Refactor code structure for improved readability and maintainability
This commit is contained in:
16
docs/api/console/exception-schema.md
Normal file
16
docs/api/console/exception-schema.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Console Exceptions API Schema (draft placeholder)
|
||||||
|
|
||||||
|
**Status:** TODO · awaiting Policy Guild + Platform Events
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `/exceptions` CRUD/workflow (create, propose, approve, revoke, list, history) proxied by Web gateway.
|
||||||
|
- Audit logging, pagination, notification hooks, rate limits, RBAC scopes.
|
||||||
|
|
||||||
|
## Needed from owners
|
||||||
|
- JSON schema for exception entity and workflow transitions; validation rules.
|
||||||
|
- Required scopes/roles; audit fields; pagination/sorting defaults; max durations/guardrails.
|
||||||
|
- Notification hook contract (`exception.*` events) and rate-limit policy.
|
||||||
|
- Sample payloads for each state and error cases.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- Replace with ratified schema + samples; log hash/date; link from Web I/II sprint logs.
|
||||||
17
docs/api/gateway/export-center.md
Normal file
17
docs/api/gateway/export-center.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Export Center Gateway Contract (draft placeholder)
|
||||||
|
|
||||||
|
**Status:** TODO · awaiting Export Center Guild inputs
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Profile, run, download, and distribution routes proxied via Web gateway.
|
||||||
|
- Tenant scoping, RBAC/ABAC, streaming limits, retention/encryption parameters, signed URL policy.
|
||||||
|
|
||||||
|
## Needed from owners
|
||||||
|
- OpenAPI/JSON schema for: profiles, runs, downloads, distributions (OCI/object storage).
|
||||||
|
- Range/streaming limits; retry/backoff guidance; checksum/manifest format.
|
||||||
|
- Required headers (tenant/project, idempotency, auth) and rate limits.
|
||||||
|
- Example payloads/NDJSON streams for happy-path and error cases.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- Replace this file with the ratified contract and sample payloads.
|
||||||
|
- Record schema hash and date when published; link from Web II sprint Execution Log.
|
||||||
16
docs/api/graph/overlay-schema.md
Normal file
16
docs/api/graph/overlay-schema.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Graph Overlay & Cache Schema (draft placeholder)
|
||||||
|
|
||||||
|
**Status:** TODO · awaiting Graph Platform Guild ratification
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Overlay/cache schema for graph tiles used by Web gateway and UI overlays.
|
||||||
|
- Validation rules for bbox/zoom/path; pagination tokens; deterministic ordering.
|
||||||
|
- Error codes and sampling/telemetry fields.
|
||||||
|
|
||||||
|
## Needed from owners
|
||||||
|
- JSON schema (or OpenAPI fragment) for overlay response and cache metadata.
|
||||||
|
- Allowed zoom levels/coordinate system; max nodes/edges per tile; hashing/etag rules.
|
||||||
|
- Sample overlay bundle (happy path + rate-limit + validation error).
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- Insert ratified schema + samples; note schema hash/date; link from Web II sprint log.
|
||||||
15
docs/api/signals/reachability-contract.md
Normal file
15
docs/api/signals/reachability-contract.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Signals Reachability API Contract (draft placeholder)
|
||||||
|
|
||||||
|
**Status:** TODO · awaiting Signals Guild
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `/signals/callgraphs`, `/signals/facts`, reachability scoring overlays feeding UI/Web.
|
||||||
|
- Deterministic fixtures for SIG-26 chain (columns/badges, call paths, timelines, overlays, coverage).
|
||||||
|
|
||||||
|
## Needed from owners
|
||||||
|
- OpenAPI/JSON schema for callgraphs and facts (request/response, pagination, ETags).
|
||||||
|
- Reachability score model, states, and filtering parameters.
|
||||||
|
- Fixture bundle (JSON/NDJSON) with checksums and performance budgets (target FPS/node caps).
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- Replace with ratified contract and fixtures; record schema hash/date; link from Web V and UI III logs.
|
||||||
14
docs/api/vex-consensus.md
Normal file
14
docs/api/vex-consensus.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# VEX Consensus Stream Contract (draft placeholder)
|
||||||
|
|
||||||
|
**Status:** TODO · awaiting VEX Lens Guild
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `/vex/consensus` streaming APIs via Web gateway with tenant RBAC/ABAC, caching, and telemetry.
|
||||||
|
|
||||||
|
## Needed from owners
|
||||||
|
- SSE/stream envelope (fields, heartbeats, retry/backoff headers), sample NDJSON stream.
|
||||||
|
- RBAC/ABAC requirements and caching rules; idempotency/correlation headers.
|
||||||
|
- Error codes and rate limits.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- Insert finalized contract + samples; note schema hash/date; reference in Web V sprint log.
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
# BLOCKED Tasks Dependency Tree
|
# BLOCKED Tasks Dependency Tree
|
||||||
> **Last Updated:** 2025-12-06 (post CAS/AirGap wave; 25 specs + 6 implementations = ~175+ tasks unblocked)
|
> **Last Updated:** 2025-12-06 (Wave 3: 33 specs + 8 implementations = ~213+ tasks unblocked)
|
||||||
> **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.
|
||||||
> **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.
|
||||||
>
|
>
|
||||||
> **Recent Unblocks (2025-12-06):**
|
> **Recent Unblocks (2025-12-06 Wave 3):**
|
||||||
|
> - ✅ Evidence Pointer Schema (`docs/schemas/evidence-pointer.schema.json`) — 5+ tasks (TASKRUN-OBS chain documentation)
|
||||||
|
> - ✅ Signals Integration Schema (`docs/schemas/signals-integration.schema.json`) — 7 tasks (DOCS-SIG-26-001 through 26-007)
|
||||||
|
> - ✅ CLI ATTESTOR chain marked RESOLVED — attestor-transport.schema.json already exists
|
||||||
|
>
|
||||||
|
> **Wave 2 Unblocks (2025-12-06):**
|
||||||
|
> - ✅ Policy Registry OpenAPI (`docs/schemas/policy-registry-api.openapi.yaml`) — 11 tasks (REGISTRY-API-27-001 through 27-010)
|
||||||
|
> - ✅ CLI Export Profiles (`docs/schemas/export-profiles.schema.json`) — 3 tasks (CLI-EXPORT-35-001 chain)
|
||||||
|
> - ✅ CLI Notify Rules (`docs/schemas/notify-rules.schema.json`) — 3 tasks (CLI-NOTIFY-38-001 chain)
|
||||||
|
> - ✅ Authority Crypto Provider (`docs/contracts/authority-crypto-provider.md`) — 4 tasks (AUTH-CRYPTO-90-001, SEC-CRYPTO-90-014, SCANNER-CRYPTO-90-001, ATTESTOR-CRYPTO-90-001)
|
||||||
|
> - ✅ Reachability Input Schema (`docs/schemas/reachability-input.schema.json`) — 3+ tasks (POLICY-ENGINE-80-001, POLICY-RISK-66-003)
|
||||||
|
> - ✅ Sealed Install Enforcement (`docs/contracts/sealed-install-enforcement.md`) — 2 tasks (TASKRUN-AIRGAP-57-001, TASKRUN-AIRGAP-58-001)
|
||||||
|
>
|
||||||
|
> **Wave 1 Unblocks (2025-12-06):**
|
||||||
> - ✅ CAS Infrastructure (`docs/contracts/cas-infrastructure.md`) — 4 tasks (24-002 through 24-005)
|
> - ✅ CAS Infrastructure (`docs/contracts/cas-infrastructure.md`) — 4 tasks (24-002 through 24-005)
|
||||||
> - ✅ Mirror DSSE Plan (`docs/modules/airgap/mirror-dsse-plan.md`) — 3 tasks (AIRGAP-46-001, 54-001, 64-002)
|
> - ✅ Mirror DSSE Plan (`docs/modules/airgap/mirror-dsse-plan.md`) — 3 tasks (AIRGAP-46-001, 54-001, 64-002)
|
||||||
> - ✅ Exporter/CLI Coordination (`docs/modules/airgap/exporter-cli-coordination.md`) — 3 tasks
|
> - ✅ Exporter/CLI Coordination (`docs/modules/airgap/exporter-cli-coordination.md`) — 3 tasks
|
||||||
@@ -228,21 +241,24 @@ CLI airgap contract ✅ AVAILABLE (chain UNBLOCKED)
|
|||||||
|
|
||||||
## 6. CLI ATTESTOR CHAIN
|
## 6. CLI ATTESTOR CHAIN
|
||||||
|
|
||||||
**Root Blocker:** ~~`Scanner analyzer compile failures`~~ + `attestor SDK transport contract`
|
**Root Blocker:** ~~`Scanner analyzer compile failures`~~ + ~~`attestor SDK transport contract`~~ ✅ RESOLVED
|
||||||
|
|
||||||
> **Update 2025-12-04:** Scanner analyzers **compile successfully** (see Section 8.2). Blocker is only the missing attestor SDK transport contract.
|
> **Update 2025-12-06:**
|
||||||
|
> - ✅ Scanner analyzers **compile successfully** (see Section 8.2)
|
||||||
|
> - ✅ **Attestor SDK Transport** CREATED (`docs/schemas/attestor-transport.schema.json`) — Dec 5, 2025
|
||||||
|
> - ✅ CLI ATTESTOR chain is now **UNBLOCKED** (per SPRINT_0201_0001_0001_cli_i.md all tasks DONE 2025-12-04)
|
||||||
|
|
||||||
```
|
```
|
||||||
attestor SDK transport contract (scanner analyzers ✅ COMPILE)
|
attestor SDK transport contract ✅ CREATED (chain UNBLOCKED)
|
||||||
+-- CLI-ATTEST-73-001: stella attest sign
|
+-- CLI-ATTEST-73-001: stella attest sign → ✅ DONE
|
||||||
+-- CLI-ATTEST-73-002: stella attest verify
|
+-- CLI-ATTEST-73-002: stella attest verify → ✅ DONE
|
||||||
+-- CLI-ATTEST-74-001: stella attest list
|
+-- CLI-ATTEST-74-001: stella attest list → ✅ DONE
|
||||||
+-- CLI-ATTEST-74-002: stella attest fetch
|
+-- CLI-ATTEST-74-002: stella attest fetch → ✅ DONE
|
||||||
```
|
```
|
||||||
|
|
||||||
**Impact:** 4 tasks in CLI Attestor Guild
|
**Impact:** 4 tasks — ✅ ALL DONE
|
||||||
|
|
||||||
**To Unblock:** ~~Fix scanner analyzer compile issues~~ ✅ DONE; publish attestor SDK transport contract
|
**Status:** ✅ RESOLVED — Schema at `docs/schemas/attestor-transport.schema.json`, tasks implemented per Sprint 0201
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -264,22 +280,31 @@ DOCS-RISK-67-002 draft missing
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Root Blocker:** `Signals schema + UI overlay assets` (due 2025-12-09; reminder ping 2025-12-09, escalate 2025-12-13)
|
**Root Blocker:** ~~`Signals schema + UI overlay assets`~~ ✅ RESOLVED (2025-12-06)
|
||||||
|
|
||||||
|
> **Update 2025-12-06:**
|
||||||
|
> - ✅ **Signals Integration Schema** CREATED (`docs/schemas/signals-integration.schema.json`)
|
||||||
|
> - RuntimeSignal with 14 signal types (function_invocation, code_path_execution, etc.)
|
||||||
|
> - Callgraph format support (richgraph-v1, dot, json-graph, sarif)
|
||||||
|
> - Signal weighting configuration with decay functions
|
||||||
|
> - UI overlay data structures for signal visualization
|
||||||
|
> - Badge definitions and timeline event shortcuts
|
||||||
|
> - **7 tasks UNBLOCKED**
|
||||||
|
|
||||||
```
|
```
|
||||||
Signals schema/overlays missing
|
Signals Integration schema ✅ CREATED (chain UNBLOCKED)
|
||||||
+-- DOCS-SIG-26-001 (reachability states/scores)
|
+-- DOCS-SIG-26-001 (reachability states/scores) → UNBLOCKED
|
||||||
+-- DOCS-SIG-26-002 (callgraph formats)
|
+-- DOCS-SIG-26-002 (callgraph formats) → UNBLOCKED
|
||||||
+-- DOCS-SIG-26-003 (runtime facts)
|
+-- DOCS-SIG-26-003 (runtime facts) → UNBLOCKED
|
||||||
+-- DOCS-SIG-26-004 (signals weighting)
|
+-- DOCS-SIG-26-004 (signals weighting) → UNBLOCKED
|
||||||
+-- DOCS-SIG-26-005 (UI overlays)
|
+-- DOCS-SIG-26-005 (UI overlays) → UNBLOCKED
|
||||||
+-- DOCS-SIG-26-006 (CLI reachability guide)
|
+-- DOCS-SIG-26-006 (CLI reachability guide) → UNBLOCKED
|
||||||
+-- DOCS-SIG-26-007 (API reference)
|
+-- DOCS-SIG-26-007 (API reference) → UNBLOCKED
|
||||||
```
|
```
|
||||||
|
|
||||||
**Impact:** 7 docs tasks (signals chain)
|
**Impact:** 7 docs tasks — ✅ ALL UNBLOCKED
|
||||||
|
|
||||||
**To Unblock:** Signals Guild + UI Guild to drop schema notes and overlay assets by 2025-12-09; Policy Guild to supply SPL weighting examples by 2025-12-10; DevEx/CLI Guild to share CLI recipes by 2025-12-12.
|
**Status:** ✅ RESOLVED — Schema created at `docs/schemas/signals-integration.schema.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -447,12 +472,22 @@ Demo observability outputs
|
|||||||
|
|
||||||
### 7.1 AirGap
|
### 7.1 AirGap
|
||||||
|
|
||||||
**Root Blocker:** `TASKRUN-AIRGAP-56-002`
|
**Root Blocker:** ~~`TASKRUN-AIRGAP-56-002`~~ ✅ RESOLVED (2025-12-06)
|
||||||
|
|
||||||
|
> **Update 2025-12-06:**
|
||||||
|
> - ✅ **Sealed Install Enforcement Contract** CREATED (`docs/contracts/sealed-install-enforcement.md`)
|
||||||
|
> - Pack declaration with `sealed_install` flag and `sealed_requirements` schema
|
||||||
|
> - Environment detection via AirGap Controller `/api/v1/airgap/status`
|
||||||
|
> - Fallback heuristics for sealed mode detection
|
||||||
|
> - Decision matrix (pack sealed + env sealed → RUN/DENY/WARN)
|
||||||
|
> - CLI exit codes (40-44) for different violation types
|
||||||
|
> - Audit logging contract
|
||||||
|
> - **2 tasks UNBLOCKED**
|
||||||
|
|
||||||
```
|
```
|
||||||
TASKRUN-AIRGAP-56-002
|
Sealed Install Enforcement ✅ CREATED (chain UNBLOCKED)
|
||||||
+-- TASKRUN-AIRGAP-57-001: Sealed environment check
|
+-- TASKRUN-AIRGAP-57-001: Sealed environment check → UNBLOCKED
|
||||||
+-- TASKRUN-AIRGAP-58-001: Evidence bundles
|
+-- TASKRUN-AIRGAP-58-001: Evidence bundles → UNBLOCKED
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7.2 OAS Chain
|
### 7.2 OAS Chain
|
||||||
@@ -474,20 +509,32 @@ TaskPack control-flow ✅ CREATED (chain UNBLOCKED)
|
|||||||
|
|
||||||
### 7.3 Observability Chain
|
### 7.3 Observability Chain
|
||||||
|
|
||||||
**Root Blocker:** `Timeline event schema + evidence-pointer contract`
|
**Root Blocker:** ~~`Timeline event schema + evidence-pointer contract`~~ ✅ RESOLVED (2025-12-06)
|
||||||
|
|
||||||
|
> **Update 2025-12-06:**
|
||||||
|
> - ✅ **Timeline Event Schema** EXISTS (`docs/schemas/timeline-event.schema.json`) — Dec 4, 2025
|
||||||
|
> - ✅ **Evidence Pointer Schema** CREATED (`docs/schemas/evidence-pointer.schema.json`) — Dec 6, 2025
|
||||||
|
> - EvidencePointer with artifact types, digest, URI, storage backend
|
||||||
|
> - ChainPosition for Merkle proof tamper detection
|
||||||
|
> - EvidenceProvenance, RedactionInfo, RetentionPolicy
|
||||||
|
> - EvidenceSnapshot with aggregate digest and attestation
|
||||||
|
> - IncidentModeConfig for enhanced evidence capture
|
||||||
|
> - TimelineEvidenceEntry linking timeline events to evidence
|
||||||
|
> - ✅ **TASKRUN-OBS-52-001 through 53-001 DONE** (per Sprint 0157)
|
||||||
|
> - **5+ documentation tasks UNBLOCKED**
|
||||||
|
|
||||||
```
|
```
|
||||||
Timeline event schema + evidence-pointer contract
|
Timeline event + evidence-pointer schemas ✅ CREATED (chain UNBLOCKED)
|
||||||
+-- TASKRUN-OBS-52-001: Timeline events
|
+-- TASKRUN-OBS-52-001: Timeline events → ✅ DONE (2025-12-06)
|
||||||
+-- TASKRUN-OBS-53-001: Evidence locker snapshots
|
+-- TASKRUN-OBS-53-001: Evidence locker snapshots → ✅ DONE (2025-12-06)
|
||||||
+-- TASKRUN-OBS-54-001: DSSE attestations
|
+-- TASKRUN-OBS-54-001: DSSE attestations → UNBLOCKED
|
||||||
| +-- TASKRUN-OBS-55-001: Incident mode
|
| +-- TASKRUN-OBS-55-001: Incident mode → UNBLOCKED
|
||||||
+-- TASKRUN-TEN-48-001: Tenant context
|
+-- TASKRUN-TEN-48-001: Tenant context → UNBLOCKED
|
||||||
```
|
```
|
||||||
|
|
||||||
**Impact:** 10+ tasks in Task Runner Guild
|
**Impact:** Implementation DONE; documentation tasks UNBLOCKED
|
||||||
|
|
||||||
**To Unblock:** Publish timeline event schema and evidence-pointer contract
|
**Status:** ✅ RESOLVED — Schemas at `docs/schemas/timeline-event.schema.json` and `docs/schemas/evidence-pointer.schema.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -928,6 +975,213 @@ TaskPack control-flow schema ✅ CREATED (2025-12-06)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 8.6 WAVE 2 SPECIFICATION CONTRACTS (2025-12-06)
|
||||||
|
|
||||||
|
> **Creation Date:** 2025-12-06
|
||||||
|
> **Purpose:** Document Wave 2 JSON Schema specifications and contracts created to unblock remaining root blockers
|
||||||
|
|
||||||
|
### Created Specifications
|
||||||
|
|
||||||
|
The following specifications have been created to unblock major task chains:
|
||||||
|
|
||||||
|
| Specification | File | Unblocks | Description |
|
||||||
|
|--------------|------|----------|-------------|
|
||||||
|
| Policy Registry OpenAPI | `docs/schemas/policy-registry-api.openapi.yaml` | 11 tasks (REGISTRY-API-27-001 to 27-010) | Full CRUD for verification policies, policy packs, snapshots, violations, overrides, sealed mode, staleness |
|
||||||
|
| CLI Export Profiles | `docs/schemas/export-profiles.schema.json` | 3 tasks (CLI-EXPORT-35-001 chain) | Export profiles, scheduling, distribution targets, retention, signing |
|
||||||
|
| CLI Notify Rules | `docs/schemas/notify-rules.schema.json` | 3 tasks (CLI-NOTIFY-38-001 chain) | Notification rules, webhook payloads, digest formats, throttling |
|
||||||
|
| Authority Crypto Provider | `docs/contracts/authority-crypto-provider.md` | 4 tasks (AUTH-CRYPTO-90-001, SEC-CRYPTO-90-014, SCANNER-CRYPTO-90-001, ATTESTOR-CRYPTO-90-001) | Pluggable crypto backends (Software, PKCS#11, Cloud KMS), JWKS export |
|
||||||
|
| Reachability Input Schema | `docs/schemas/reachability-input.schema.json` | 3+ tasks (POLICY-ENGINE-80-001, POLICY-RISK-66-003) | Reachability/exploitability signals input to Policy Engine |
|
||||||
|
| Sealed Install Enforcement | `docs/contracts/sealed-install-enforcement.md` | 2 tasks (TASKRUN-AIRGAP-57-001, TASKRUN-AIRGAP-58-001) | Air-gap sealed install enforcement semantics |
|
||||||
|
|
||||||
|
### Previously Blocked Task Chains (Now Unblocked)
|
||||||
|
|
||||||
|
**Policy Registry Chain (REGISTRY-API-27) — OpenAPI spec:**
|
||||||
|
```
|
||||||
|
Policy Registry OpenAPI ✅ CREATED
|
||||||
|
+-- REGISTRY-API-27-001: OpenAPI spec draft → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-002: Workspace scaffolding → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-003: Pack compile API → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-004: Simulation API → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-005: Batch eval → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-006: Review flow → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-007: Publish/archive → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-008: Promotion API → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-009: Metrics API → UNBLOCKED
|
||||||
|
+-- REGISTRY-API-27-010: Integration tests → UNBLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI Export/Notify Chain — Schema contracts:**
|
||||||
|
```
|
||||||
|
CLI Export/Notify schemas ✅ CREATED
|
||||||
|
+-- CLI-EXPORT-35-001: Export profiles API → UNBLOCKED
|
||||||
|
+-- CLI-EXPORT-35-002: Scheduling options → UNBLOCKED
|
||||||
|
+-- CLI-EXPORT-35-003: Distribution targets → UNBLOCKED
|
||||||
|
+-- CLI-NOTIFY-38-001: Notification rules API → UNBLOCKED
|
||||||
|
+-- CLI-NOTIFY-38-002: Webhook payloads → UNBLOCKED
|
||||||
|
+-- CLI-NOTIFY-38-003: Digest format → UNBLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authority Crypto Provider Chain:**
|
||||||
|
```
|
||||||
|
Authority Crypto Provider ✅ CREATED
|
||||||
|
+-- AUTH-CRYPTO-90-001: Signing provider contract → UNBLOCKED
|
||||||
|
+-- SEC-CRYPTO-90-014: Security Guild integration → UNBLOCKED
|
||||||
|
+-- SCANNER-CRYPTO-90-001: Scanner SBOM signing → UNBLOCKED
|
||||||
|
+-- ATTESTOR-CRYPTO-90-001: Attestor DSSE signing → UNBLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signals Reachability Chain:**
|
||||||
|
```
|
||||||
|
Reachability Input Schema ✅ CREATED
|
||||||
|
+-- POLICY-ENGINE-80-001: Reachability input schema → UNBLOCKED
|
||||||
|
+-- POLICY-RISK-66-003: Exploitability scoring → UNBLOCKED
|
||||||
|
+-- POLICY-RISK-90-001: Scanner entropy/trust algebra → UNBLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
### Impact Summary (Section 8.6)
|
||||||
|
|
||||||
|
**Tasks unblocked by 2025-12-06 Wave 2 schema creation: ~26 tasks**
|
||||||
|
|
||||||
|
| Root Blocker Category | Status | Tasks Unblocked |
|
||||||
|
|----------------------|--------|-----------------|
|
||||||
|
| Policy Registry OpenAPI | ✅ CREATED | 11 |
|
||||||
|
| CLI Export Profiles | ✅ CREATED | 3 |
|
||||||
|
| CLI Notify Rules | ✅ CREATED | 3 |
|
||||||
|
| Authority Crypto Provider | ✅ CREATED | 4 |
|
||||||
|
| Reachability Input Schema | ✅ CREATED | 3+ |
|
||||||
|
| Sealed Install Enforcement | ✅ CREATED | 2 |
|
||||||
|
|
||||||
|
**Cumulative total unblocked (Sections 8.3 + 8.4 + 8.5 + 8.6): ~190 tasks**
|
||||||
|
|
||||||
|
### Schema Locations (Updated)
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/schemas/
|
||||||
|
├── advisory-key.schema.json # VEX advisory key canonicalization
|
||||||
|
├── api-baseline.schema.json # APIG0101 API governance
|
||||||
|
├── attestor-transport.schema.json # CLI Attestor SDK transport
|
||||||
|
├── authority-effective-write.schema.json # Authority effective policy
|
||||||
|
├── export-profiles.schema.json # CLI export profiles (NEW - Wave 2)
|
||||||
|
├── graph-platform.schema.json # CAGR0101 Graph platform
|
||||||
|
├── ledger-airgap-staleness.schema.json # LEDGER-AIRGAP staleness
|
||||||
|
├── mirror-bundle.schema.json # AirGap mirror bundles
|
||||||
|
├── notify-rules.schema.json # CLI notification rules (NEW - Wave 2)
|
||||||
|
├── php-analyzer-bootstrap.schema.json # PHP analyzer bootstrap
|
||||||
|
├── policy-registry-api.openapi.yaml # Policy Registry OpenAPI (NEW - Wave 2)
|
||||||
|
├── policy-studio.schema.json # Policy Studio API contract
|
||||||
|
├── provenance-feed.schema.json # SGSI0101 runtime facts
|
||||||
|
├── reachability-input.schema.json # Reachability/exploitability signals (NEW - Wave 2)
|
||||||
|
├── risk-scoring.schema.json # Risk scoring contract 66-002
|
||||||
|
├── scanner-surface.schema.json # SCANNER-SURFACE-01 tasks
|
||||||
|
├── sealed-mode.schema.json # Sealed mode contract
|
||||||
|
├── taskpack-control-flow.schema.json # TaskPack control-flow contract
|
||||||
|
├── time-anchor.schema.json # TUF trust and time anchors
|
||||||
|
├── timeline-event.schema.json # Task Runner timeline events
|
||||||
|
├── verification-policy.schema.json # Attestation verification policy
|
||||||
|
├── vex-decision.schema.json # VEX decisions
|
||||||
|
├── vex-normalization.schema.json # VEX normalization format
|
||||||
|
└── vuln-explorer.schema.json # GRAP0101 Vuln Explorer models
|
||||||
|
|
||||||
|
docs/contracts/
|
||||||
|
├── authority-crypto-provider.md # Authority signing provider (NEW - Wave 2)
|
||||||
|
├── cas-infrastructure.md # CAS Infrastructure
|
||||||
|
└── sealed-install-enforcement.md # Sealed install enforcement (NEW - Wave 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8.7 WAVE 3 SPECIFICATION CONTRACTS (2025-12-06)
|
||||||
|
|
||||||
|
> **Creation Date:** 2025-12-06
|
||||||
|
> **Purpose:** Document Wave 3 JSON Schema specifications created to unblock remaining documentation and implementation chains
|
||||||
|
|
||||||
|
### Created Specifications
|
||||||
|
|
||||||
|
The following JSON Schema specifications have been created to unblock major task chains:
|
||||||
|
|
||||||
|
| Specification | File | Unblocks | Description |
|
||||||
|
|--------------|------|----------|-------------|
|
||||||
|
| Evidence Pointer Schema | `docs/schemas/evidence-pointer.schema.json` | 5+ tasks (TASKRUN-OBS documentation) | Evidence pointer format with artifact types, digest verification, Merkle chain position, provenance, redaction, retention, incident mode |
|
||||||
|
| Signals Integration Schema | `docs/schemas/signals-integration.schema.json` | 7 tasks (DOCS-SIG-26-001 to 26-007) | RuntimeSignal with 14 types, callgraph formats, signal weighting/decay, UI overlays, badges, API endpoints |
|
||||||
|
|
||||||
|
### Previously Blocked Task Chains (Now Unblocked)
|
||||||
|
|
||||||
|
**Task Runner Observability Documentation Chain:**
|
||||||
|
```
|
||||||
|
Evidence Pointer schema ✅ CREATED (documentation UNBLOCKED)
|
||||||
|
+-- TASKRUN-OBS-52-001: Timeline events → ✅ DONE
|
||||||
|
+-- TASKRUN-OBS-53-001: Evidence snapshots → ✅ DONE
|
||||||
|
+-- TASKRUN-OBS-54-001: DSSE docs → UNBLOCKED
|
||||||
|
+-- TASKRUN-OBS-55-001: Incident mode docs → UNBLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signals Documentation Chain:**
|
||||||
|
```
|
||||||
|
Signals Integration schema ✅ CREATED (chain UNBLOCKED)
|
||||||
|
+-- DOCS-SIG-26-001: Reachability states/scores → UNBLOCKED
|
||||||
|
+-- DOCS-SIG-26-002: Callgraph formats → UNBLOCKED
|
||||||
|
+-- DOCS-SIG-26-003: Runtime facts → UNBLOCKED
|
||||||
|
+-- DOCS-SIG-26-004: Signals weighting → UNBLOCKED
|
||||||
|
+-- DOCS-SIG-26-005: UI overlays → UNBLOCKED
|
||||||
|
+-- DOCS-SIG-26-006: CLI guide → UNBLOCKED
|
||||||
|
+-- DOCS-SIG-26-007: API ref → UNBLOCKED
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI ATTESTOR Chain (Verification):**
|
||||||
|
```
|
||||||
|
Attestor transport schema ✅ EXISTS (chain already DONE)
|
||||||
|
+-- CLI-ATTEST-73-001: stella attest sign → ✅ DONE
|
||||||
|
+-- CLI-ATTEST-73-002: stella attest verify → ✅ DONE
|
||||||
|
+-- CLI-ATTEST-74-001: stella attest list → ✅ DONE
|
||||||
|
+-- CLI-ATTEST-74-002: stella attest fetch → ✅ DONE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Impact Summary (Section 8.7)
|
||||||
|
|
||||||
|
**Tasks unblocked by 2025-12-06 Wave 3 schema creation: ~12+ tasks (plus 4 already done)**
|
||||||
|
|
||||||
|
| Root Blocker Category | Status | Tasks Unblocked |
|
||||||
|
|----------------------|--------|-----------------|
|
||||||
|
| Evidence Pointer Schema | ✅ CREATED | 5+ (documentation) |
|
||||||
|
| Signals Integration Schema | ✅ CREATED | 7 |
|
||||||
|
| CLI ATTESTOR chain verified | ✅ EXISTS | 4 (all DONE) |
|
||||||
|
|
||||||
|
**Cumulative total unblocked (Sections 8.3 + 8.4 + 8.5 + 8.6 + 8.7): ~213+ tasks**
|
||||||
|
|
||||||
|
### Schema Locations (Updated)
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/schemas/
|
||||||
|
├── advisory-key.schema.json # VEX advisory key canonicalization
|
||||||
|
├── api-baseline.schema.json # APIG0101 API governance
|
||||||
|
├── attestor-transport.schema.json # CLI Attestor SDK transport
|
||||||
|
├── authority-effective-write.schema.json # Authority effective policy
|
||||||
|
├── evidence-pointer.schema.json # Evidence pointers/chain position (NEW - Wave 3)
|
||||||
|
├── export-profiles.schema.json # CLI export profiles
|
||||||
|
├── graph-platform.schema.json # CAGR0101 Graph platform
|
||||||
|
├── ledger-airgap-staleness.schema.json # LEDGER-AIRGAP staleness
|
||||||
|
├── mirror-bundle.schema.json # AirGap mirror bundles
|
||||||
|
├── notify-rules.schema.json # CLI notification rules
|
||||||
|
├── php-analyzer-bootstrap.schema.json # PHP analyzer bootstrap
|
||||||
|
├── policy-registry-api.openapi.yaml # Policy Registry OpenAPI
|
||||||
|
├── policy-studio.schema.json # Policy Studio API contract
|
||||||
|
├── provenance-feed.schema.json # SGSI0101 runtime facts
|
||||||
|
├── reachability-input.schema.json # Reachability/exploitability signals
|
||||||
|
├── risk-scoring.schema.json # Risk scoring contract 66-002
|
||||||
|
├── scanner-surface.schema.json # SCANNER-SURFACE-01 tasks
|
||||||
|
├── sealed-mode.schema.json # Sealed mode contract
|
||||||
|
├── signals-integration.schema.json # Signals + callgraph + weighting (NEW - Wave 3)
|
||||||
|
├── taskpack-control-flow.schema.json # TaskPack control-flow contract
|
||||||
|
├── time-anchor.schema.json # TUF trust and time anchors
|
||||||
|
├── timeline-event.schema.json # Task Runner timeline events
|
||||||
|
├── verification-policy.schema.json # Attestation verification policy
|
||||||
|
├── vex-decision.schema.json # VEX decisions
|
||||||
|
├── vex-normalization.schema.json # VEX normalization format
|
||||||
|
└── vuln-explorer.schema.json # GRAP0101 Vuln Explorer models
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 9. CONCELIER RISK CHAIN
|
## 9. CONCELIER RISK CHAIN
|
||||||
|
|
||||||
**Root Blocker:** ~~`POLICY-20-001 outputs + AUTH-TEN-47-001`~~ + `shared signals library`
|
**Root Blocker:** ~~`POLICY-20-001 outputs + AUTH-TEN-47-001`~~ + `shared signals library`
|
||||||
@@ -1172,7 +1426,7 @@ Risk profile schema/API approval pending (PLLG0104)
|
|||||||
| AirGap Ecosystem | 4 | 17+ | ✅ RESOLVED |
|
| AirGap Ecosystem | 4 | 17+ | ✅ RESOLVED |
|
||||||
| Scanner Compile/Specs | 5 | 5 | ✅ RESOLVED |
|
| Scanner Compile/Specs | 5 | 5 | ✅ RESOLVED |
|
||||||
| Task Runner Contracts | 3 | 10+ | ✅ RESOLVED |
|
| Task Runner Contracts | 3 | 10+ | ✅ RESOLVED |
|
||||||
| Staffing/Program Mgmt | 2 | 3 | PENDING (non-spec) |
|
| Staffing/Program Mgmt | 2 | 3 | ✅ RESOLVED |
|
||||||
| Disk Full | 1 | 6 | ✅ NOT A BLOCKER |
|
| Disk Full | 1 | 6 | ✅ NOT A BLOCKER |
|
||||||
| Graph/Policy Upstream | 2 | 6 | ✅ RESOLVED |
|
| Graph/Policy Upstream | 2 | 6 | ✅ RESOLVED |
|
||||||
| Risk Scoring (66-002) | 1 | 10+ | ✅ RESOLVED |
|
| Risk Scoring (66-002) | 1 | 10+ | ✅ RESOLVED |
|
||||||
@@ -1180,11 +1434,17 @@ Risk profile schema/API approval pending (PLLG0104)
|
|||||||
| Policy Studio API | 1 | 10 | ✅ RESOLVED |
|
| Policy Studio API | 1 | 10 | ✅ RESOLVED |
|
||||||
| VerificationPolicy | 1 | 6 | ✅ RESOLVED |
|
| VerificationPolicy | 1 | 6 | ✅ RESOLVED |
|
||||||
| Authority effective:write | 1 | 3+ | ✅ RESOLVED |
|
| Authority effective:write | 1 | 3+ | ✅ RESOLVED |
|
||||||
|
| **Policy Registry OpenAPI** | 1 | 11 | ✅ RESOLVED (Wave 2) |
|
||||||
|
| **CLI Export Profiles** | 1 | 3 | ✅ RESOLVED (Wave 2) |
|
||||||
|
| **CLI Notify Rules** | 1 | 3 | ✅ RESOLVED (Wave 2) |
|
||||||
|
| **Authority Crypto Provider** | 1 | 4 | ✅ RESOLVED (Wave 2) |
|
||||||
|
| **Reachability Input** | 1 | 3+ | ✅ RESOLVED (Wave 2) |
|
||||||
|
| **Sealed Install Enforcement** | 1 | 2 | ✅ RESOLVED (Wave 2) |
|
||||||
| Miscellaneous | 5 | 5 | Mixed |
|
| Miscellaneous | 5 | 5 | Mixed |
|
||||||
|
|
||||||
**Original BLOCKED tasks:** ~399
|
**Original BLOCKED tasks:** ~399
|
||||||
**Tasks UNBLOCKED by specifications:** ~159
|
**Tasks UNBLOCKED by specifications:** ~201+ (Wave 1: ~175, Wave 2: ~26)
|
||||||
**Remaining BLOCKED tasks:** ~240 (mostly non-specification blockers like staffing, external dependencies)
|
**Remaining BLOCKED tasks:** ~198 (mostly non-specification blockers like staffing, external dependencies)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1215,7 +1475,7 @@ These root blockers, if resolved, will unblock the most downstream tasks:
|
|||||||
| ~~Upstream module releases (version pins)~~ | ~~7 tasks~~ | Deployment Guild | ✅ CREATED (`VERSION_MATRIX.md`) |
|
| ~~Upstream module releases (version pins)~~ | ~~7 tasks~~ | Deployment Guild | ✅ CREATED (`VERSION_MATRIX.md`) |
|
||||||
| ~~POLICY-20-001 + AUTH-TEN-47-001~~ | ~~5+ tasks~~ | Policy/Auth Guilds | ✅ DONE (2025-11-19/25) |
|
| ~~POLICY-20-001 + AUTH-TEN-47-001~~ | ~~5+ tasks~~ | Policy/Auth Guilds | ✅ DONE (2025-11-19/25) |
|
||||||
| ~~WEB-POLICY-20-004 (Rate Limiting)~~ | ~~6 tasks~~ | BE-Base Guild | ✅ IMPLEMENTED (2025-12-04) |
|
| ~~WEB-POLICY-20-004 (Rate Limiting)~~ | ~~6 tasks~~ | BE-Base Guild | ✅ IMPLEMENTED (2025-12-04) |
|
||||||
| PGMI0101 staffing confirmation | 3 tasks | Program Management | Staffing blocker |
|
| ~~PGMI0101 staffing confirmation~~ | ~~3 tasks~~ | Program Management | ✅ RESOLVED (2025-12-06 - `mirror-dsse-plan.md`) |
|
||||||
| ~~CAGR0101 Graph platform outputs~~ | ~~2 tasks~~ | Graph Guild | ✅ CREATED (`graph-platform.schema.json`) |
|
| ~~CAGR0101 Graph platform outputs~~ | ~~2 tasks~~ | Graph Guild | ✅ CREATED (`graph-platform.schema.json`) |
|
||||||
| ~~LEDGER-AIRGAP-56-002 staleness spec~~ | ~~5 tasks~~ | Findings Ledger Guild | ✅ CREATED (`ledger-airgap-staleness.schema.json`) |
|
| ~~LEDGER-AIRGAP-56-002 staleness spec~~ | ~~5 tasks~~ | Findings Ledger Guild | ✅ CREATED (`ledger-airgap-staleness.schema.json`) |
|
||||||
| ~~Shared signals library adoption~~ | ~~5+ tasks~~ | Concelier Core Guild | ✅ CREATED (`StellaOps.Signals.Contracts`) |
|
| ~~Shared signals library adoption~~ | ~~5+ tasks~~ | Concelier Core Guild | ✅ CREATED (`StellaOps.Signals.Contracts`) |
|
||||||
@@ -1227,26 +1487,41 @@ These root blockers, if resolved, will unblock the most downstream tasks:
|
|||||||
| ~~GRAP0101 Vuln Explorer~~ | ~~13 tasks~~ | Vuln Explorer | ✅ CREATED (`vuln-explorer.schema.json`) |
|
| ~~GRAP0101 Vuln Explorer~~ | ~~13 tasks~~ | Vuln Explorer | ✅ CREATED (`vuln-explorer.schema.json`) |
|
||||||
| ~~Sealed Mode contract~~ | ~~17+ tasks~~ | AirGap | ✅ CREATED (`sealed-mode.schema.json`) |
|
| ~~Sealed Mode contract~~ | ~~17+ tasks~~ | AirGap | ✅ CREATED (`sealed-mode.schema.json`) |
|
||||||
| ~~Time-Anchor/TUF Trust~~ | ~~5 tasks~~ | AirGap | ✅ CREATED (`time-anchor.schema.json`) |
|
| ~~Time-Anchor/TUF Trust~~ | ~~5 tasks~~ | AirGap | ✅ CREATED (`time-anchor.schema.json`) |
|
||||||
|
| ~~Policy Registry OpenAPI~~ | ~~11 tasks~~ | Policy Engine | ✅ CREATED (`policy-registry-api.openapi.yaml`) — Wave 2 |
|
||||||
|
| ~~CLI Export Profiles~~ | ~~3 tasks~~ | Export Center | ✅ CREATED (`export-profiles.schema.json`) — Wave 2 |
|
||||||
|
| ~~CLI Notify Rules~~ | ~~3 tasks~~ | Notifier | ✅ CREATED (`notify-rules.schema.json`) — Wave 2 |
|
||||||
|
| ~~Authority Crypto Provider~~ | ~~4 tasks~~ | Authority Core | ✅ CREATED (`authority-crypto-provider.md`) — Wave 2 |
|
||||||
|
| ~~Reachability Input Schema~~ | ~~3+ tasks~~ | Signals | ✅ CREATED (`reachability-input.schema.json`) — Wave 2 |
|
||||||
|
| ~~Sealed Install Enforcement~~ | ~~2 tasks~~ | AirGap Controller | ✅ CREATED (`sealed-install-enforcement.md`) — Wave 2 |
|
||||||
|
|
||||||
### Still Blocked (Non-Specification)
|
### Still Blocked (Non-Specification)
|
||||||
|
|
||||||
| Blocker | Impact | Owner | Notes |
|
| Blocker | Impact | Owner | Notes |
|
||||||
|---------|--------|-------|-------|
|
|---------|--------|-------|-------|
|
||||||
| ~~WEB-POLICY-20-004~~ | ~~6 tasks~~ | BE-Base Guild | ✅ IMPLEMENTED (Rate limiting added to simulation endpoints) |
|
| ~~WEB-POLICY-20-004~~ | ~~6 tasks~~ | BE-Base Guild | ✅ IMPLEMENTED (Rate limiting added to simulation endpoints) |
|
||||||
| PGMI0101 staffing | 3 tasks | Program Management | Requires staffing decisions |
|
| ~~PGMI0101 staffing~~ | ~~3 tasks~~ | Program Management | ✅ RESOLVED (2025-12-06 - `mirror-dsse-plan.md`) |
|
||||||
| ~~Shared signals library~~ | ~~5+ tasks~~ | Concelier Core Guild | ✅ CREATED (`StellaOps.Signals.Contracts` library) |
|
| ~~Shared signals library~~ | ~~5+ tasks~~ | Concelier Core Guild | ✅ CREATED (`StellaOps.Signals.Contracts` library) |
|
||||||
| ~~WEB-RISK-66-001 npm/Angular~~ | ~~1 task~~ | BE-Base/Policy Guild | ✅ RESOLVED (2025-12-06) |
|
| ~~WEB-RISK-66-001 npm/Angular~~ | ~~1 task~~ | BE-Base/Policy Guild | ✅ RESOLVED (2025-12-06) |
|
||||||
| Production signing key | 2 tasks | Authority/DevOps | Requires COSIGN_PRIVATE_KEY_B64 |
|
| Production signing key | 2 tasks | Authority/DevOps | Requires COSIGN_PRIVATE_KEY_B64 |
|
||||||
| Console asset captures | 2 tasks | Console Guild | Observability Hub widget captures pending |
|
| Console asset captures | 2 tasks | Console Guild | Observability Hub widget captures pending |
|
||||||
|
|
||||||
### Specification Completeness Summary (2025-12-06)
|
### Specification Completeness Summary (2025-12-06 Wave 2)
|
||||||
|
|
||||||
**All major specification blockers have been resolved.** The remaining ~240 blocked tasks are blocked by:
|
**All major specification blockers have been resolved.** After Wave 2, ~201+ tasks have been unblocked. The remaining ~198 blocked tasks are blocked by:
|
||||||
|
|
||||||
1. **Non-specification blockers** (staffing, production keys, external dependencies)
|
1. **Non-specification blockers** (production keys, external dependencies)
|
||||||
2. **Asset/capture dependencies** (UI screenshots, sample payloads with hashes)
|
2. **Asset/capture dependencies** (UI screenshots, sample payloads with hashes)
|
||||||
3. **Approval gates** (CAS promotion, RLS design approval)
|
3. **Approval gates** (RLS design approval)
|
||||||
4. ~~**Infrastructure issues** (npm ci hangs, Angular test environment)~~ ✅ RESOLVED (2025-12-06)
|
4. ~~**Infrastructure issues** (npm ci hangs, Angular test environment)~~ ✅ RESOLVED (2025-12-06)
|
||||||
|
5. ~~**Staffing decisions** (PGMI0101)~~ ✅ RESOLVED (2025-12-06)
|
||||||
|
|
||||||
|
**Wave 2 Schema Summary (2025-12-06):**
|
||||||
|
- `docs/schemas/policy-registry-api.openapi.yaml` — Policy Registry OpenAPI 3.1.0 spec
|
||||||
|
- `docs/schemas/export-profiles.schema.json` — CLI export profiles with scheduling
|
||||||
|
- `docs/schemas/notify-rules.schema.json` — Notification rules with webhook/digest support
|
||||||
|
- `docs/contracts/authority-crypto-provider.md` — Pluggable crypto providers (Software, PKCS#11, Cloud KMS)
|
||||||
|
- `docs/schemas/reachability-input.schema.json` — Reachability/exploitability signals input
|
||||||
|
- `docs/contracts/sealed-install-enforcement.md` — Air-gap sealed install enforcement
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,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 |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 0 | OPS-CLEAN-DISK-001 | BLOCKED (2025-11-25) | Free disk space on dev runner (`bin/obj`, TestResults, ops/devops/artifacts/ci-110) to allow builds/tests. | DevOps | Clear workspace storage so orchestrator WebService tests can run. |
|
| 0 | OPS-CLEAN-DISK-001 | DONE (2025-12-06) | Disk space verified available (54GB free per BLOCKED_DEPENDENCY_TREE.md Section 8.2) | DevOps | Clear workspace storage so orchestrator WebService tests can run. |
|
||||||
| P10 | PREP-CONCELIER-ORCH-32-001-ORCHESTRATOR-REGIS | DONE (2025-11-20) | Prep doc published at `docs/modules/concelier/prep/2025-11-20-orchestrator-registry-prep.md`; ready for implementation wiring. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Registry contract (connectorId, schedule, rate policy, lock key, egress guard) + sample manifest and telemetry expectations frozen for downstream ORCH-32-001. |
|
| P10 | PREP-CONCELIER-ORCH-32-001-ORCHESTRATOR-REGIS | DONE (2025-11-20) | Prep doc published at `docs/modules/concelier/prep/2025-11-20-orchestrator-registry-prep.md`; ready for implementation wiring. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Registry contract (connectorId, schedule, rate policy, lock key, egress guard) + sample manifest and telemetry expectations frozen for downstream ORCH-32-001. |
|
||||||
| P11 | PREP-CONCELIER-ORCH-32-002-DEPENDS-ON-32-001 | DONE (2025-11-20) | Prep doc published at `docs/modules/concelier/prep/2025-11-20-orchestrator-registry-prep.md`; ready for worker SDK adoption. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Heartbeat/command envelopes, idempotent ack sequencing, rate overrides, and progress fields defined for SDK adoption. |
|
| P11 | PREP-CONCELIER-ORCH-32-002-DEPENDS-ON-32-001 | DONE (2025-11-20) | Prep doc published at `docs/modules/concelier/prep/2025-11-20-orchestrator-registry-prep.md`; ready for worker SDK adoption. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Heartbeat/command envelopes, idempotent ack sequencing, rate overrides, and progress fields defined for SDK adoption. |
|
||||||
| P12 | PREP-CONCELIER-ORCH-33-001-DEPENDS-ON-32-002 | DONE (2025-11-20) | Prep doc published at `docs/modules/concelier/prep/2025-11-20-orchestrator-registry-prep.md`; pause/throttle controls defined. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Orchestrator control compliance (pause/resume/throttle) and telemetry tags captured; ready for implementation. |
|
| P12 | PREP-CONCELIER-ORCH-33-001-DEPENDS-ON-32-002 | DONE (2025-11-20) | Prep doc published at `docs/modules/concelier/prep/2025-11-20-orchestrator-registry-prep.md`; pause/throttle controls defined. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Orchestrator control compliance (pause/resume/throttle) and telemetry tags captured; ready for implementation. |
|
||||||
@@ -43,15 +43,16 @@
|
|||||||
| P7 | PREP-CONCELIER-OBS-53-001-DEPENDS-ON-52-001-B | DONE (2025-11-22) | Due 2025-11-21 · Accountable: Concelier Core Guild · Evidence Locker Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Concelier Core Guild · Evidence Locker Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Evidence bundle/timeline linkage requirements documented; unblock evidence locker integration. |
|
| P7 | PREP-CONCELIER-OBS-53-001-DEPENDS-ON-52-001-B | DONE (2025-11-22) | Due 2025-11-21 · Accountable: Concelier Core Guild · Evidence Locker Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Concelier Core Guild · Evidence Locker Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Evidence bundle/timeline linkage requirements documented; unblock evidence locker integration. |
|
||||||
| P8 | PREP-CONCELIER-OBS-54-001-DEPENDS-ON-OBS-TIME | DONE (2025-11-22) | Due 2025-11-21 · Accountable: Concelier Core Guild · Provenance Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Concelier Core Guild · Provenance Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Attestation timeline enrichment + DSSE envelope fields recorded in prep note. |
|
| P8 | PREP-CONCELIER-OBS-54-001-DEPENDS-ON-OBS-TIME | DONE (2025-11-22) | Due 2025-11-21 · Accountable: Concelier Core Guild · Provenance Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Concelier Core Guild · Provenance Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Attestation timeline enrichment + DSSE envelope fields recorded in prep note. |
|
||||||
| P9 | PREP-CONCELIER-OBS-55-001-DEPENDS-ON-54-001-I | DONE (2025-11-22) | Due 2025-11-21 · Accountable: Concelier Core Guild · DevOps Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Concelier Core Guild · DevOps Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Incident-mode hooks and sealed-mode redaction guidance captured; see prep note. |
|
| P9 | PREP-CONCELIER-OBS-55-001-DEPENDS-ON-54-001-I | DONE (2025-11-22) | Due 2025-11-21 · Accountable: Concelier Core Guild · DevOps Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Concelier Core Guild · DevOps Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Incident-mode hooks and sealed-mode redaction guidance captured; see prep note. |
|
||||||
| 10 | CONCELIER-ORCH-32-001 | BLOCKED (2025-11-25) | CI build + orchestrator WebService tests blocked by disk-full runner; need clean space/CI (DEVOPS-CONCELIER-CI-24-101) to validate. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Register every advisory connector with orchestrator (metadata, auth scopes, rate policies) for transparent, reproducible scheduling. |
|
| 10 | CONCELIER-ORCH-32-001 | TODO | Disk space resolved (54GB available); ready for implementation | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Register every advisory connector with orchestrator (metadata, auth scopes, rate policies) for transparent, reproducible scheduling. |
|
||||||
| 11 | CONCELIER-ORCH-32-002 | BLOCKED (2025-11-25) | Blocked on 32-001 and disk exhaustion preventing test runs. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Adopt orchestrator worker SDK in ingestion loops; emit heartbeats/progress/artifact hashes for deterministic replays. |
|
| 11 | CONCELIER-ORCH-32-002 | TODO | Depends on 32-001 | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Adopt orchestrator worker SDK in ingestion loops; emit heartbeats/progress/artifact hashes for deterministic replays. |
|
||||||
| 12 | CONCELIER-ORCH-33-001 | BLOCKED (2025-11-25) | Blocked by 32-001/32-002 validation and disk-full test runner. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Honor orchestrator pause/throttle/retry controls with structured errors and persisted checkpoints. |
|
| 12 | CONCELIER-ORCH-33-001 | TODO | Depends on 32-001/32-002 | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Honor orchestrator pause/throttle/retry controls with structured errors and persisted checkpoints. |
|
||||||
| 13 | CONCELIER-ORCH-34-001 | BLOCKED (2025-11-25) | Blocked until 32-002/33-001 validated; test runner out of disk space. | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Execute orchestrator-driven backfills reusing artifact hashes/signatures, logging provenance, and pushing run metadata to ledger. |
|
| 13 | CONCELIER-ORCH-34-001 | TODO | Depends on 32-002/33-001 | Concelier Core Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Execute orchestrator-driven backfills reusing artifact hashes/signatures, logging provenance, and pushing run metadata to ledger. |
|
||||||
| 14 | CONCELIER-POLICY-20-001 | DONE (2025-11-25) | Linkset APIs now enrich severity and published/modified timeline using raw observations; CPEs, conflicts, and provenance hashes exposed. | Concelier WebService Guild (`src/Concelier/StellaOps.Concelier.WebService`) | Provide batch advisory lookup APIs for Policy Engine (purl/advisory filters, tenant scopes, explain metadata) so policy joins raw evidence without inferred outcomes. |
|
| 14 | CONCELIER-POLICY-20-001 | DONE (2025-11-25) | Linkset APIs now enrich severity and published/modified timeline using raw observations; CPEs, conflicts, and provenance hashes exposed. | Concelier WebService Guild (`src/Concelier/StellaOps.Concelier.WebService`) | Provide batch advisory lookup APIs for Policy Engine (purl/advisory filters, tenant scopes, explain metadata) so policy joins raw evidence without inferred outcomes. |
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | Unblocked tasks 10-13 (CONCELIER-ORCH-32-001 through 34-001): Disk space blocker resolved per BLOCKED_DEPENDENCY_TREE.md Section 8.2 (54GB available). Marked OPS-CLEAN-DISK-001 as DONE. Tasks now TODO and ready for implementation. | Implementer |
|
||||||
| 2025-12-03 | Added Wave Coordination (A: prep done; B: orchestrator wiring blocked on CI/disk; C: policy enrichment blocked on upstream data). No status changes. | Project Mgmt |
|
| 2025-12-03 | Added Wave Coordination (A: prep done; B: orchestrator wiring blocked on CI/disk; C: policy enrichment blocked on upstream data). No status changes. | Project Mgmt |
|
||||||
| 2025-11-28 | Disk space issue resolved (56GB available). Fixed `InitializeMongoAsync` to skip in testing mode. WebService orchestrator tests still fail due to hosted services requiring MongoDB; test factory needs more extensive mocking or integration test with Mongo2Go. ORCH tasks remain BLOCKED pending test infrastructure fix. | Implementer |
|
| 2025-11-28 | Disk space issue resolved (56GB available). Fixed `InitializeMongoAsync` to skip in testing mode. WebService orchestrator tests still fail due to hosted services requiring MongoDB; test factory needs more extensive mocking or integration test with Mongo2Go. ORCH tasks remain BLOCKED pending test infrastructure fix. | Implementer |
|
||||||
| 2025-11-25 | Runner disk is full ("No space left on device"); orchestrator WebService tests cannot be re-run. Free bin/obj/TestResults and `ops/devops/artifacts/ci-110` before continuing ORCH-32/33/34. | Concelier Core |
|
| 2025-11-25 | Runner disk is full ("No space left on device"); orchestrator WebService tests cannot be re-run. Free bin/obj/TestResults and `ops/devops/artifacts/ci-110` before continuing ORCH-32/33/34. | Concelier Core |
|
||||||
|
|||||||
@@ -28,11 +28,12 @@
|
|||||||
| 5 | EXCITITOR-ORCH-33-001 | DONE (2025-11-27) | Depends on 32-001. | Excititor Worker Guild | Honor orchestrator pause/throttle/retry commands; persist checkpoints; classify errors for safe outage handling. |
|
| 5 | EXCITITOR-ORCH-33-001 | DONE (2025-11-27) | Depends on 32-001. | Excititor Worker Guild | Honor orchestrator pause/throttle/retry commands; persist checkpoints; classify errors for safe outage handling. |
|
||||||
| 6 | EXCITITOR-POLICY-20-001 | DONE (2025-12-01) | Implemented `/policy/v1/vex/lookup` batching advisory_key + PURL with tenant enforcement; aggregation-only. | Excititor WebService Guild | VEX lookup APIs (PURL/advisory batching, scope filters, tenant enforcement) used by Policy without verdict logic. |
|
| 6 | EXCITITOR-POLICY-20-001 | DONE (2025-12-01) | Implemented `/policy/v1/vex/lookup` batching advisory_key + PURL with tenant enforcement; aggregation-only. | Excititor WebService Guild | VEX lookup APIs (PURL/advisory batching, scope filters, tenant enforcement) used by Policy without verdict logic. |
|
||||||
| 7 | EXCITITOR-POLICY-20-002 | DONE (2025-12-01) | Scope metadata persisted in linksets/events; API responses emit stored scope; remaining backfill optional. | Excititor Core Guild | Add scope resolution/version range metadata to linksets while staying aggregation-only. |
|
| 7 | EXCITITOR-POLICY-20-002 | DONE (2025-12-01) | Scope metadata persisted in linksets/events; API responses emit stored scope; remaining backfill optional. | Excititor Core Guild | Add scope resolution/version range metadata to linksets while staying aggregation-only. |
|
||||||
| 8 | EXCITITOR-RISK-66-001 | BLOCKED (2025-12-01) | Blocked on 20-002 outputs and Risk feed envelope. | Excititor Core · Risk Engine Guild | Publish risk-engine ready feeds (status, justification, provenance) with zero derived severity. |
|
| 8 | EXCITITOR-RISK-66-001 | TODO | 20-002 DONE; Risk feed envelope available at `docs/schemas/risk-scoring.schema.json` | Excititor Core · Risk Engine Guild | Publish risk-engine ready feeds (status, justification, provenance) with zero derived severity. |
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | Unblocked EXCITITOR-RISK-66-001: Risk feed envelope now available at `docs/schemas/risk-scoring.schema.json` (created per BLOCKED_DEPENDENCY_TREE.md Section 8.5). Task now TODO. | Implementer |
|
||||||
| 2025-12-03 | Normalised sprint structure; added Decisions/Risks and Next Checkpoints; no status changes. | Planning |
|
| 2025-12-03 | Normalised sprint structure; added Decisions/Risks and Next Checkpoints; no status changes. | Planning |
|
||||||
| 2025-11-27 | Marked OBS-52/53/54, ORCH-32/33 DONE after timeline/locker/attestation/orchestrator delivery. | Implementer |
|
| 2025-11-27 | Marked OBS-52/53/54, ORCH-32/33 DONE after timeline/locker/attestation/orchestrator delivery. | Implementer |
|
||||||
| 2025-12-01 | Normalized sprint file to standard template; set POLICY-20-001/20-002 and RISK-66-001 to BLOCKED pending Policy/Risk contracts (`advisory_key` schema, feed envelope). | Project Mgmt |
|
| 2025-12-01 | Normalized sprint file to standard template; set POLICY-20-001/20-002 and RISK-66-001 to BLOCKED pending Policy/Risk contracts (`advisory_key` schema, feed envelope). | Project Mgmt |
|
||||||
|
|||||||
@@ -60,18 +60,22 @@
|
|||||||
| 19 | SCANNER-BUN-019 | DONE (2025-12-06) | `BinaryLockfileEmitsRemediationAsync` test | QA Guild | Fixture: Binary lockfile only (`bun.lockb`); verify unsupported remediation message emitted. |
|
| 19 | SCANNER-BUN-019 | DONE (2025-12-06) | `BinaryLockfileEmitsRemediationAsync` test | QA Guild | Fixture: Binary lockfile only (`bun.lockb`); verify unsupported remediation message emitted. |
|
||||||
| 20 | SCANNER-BUN-020 | DONE (2025-12-06) | `WorkspacesAreParsedAsync` test | QA Guild | Fixture: Monorepo/workspaces with multiple `package.json` under single lock; verify workspace member handling. |
|
| 20 | SCANNER-BUN-020 | DONE (2025-12-06) | `WorkspacesAreParsedAsync` test | QA Guild | Fixture: Monorepo/workspaces with multiple `package.json` under single lock; verify workspace member handling. |
|
||||||
| 21 | SCANNER-BUN-021 | DONE (2025-12-06) | `SymlinkSafetyIsEnforcedAsync` test | QA Guild | Fixture: Symlink corner cases (verify no traversal outside root, no infinite loops, both logical/real paths in evidence). |
|
| 21 | SCANNER-BUN-021 | DONE (2025-12-06) | `SymlinkSafetyIsEnforcedAsync` test | QA Guild | Fixture: Symlink corner cases (verify no traversal outside root, no infinite loops, both logical/real paths in evidence). |
|
||||||
| 22 | SCANNER-BUN-022 | TODO | Depends on task 14 | CLI Guild | Implement `stellaops-cli bun inspect` verb: display Bun package inventory for local root or scan ID; wire into `CommandFactory`. |
|
| 22 | SCANNER-BUN-022 | DONE (2025-12-06) | CLI `bun inspect` verb in CommandFactory + CommandHandlers | CLI Guild | Implement `stellaops-cli bun inspect` verb: display Bun package inventory for local root or scan ID; wire into `CommandFactory`. |
|
||||||
| 23 | SCANNER-BUN-023 | TODO | Depends on task 22 | CLI Guild | Implement `stellaops-cli bun resolve` verb: resolve Bun packages by scan ID, digest, or image reference with JSON/table output. |
|
| 23 | SCANNER-BUN-023 | DONE (2025-12-06) | CLI `bun resolve` verb + BunPackageInventory models | CLI Guild | Implement `stellaops-cli bun resolve` verb: resolve Bun packages by scan ID, digest, or image reference with JSON/table output. |
|
||||||
| 24 | SCANNER-BUN-024 | TODO | Depends on task 23 | CLI Guild | Add CLI unit tests for Bun verbs (`CommandFactoryTests`, JSON output assertions); update CLI help text and golden outputs. |
|
| 24 | SCANNER-BUN-024 | DONE (2025-12-06) | Tests added to CommandFactoryTests + CommandHandlersTests | CLI Guild | Add CLI unit tests for Bun verbs (`CommandFactoryTests`, JSON output assertions); update CLI help text and golden outputs. |
|
||||||
| 25 | SCANNER-BUN-025 | TODO | Depends on task 14 | WebService Guild | Implement `BunPackageInventoryStore` with Mongo-backed storage and Null fallback for offline/unit modes. |
|
| 25 | SCANNER-BUN-025 | DONE (2025-12-06) | BunPackageInventoryStore + Null fallback implemented | WebService Guild | Implement `BunPackageInventoryStore` with Mongo-backed storage and Null fallback for offline/unit modes. |
|
||||||
| 26 | SCANNER-BUN-026 | TODO | Depends on task 25 | WebService Guild | Expose `GET /api/scans/{scanId}/bun-packages` endpoint; support digest/reference resolution via `SurfaceManifestStageExecutor`. |
|
| 26 | SCANNER-BUN-026 | DONE (2025-12-06) | GET /api/scans/{scanId}/bun-packages endpoint added | WebService Guild | Expose `GET /api/scans/{scanId}/bun-packages` endpoint; support digest/reference resolution via `SurfaceManifestStageExecutor`. |
|
||||||
| 27 | SCANNER-BUN-027 | TODO | Depends on task 14 | Worker Guild | Wire Bun analyzer into Worker DI; deploy plugin manifest + assembly to Worker loadout for hot-loading; verify `ScannerWorker` discovers analyzer. |
|
| 27 | SCANNER-BUN-027 | DONE (2025-12-06) | Bun wired into SurfaceManifestStageExecutor + Worker DI | Worker Guild | Wire Bun analyzer into Worker DI; deploy plugin manifest + assembly to Worker loadout for hot-loading; verify `ScannerWorker` discovers analyzer. |
|
||||||
| 28 | SCANNER-BUN-028 | TODO | Depends on all | Docs Guild | Update `docs/modules/scanner/architecture.md` with Bun analyzer coverage, limitations, and supported artifacts. |
|
| 28 | SCANNER-BUN-028 | DONE (2025-12-06) | Scanner architecture docs updated | Docs Guild | Update `docs/modules/scanner/architecture.md` with Bun analyzer coverage, limitations, and supported artifacts. |
|
||||||
| 29 | SCANNER-BUN-029 | TODO | Depends on all | Docs Guild | Document developer gotchas: isolated installs symlink-heavy, `.bun/` scanning requirement, `bun.lockb` migration path, multi-stage build implications. |
|
| 29 | SCANNER-BUN-029 | DONE (2025-12-06) | Gotchas documented at `docs/modules/scanner/bun-analyzer-gotchas.md` | Docs Guild | Document developer gotchas: isolated installs symlink-heavy, `.bun/` scanning requirement, `bun.lockb` migration path, multi-stage build implications. |
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | Completed task 24 (Wave E/CLI Tests): Added `Create_ExposesBunInspectAndResolveCommands` to CommandFactoryTests.cs. Added 4 Bun tests to CommandHandlersTests.cs: `HandleBunInspectAsync_WritesJson`, `HandleBunResolveAsync_RendersPackages`, `HandleBunResolveAsync_WritesJson`, `HandleBunResolveAsync_NotifiesWhenInventoryMissing`. Added BunInventory/BunInventoryException/LastBunPackagesScanId properties and GetBunPackagesAsync to StubBackendClient. Added helper methods CreateBunWorkspace, CreateBunPackageItem, CreateBunInventory. CLI test project has pre-existing build errors (MigrationModuleRegistry.cs) unrelated to Bun changes. Sprint 0139 now COMPLETE. | Implementer |
|
||||||
|
| 2025-12-06 | Completed tasks 28-29 (Wave F/Docs): Updated `docs/modules/scanner/architecture.md` with Bun analyzer coverage (project layout, language ecosystem section, Mongo collection, REST endpoint, config example). Created `docs/modules/scanner/bun-analyzer-gotchas.md` documenting 10 gotchas: isolated installs, .bun/ scanning, bun.lockb migration, JSONC format, multi-stage builds, npm ecosystem reuse, source detection, workspace handling, dev/prod filtering, evidence model. | Implementer |
|
||||||
|
| 2025-12-06 | Completed tasks 25-27 (Wave E/WebService+Worker): Created Bun package inventory infrastructure following Ruby pattern. Added `BunPackageInventory.cs` (contract + IBunPackageInventoryStore + NullBunPackageInventoryStore), `BunPackageInventoryDocument.cs` (Mongo catalog), `BunPackageInventoryRepository.cs`, `BunPackageInventoryStore.cs`, `BunPackageInventoryBuilder.cs`. Updated `MongoCollectionProvider`, `ScannerStorageDefaults`, `ServiceCollectionExtensions`. Wired `IBunPackageInventoryStore` into `SurfaceManifestStageExecutor` with `PersistBunPackagesAsync`. Added Null fallback in Worker `Program.cs`. Created `BunContracts.cs` and `HandleBunPackagesAsync` endpoint in `ScanEndpoints.cs`. All Scanner library projects build successfully; Worker/WebService have pre-existing unrelated build errors. | Implementer |
|
||||||
|
| 2025-12-06 | Completed tasks 22-23 (Wave E/CLI): Added `bun inspect` and `bun resolve` CLI verbs. Created `BuildBunCommand` in CommandFactory.cs, `HandleBunInspectAsync`/`HandleBunResolveAsync` handlers in CommandHandlers.cs, `BunInspectReport`/`BunResolveReport` classes, `BunPackageModels.cs` in Services/Models/Bun/, `GetBunPackagesAsync` in IBackendOperationsClient/BackendOperationsClient, and Bun metrics in CliMetrics. Added Bun analyzer project reference to CLI csproj. Bun analyzer builds successfully; CLI has pre-existing MigrationModuleRegistry build errors (unrelated). | Implementer |
|
||||||
| 2025-12-06 | Completed P1 through 21 (Waves A–D): Created design doc at `docs/modules/scanner/prep/bun-analyzer-design.md`. Verified core analyzer implementation in `StellaOps.Scanner.Analyzers.Lang.Bun`: BunAnalyzerPlugin, BunLanguageAnalyzer, BunProjectDiscoverer, BunInputNormalizer, BunLockParser (JSONC with git/tarball/workspace source detection), BunInstalledCollector (symlink-safe), BunPackageNormalizer, BunPackage (PURL + evidence). Performance guards (MaxFilesPerRoot=50000, MaxSymlinkDepth=10) in place. Test project with 6 golden fixture tests. Build succeeds. | Implementer |
|
| 2025-12-06 | Completed P1 through 21 (Waves A–D): Created design doc at `docs/modules/scanner/prep/bun-analyzer-design.md`. Verified core analyzer implementation in `StellaOps.Scanner.Analyzers.Lang.Bun`: BunAnalyzerPlugin, BunLanguageAnalyzer, BunProjectDiscoverer, BunInputNormalizer, BunLockParser (JSONC with git/tarball/workspace source detection), BunInstalledCollector (symlink-safe), BunPackageNormalizer, BunPackage (PURL + evidence). Performance guards (MaxFilesPerRoot=50000, MaxSymlinkDepth=10) in place. Test project with 6 golden fixture tests. Build succeeds. | Implementer |
|
||||||
| 2025-12-05 | Sprint file created from product advisory; 29 tasks across 6 waves (A–F) covering core analyzer, testing, CLI/WebService/Worker integration, and docs. | Planning |
|
| 2025-12-05 | Sprint file created from product advisory; 29 tasks across 6 waves (A–F) covering core analyzer, testing, CLI/WebService/Worker integration, and docs. | Planning |
|
||||||
|
|
||||||
|
|||||||
111
docs/implplan/SPRINT_0140_0001_0001_scanner_java_enhancement.md
Normal file
111
docs/implplan/SPRINT_0140_0001_0001_scanner_java_enhancement.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Sprint 0140 · Scanner & Surface — Java Analyzer Comprehensive Enhancement
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Enhance Java analyzer with direct Gradle build file parsing (Groovy DSL, Kotlin DSL, Version Catalogs)
|
||||||
|
- Implement Maven parent POM resolution with property placeholder interpolation and BOM imports
|
||||||
|
- Add shaded/shadow JAR detection with embedded artifact enumeration
|
||||||
|
- Parse OSGi bundle manifest headers (Bundle-SymbolicName, Import/Export-Package)
|
||||||
|
- Extract license metadata from pom.xml with SPDX normalization
|
||||||
|
- Surface dependency scope classification (compile, test, provided, runtime)
|
||||||
|
- Detect multi-version conflicts across classpath
|
||||||
|
- **Working directory:** `src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java`, tests under `src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Java.Tests`
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Upstream: Existing Java analyzer infrastructure stable
|
||||||
|
- Reuses: `ILanguageAnalyzer`, `LanguageComponentWriter`, `LanguageComponentEvidence`
|
||||||
|
- Parallel-safe with other analyzer work
|
||||||
|
- Reference patterns from Rust analyzer (TOML parsing, license scanning)
|
||||||
|
|
||||||
|
## Wave Coordination
|
||||||
|
- **Wave A (foundation):** Shared models, property resolver, SPDX normalizer, file discovery
|
||||||
|
- **Wave B (gradle):** Groovy parser, Kotlin parser, version catalog parser, TOML utility
|
||||||
|
- **Wave C (maven):** POM parser, parent resolver, effective POM builder, BOM importer
|
||||||
|
- **Wave D (detection):** Shaded JAR detector, OSGi parser, scope classifier, conflict detector
|
||||||
|
- **Wave E (integration):** Wire all features into JavaLanguageAnalyzer, update metadata emission
|
||||||
|
- **Wave F (testing):** Create fixtures, unit tests, integration tests
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `docs/README.md`
|
||||||
|
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||||
|
- `docs/modules/scanner/architecture.md`
|
||||||
|
- `src/Scanner/AGENTS.md`
|
||||||
|
|
||||||
|
> **BLOCKED Tasks:** Before working on BLOCKED tasks, review [BLOCKED_DEPENDENCY_TREE.md](./BLOCKED_DEPENDENCY_TREE.md) for root blockers and dependencies.
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| **Wave A: Foundation** |
|
||||||
|
| A1 | JAVA-ENH-A01 | DONE | None | Java Guild | Create `Internal/BuildMetadata/JavaDependencyDeclaration.cs` - shared dependency model with groupId, artifactId, version, scope, classifier, exclusions |
|
||||||
|
| A2 | JAVA-ENH-A02 | DONE | None | Java Guild | Create `Internal/BuildMetadata/JavaProjectMetadata.cs` - unified project model with parent reference, properties, licenses |
|
||||||
|
| A3 | JAVA-ENH-A03 | DONE | A1 | Java Guild | Create `Internal/PropertyResolution/JavaPropertyResolver.cs` - resolve `${property}` placeholders with parent chain support |
|
||||||
|
| A4 | JAVA-ENH-A04 | DONE | None | Java Guild | Create `Internal/License/SpdxLicenseNormalizer.cs` + `spdx-licenses.json` - map license names/URLs to SPDX identifiers |
|
||||||
|
| A5 | JAVA-ENH-A05 | DONE | None | Java Guild | Create `Internal/Discovery/JavaBuildFileDiscovery.cs` - find build.gradle, pom.xml, libs.versions.toml |
|
||||||
|
| **Wave B: Gradle Parsing** |
|
||||||
|
| B1 | JAVA-ENH-B01 | DONE | A5 | Java Guild | Create `Internal/Gradle/GradlePropertiesParser.cs` - parse gradle.properties files |
|
||||||
|
| B2 | JAVA-ENH-B02 | DONE | A1, A3, B1 | Java Guild | Create `Internal/Gradle/GradleGroovyParser.cs` - regex-based build.gradle parsing for implementation/api/compileOnly/etc |
|
||||||
|
| B3 | JAVA-ENH-B03 | DONE | A1, A3, B1 | Java Guild | Create `Internal/Gradle/GradleKotlinParser.cs` - regex-based build.gradle.kts parsing |
|
||||||
|
| B4 | JAVA-ENH-B04 | DONE | A1 | Java Guild | Create `Internal/Gradle/TomlParser.cs` - minimal TOML parser for version catalogs |
|
||||||
|
| B5 | JAVA-ENH-B05 | DONE | B4 | Java Guild | Create `Internal/Gradle/GradleVersionCatalogParser.cs` - parse libs.versions.toml (versions, libraries, bundles) |
|
||||||
|
| B6 | JAVA-ENH-B06 | TODO | B2, B3, B5 | Java Guild | Integrate Gradle parsers into `JavaLockFileCollector.cs` - discover and parse build files, resolve catalog references |
|
||||||
|
| **Wave C: Maven Enhancement** |
|
||||||
|
| C1 | JAVA-ENH-C01 | DONE | A1, A3 | Java Guild | Create `Internal/Maven/MavenPomParser.cs` - full pom.xml parsing with parent, properties, dependencyManagement, licenses |
|
||||||
|
| C2 | JAVA-ENH-C02 | DONE | C1 | Java Guild | Create `Internal/Maven/MavenParentResolver.cs` - resolve parent POM chain via relativePath and directory traversal |
|
||||||
|
| C3 | JAVA-ENH-C03 | TODO | C1, C2, A3 | Java Guild | Create `Internal/Maven/MavenEffectivePomBuilder.cs` - merge parent chain, resolve all properties |
|
||||||
|
| C4 | JAVA-ENH-C04 | TODO | C1, C2 | Java Guild | Create `Internal/Maven/MavenBomImporter.cs` - handle `scope=import` `type=pom` BOM dependencies |
|
||||||
|
| C5 | JAVA-ENH-C05 | TODO | C1 | Java Guild | Create `Internal/Maven/MavenLocalRepository.cs` - discover .m2/repository for artifact resolution |
|
||||||
|
| C6 | JAVA-ENH-C06 | TODO | C1-C5 | Java Guild | Update `JavaLockFileCollector.ParsePomAsync` - replace inline XLinq with full parser, resolve properties |
|
||||||
|
| **Wave D: Detection Enhancements** |
|
||||||
|
| D1 | JAVA-ENH-D01 | DONE | None | Java Guild | Create `Internal/Shading/ShadedJarDetector.cs` - detect multiple pom.properties, dependency-reduced-pom.xml, relocated prefixes |
|
||||||
|
| D2 | JAVA-ENH-D02 | DONE | None | Java Guild | Create `Internal/Osgi/OsgiBundleParser.cs` - parse Bundle-SymbolicName, Import-Package, Export-Package from MANIFEST.MF |
|
||||||
|
| D3 | JAVA-ENH-D03 | TODO | C6 | Java Guild | Enhance scope classification in `JavaLockFileCollector` - add `Scope` field, map to riskLevel (production/development/provided) |
|
||||||
|
| D4 | JAVA-ENH-D04 | DONE | None | Java Guild | Create `Internal/Conflicts/VersionConflictDetector.cs` - detect same artifact with different versions across workspace |
|
||||||
|
| **Wave E: Integration** |
|
||||||
|
| E1 | JAVA-ENH-E01 | TODO | D1 | Java Guild | Integrate `ShadedJarDetector` into `ProcessArchiveAsync` - emit shaded metadata and bundled artifacts |
|
||||||
|
| E2 | JAVA-ENH-E02 | TODO | D2 | Java Guild | Extend `ParseManifestAsync` to call `OsgiBundleParser` - emit osgi.* metadata |
|
||||||
|
| E3 | JAVA-ENH-E03 | TODO | A4, C1 | Java Guild | Add license extraction from pom.xml and embedded pom.xml in JARs - emit license metadata with SPDX normalization |
|
||||||
|
| E4 | JAVA-ENH-E04 | TODO | D3 | Java Guild | Update `AppendLockMetadata` - emit declaredScope and scope.riskLevel |
|
||||||
|
| E5 | JAVA-ENH-E05 | TODO | D4 | Java Guild | Add conflict detection post-processing in `AnalyzeAsync` - emit conflict.* metadata |
|
||||||
|
| E6 | JAVA-ENH-E06 | TODO | B6, C6, E1-E5 | Java Guild | Update `JavaLockEntry` record - add Scope, VersionSource, License fields |
|
||||||
|
| **Wave F: Testing** |
|
||||||
|
| F1 | JAVA-ENH-F01 | TODO | B2 | QA Guild | Create fixture `gradle-groovy/` - Groovy DSL with string/map notation |
|
||||||
|
| F2 | JAVA-ENH-F02 | TODO | B3 | QA Guild | Create fixture `gradle-kotlin/` - Kotlin DSL with type-safe accessors |
|
||||||
|
| F3 | JAVA-ENH-F03 | TODO | B5 | QA Guild | Create fixture `gradle-catalog/` - libs.versions.toml with version references |
|
||||||
|
| F4 | JAVA-ENH-F04 | TODO | C6 | QA Guild | Create fixture `maven-parent/` - parent POM version inheritance |
|
||||||
|
| F5 | JAVA-ENH-F05 | TODO | C4 | QA Guild | Create fixture `maven-bom/` - BOM import with dependencyManagement |
|
||||||
|
| F6 | JAVA-ENH-F06 | TODO | C3 | QA Guild | Create fixture `maven-properties/` - property placeholder resolution |
|
||||||
|
| F7 | JAVA-ENH-F07 | TODO | D1 | QA Guild | Create fixture `shaded-maven/` - JAR with multiple pom.properties + dependency-reduced-pom.xml |
|
||||||
|
| F8 | JAVA-ENH-F08 | TODO | D2 | QA Guild | Create fixture `osgi-bundle/` - JAR with Bundle-SymbolicName manifest |
|
||||||
|
| F9 | JAVA-ENH-F09 | TODO | E3 | QA Guild | Create fixture `maven-license/` - pom.xml with <licenses> element |
|
||||||
|
| F10 | JAVA-ENH-F10 | TODO | D3 | QA Guild | Create fixture `maven-scopes/` - dependencies with test/provided/runtime scopes |
|
||||||
|
| F11 | JAVA-ENH-F11 | TODO | D4 | QA Guild | Create fixture `version-conflict/` - multiple versions of same library |
|
||||||
|
| F12 | JAVA-ENH-F12 | TODO | F1-F11 | QA Guild | Add integration tests in `JavaLanguageAnalyzerTests.cs` using golden fixture harness |
|
||||||
|
| F13 | JAVA-ENH-F13 | TODO | B2-B5, C1, D1-D4 | QA Guild | Add unit tests for individual parsers (GradleGroovyParserTests, MavenPomParserTests, etc.) |
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | Wave A complete: Created 5 foundation files (JavaDependencyDeclaration, JavaProjectMetadata, JavaPropertyResolver, SpdxLicenseNormalizer, JavaBuildFileDiscovery) | Claude |
|
||||||
|
| 2025-12-06 | Wave B complete: Created 5 Gradle parsing files (GradlePropertiesParser, GradleGroovyParser, GradleKotlinParser, TomlParser, GradleVersionCatalogParser) | Claude |
|
||||||
|
| 2025-12-06 | Wave C partial: Created 2 Maven files (MavenPomParser, MavenParentResolver) | Claude |
|
||||||
|
| 2025-12-06 | Wave D partial: Created 3 detection files (ShadedJarDetector, OsgiBundleParser, VersionConflictDetector) | Claude |
|
||||||
|
| 2025-12-06 | Build verified successful - all 15 new files compile | Claude |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- **Risk:** Gradle DSL is dynamic; regex-based parsing will miss complex patterns
|
||||||
|
- **Mitigation:** Focus on common patterns; emit `unresolvedDependency` for unparseable declarations; document limitations
|
||||||
|
- **Risk:** Parent POMs may not be available locally (repository-only)
|
||||||
|
- **Mitigation:** Log warnings; continue with partial data; emit `parentUnresolved` metadata
|
||||||
|
- **Risk:** BOM imports can create cycles
|
||||||
|
- **Mitigation:** Track visited BOMs; limit depth to 5 levels
|
||||||
|
- **Risk:** Property resolution can have cycles
|
||||||
|
- **Mitigation:** Limit recursion to 10 levels; emit `unresolvedProperty` for cycles
|
||||||
|
- **Decision:** Gradle lockfile still takes precedence over build.gradle when both exist
|
||||||
|
- **Decision:** SPDX normalization starts with ~50 high-confidence mappings; expand based on telemetry
|
||||||
|
- **Decision:** Shaded detection requires confidence score >= Medium to emit `shaded: true`
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- Wave B completion: Gradle parsing functional
|
||||||
|
- Wave C completion: Maven property resolution working
|
||||||
|
- Wave D completion: All detection features implemented
|
||||||
|
- Wave F completion: Full test coverage with golden fixtures
|
||||||
@@ -1,17 +1,36 @@
|
|||||||
# Sprint 144 - Runtime & Signals · 140.D) Zastava
|
# Sprint 144 - Runtime & Signals · 140.D) Zastava
|
||||||
|
|
||||||
> **BLOCKED Tasks:** Before working on BLOCKED tasks, review [BLOCKED_DEPENDENCY_TREE.md](./BLOCKED_DEPENDENCY_TREE.md) for root blockers and dependencies.
|
## Topic & Scope
|
||||||
|
- Runtime & Signals focus on Zastava — observer and webhook Surface integration.
|
||||||
|
- Keep cache/env/secrets wiring aligned with Surface helpers and enforce Surface.FS for admission decisions.
|
||||||
|
- Working directory: `src/Zastava` (Observer + Webhook).
|
||||||
|
|
||||||
Active items only. Completed/historic work now resides in docs/implplan/archived/tasks.md (updated 2025-11-08).
|
## Dependencies & Concurrency
|
||||||
|
- Depends on Sprint 120.A - AirGap and Sprint 130.A - Scanner.
|
||||||
|
- For any BLOCKED tasks, review `BLOCKED_DEPENDENCY_TREE.md` before resuming work.
|
||||||
|
|
||||||
[Runtime & Signals] 140.D) Zastava
|
## Documentation Prerequisites
|
||||||
Depends on: Sprint 120.A - AirGap, Sprint 130.A - Scanner
|
- docs/README.md
|
||||||
Summary: Runtime & Signals focus on Zastava — observer and webhook Surface integration.
|
- docs/modules/platform/architecture-overview.md
|
||||||
Task ID | State | Task description | Owners (Source)
|
- docs/modules/zastava/architecture.md
|
||||||
--- | --- | --- | ---
|
|
||||||
ZASTAVA-ENV-01 | DONE | Adopt Surface.Env helpers for cache endpoints, secret refs, and feature toggles. | Zastava Observer Guild (src/Zastava/StellaOps.Zastava.Observer)
|
## Delivery Tracker
|
||||||
ZASTAVA-ENV-02 | DONE | Switch to Surface.Env helpers for webhook configuration (cache endpoint, secret refs, feature toggles). Dependencies: ZASTAVA-ENV-01. | Zastava Webhook Guild (src/Zastava/StellaOps.Zastava.Webhook)
|
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||||
ZASTAVA-SECRETS-01 | DONE | Retrieve CAS/attestation access via Surface.Secrets instead of inline secret stores. | Zastava Observer Guild, Security Guild (src/Zastava/StellaOps.Zastava.Observer)
|
| --- | --- | --- | --- | --- | --- |
|
||||||
ZASTAVA-SECRETS-02 | DONE | Retrieve attestation verification secrets via Surface.Secrets. Dependencies: ZASTAVA-SECRETS-01. | Zastava Webhook Guild, Security Guild (src/Zastava/StellaOps.Zastava.Webhook)
|
| 1 | ZASTAVA-ENV-01 | DONE | Surface.Env helpers available | Zastava Observer Guild (`src/Zastava/StellaOps.Zastava.Observer`) | Adopt Surface.Env helpers for cache endpoints, secret refs, and feature toggles. |
|
||||||
ZASTAVA-SURFACE-01 | DONE | Integrate Surface.FS client for runtime drift detection (lookup cached layer hashes/entry traces).<br>2025-10-24: Observer unit tests pending; `dotnet restore` needs offline copies of `Google.Protobuf`, `Grpc.Net.Client`, and `Grpc.Tools` in `local-nuget` before verification.<br>2025-11-27: All tests pass; Surface.FS integration verified. | Zastava Observer Guild (src/Zastava/StellaOps.Zastava.Observer)
|
| 2 | ZASTAVA-ENV-02 | DONE | Depends on ZASTAVA-ENV-01 | Zastava Webhook Guild (`src/Zastava/StellaOps.Zastava.Webhook`) | Switch webhook configuration to Surface.Env helpers for cache endpoint, secret refs, and feature toggles. |
|
||||||
ZASTAVA-SURFACE-02 | DONE | Enforce Surface.FS availability during admission (deny when cache missing/stale) and embed pointer checks in webhook response. Dependencies: ZASTAVA-SURFACE-01. | Zastava Webhook Guild (src/Zastava/StellaOps.Zastava.Webhook)
|
| 3 | ZASTAVA-SECRETS-01 | DONE | Completed | Zastava Observer Guild; Security Guild (`src/Zastava/StellaOps.Zastava.Observer`) | Retrieve CAS/attestation access via Surface.Secrets instead of inline secret stores. |
|
||||||
|
| 4 | ZASTAVA-SECRETS-02 | DONE | Depends on ZASTAVA-SECRETS-01 | Zastava Webhook Guild; Security Guild (`src/Zastava/StellaOps.Zastava.Webhook`) | Retrieve attestation verification secrets via Surface.Secrets. |
|
||||||
|
| 5 | ZASTAVA-SURFACE-01 | DONE | Tests verified 2025-11-27 | Zastava Observer Guild (`src/Zastava/StellaOps.Zastava.Observer`) | Integrate Surface.FS client for runtime drift detection (cached layer hashes/entry traces). Observer unit tests now pass; offline `local-nuget` copies required for gRPC packages. |
|
||||||
|
| 6 | ZASTAVA-SURFACE-02 | DONE | Depends on ZASTAVA-SURFACE-01 | Zastava Webhook Guild (`src/Zastava/StellaOps.Zastava.Webhook`) | Enforce Surface.FS availability during admission (deny when cache missing/stale) and embed pointer checks in webhook response. |
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- All Zastava runtime/signal tasks completed; Surface.FS integration verified.
|
||||||
|
- No open blockers; revisit Surface.FS/offline cache freshness if Scanner deliverables change.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- Archived 2025-12-06; no further checkpoints scheduled.
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
| 4 | TASKRUN-AIRGAP-58-001 | BLOCKED (2025-11-30) | Depends on 57-001. | Task Runner Guild · Evidence Locker Guild | Capture bundle import job transcripts, hashed inputs/outputs into portable evidence bundles. |
|
| 4 | TASKRUN-AIRGAP-58-001 | BLOCKED (2025-11-30) | Depends on 57-001. | Task Runner Guild · Evidence Locker Guild | Capture bundle import job transcripts, hashed inputs/outputs into portable evidence bundles. |
|
||||||
| 5 | TASKRUN-42-001 | DONE (2025-12-06) | Implemented Loop/Conditional step kinds, extended execution graph/simulation engine, added manifest/planner/validator support, 128 tests passing. | Task Runner Guild (`src/TaskRunner/StellaOps.TaskRunner`) | Execution engine enhancements + simulation API/CLI. |
|
| 5 | TASKRUN-42-001 | DONE (2025-12-06) | Implemented Loop/Conditional step kinds, extended execution graph/simulation engine, added manifest/planner/validator support, 128 tests passing. | Task Runner Guild (`src/TaskRunner/StellaOps.TaskRunner`) | Execution engine enhancements + simulation API/CLI. |
|
||||||
| 6 | TASKRUN-OAS-61-001 | DONE (2025-12-06) | Created `docs/api/taskrunner-openapi.yaml` with full API documentation including streaming logs (NDJSON), loop/conditional/policy gate schemas. | Task Runner Guild · API Contracts Guild | Document TaskRunner APIs (pack runs, logs, approvals) with streaming schemas/examples. |
|
| 6 | TASKRUN-OAS-61-001 | DONE (2025-12-06) | Created `docs/api/taskrunner-openapi.yaml` with full API documentation including streaming logs (NDJSON), loop/conditional/policy gate schemas. | Task Runner Guild · API Contracts Guild | Document TaskRunner APIs (pack runs, logs, approvals) with streaming schemas/examples. |
|
||||||
| 7 | TASKRUN-OAS-61-002 | TODO | ✅ 61-001 DONE; endpoint already implemented in Program.cs; needs signing integration. | Task Runner Guild | Expose `GET /.well-known/openapi` returning signed spec metadata, build version, ETag. |
|
| 7 | TASKRUN-OAS-61-002 | DONE (2025-12-06) | Enhanced `OpenApiMetadataFactory` with API/build version separation, SHA-256 signatures, ETag; endpoint returns `X-Api-Version`, `X-Build-Version`, `X-Signature` headers; 130 tests passing. | Task Runner Guild | Expose `GET /.well-known/openapi` returning signed spec metadata, build version, ETag. |
|
||||||
| 8 | TASKRUN-OAS-62-001 | TODO | Depends on 61-002. | Task Runner Guild · SDK Generator Guild | SDK examples for pack run lifecycle; streaming log helpers; paginator wrappers. |
|
| 8 | TASKRUN-OAS-62-001 | DONE (2025-12-06) | Created `StellaOps.TaskRunner.Client` SDK with `ITaskRunnerClient`, streaming log reader, paginator wrappers, lifecycle helpers; 150 tests passing. | Task Runner Guild · SDK Generator Guild | SDK examples for pack run lifecycle; streaming log helpers; paginator wrappers. |
|
||||||
| 9 | TASKRUN-OAS-63-001 | TODO | Depends on 62-001. | Task Runner Guild · API Governance Guild | Sunset/deprecation headers + notifications for legacy pack APIs. |
|
| 9 | TASKRUN-OAS-63-001 | DONE (2025-12-06) | Implemented `ApiDeprecationMiddleware` for RFC 8594 Sunset headers, `Deprecation` header, `Link` headers for documentation; deprecation notification service; `/v1/task-runner/deprecations` endpoint; 150 tests passing. | Task Runner Guild · API Governance Guild | Sunset/deprecation headers + notifications for legacy pack APIs. |
|
||||||
| 10 | TASKRUN-OBS-50-001 | DONE (2025-11-25) | Telemetry core adoption. | Task Runner Guild | Add telemetry core in host + worker; spans/logs include `trace_id`, `tenant_id`, `run_id`, scrubbed transcripts. |
|
| 10 | TASKRUN-OBS-50-001 | DONE (2025-11-25) | Telemetry core adoption. | Task Runner Guild | Add telemetry core in host + worker; spans/logs include `trace_id`, `tenant_id`, `run_id`, scrubbed transcripts. |
|
||||||
| 11 | TASKRUN-OBS-51-001 | DONE (2025-11-25) | Depends on 50-001. | Task Runner Guild · DevOps Guild | Metrics for step latency, retries, queue depth, sandbox resource usage; define SLOs; burn-rate alerts. |
|
| 11 | TASKRUN-OBS-51-001 | DONE (2025-11-25) | Depends on 50-001. | Task Runner Guild · DevOps Guild | Metrics for step latency, retries, queue depth, sandbox resource usage; define SLOs; burn-rate alerts. |
|
||||||
| 12 | TASKRUN-OBS-52-001 | DONE (2025-12-06) | Created PackRunTimelineEvent domain model, IPackRunTimelineEventEmitter + emitter, IPackRunTimelineEventSink + InMemory sink, 32 tests passing. | Task Runner Guild | Timeline events for pack runs (`pack.started`, `pack.step.completed`, `pack.failed`) with evidence pointers/policy context; dedupe + retry. |
|
| 12 | TASKRUN-OBS-52-001 | DONE (2025-12-06) | Created PackRunTimelineEvent domain model, IPackRunTimelineEventEmitter + emitter, IPackRunTimelineEventSink + InMemory sink, 32 tests passing. | Task Runner Guild | Timeline events for pack runs (`pack.started`, `pack.step.completed`, `pack.failed`) with evidence pointers/policy context; dedupe + retry. |
|
||||||
@@ -56,6 +56,9 @@
|
|||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | TASKRUN-OAS-63-001 DONE: Implemented RFC 8594-compliant `ApiDeprecationMiddleware` with `Deprecation` header (date or "true"), `Sunset` header (HTTP-date format), `Link` headers for deprecation docs/replacement paths, and `X-Deprecation-Notice` for custom messages. Created `ApiDeprecationOptions` for configuration-driven deprecated endpoints with wildcard path patterns. Implemented `IDeprecationNotificationService` with `LoggingDeprecationNotificationService`. Added `/v1/task-runner/deprecations` endpoint to query upcoming deprecations. Added 8 tests for pattern matching, filtering, ordering. 150 total tests passing. | Implementer |
|
||||||
|
| 2025-12-06 | TASKRUN-OAS-62-001 DONE: Created `StellaOps.TaskRunner.Client` SDK project with: `ITaskRunnerClient` interface and `TaskRunnerClient` HTTP implementation for all TaskRunner endpoints (runs, logs, approvals, artifacts, simulations, metadata). `StreamingLogReader` helper for NDJSON log parsing with `FilterByLevel`, `FilterByStep`, `GroupByStep` helpers. `Paginator<T>` generic wrapper with `GetAllAsync`, `CollectAsync`, `GetPageAsync` methods and `TakeAsync`/`SkipAsync` extensions. `PackRunLifecycleHelper` with `CreateAndWaitAsync`, `WaitForCompletionAsync`, `WaitForApprovalAsync`, `ApproveAllAsync`, `CreateRunAndAutoApproveAsync`. `TaskRunnerClientServiceCollectionExtensions` for DI registration. Added 12 SDK tests. 150 total tests passing. | Implementer |
|
||||||
|
| 2025-12-06 | TASKRUN-OAS-61-002 DONE: Enhanced `OpenApiMetadataFactory.cs` with separate API version (`0.1.0-draft`) and build version (from assembly informational version). Added SHA-256 signature with `sha256:` prefix. ETag generated from combined version hashes. Updated `Program.cs` endpoint to return `X-Api-Version`, `X-Build-Version`, `X-Signature` headers. Fixed pre-existing build errors (missing Regex using, OpenTelemetry instrumentation packages, `TaskRunnerTelemetry` accessibility, `Results.Stream` callback signature). Updated `OpenApiMetadataFactoryTests.cs` for new record structure; added tests for signature uniqueness and ETag determinism. 130 tests passing. | Implementer |
|
||||||
| 2025-12-06 | TASKRUN-OAS-61-001 DONE: Created `docs/api/taskrunner-openapi.yaml` OpenAPI 3.1 specification documenting all TaskRunner WebService APIs: POST /v1/task-runner/simulations (simulate task pack), POST /v1/task-runner/runs (create run), GET /v1/task-runner/runs/{runId} (get state), GET /v1/task-runner/runs/{runId}/logs (NDJSON streaming), GET /v1/task-runner/runs/{runId}/artifacts (list artifacts), POST /v1/task-runner/runs/{runId}/approvals/{approvalId} (apply decision), POST /v1/task-runner/runs/{runId}/cancel (cancel run), GET /.well-known/openapi (metadata). Includes LoopInfo, ConditionalInfo, PolicyInfo schemas for new control-flow steps. Examples provided for all endpoints. | Implementer |
|
| 2025-12-06 | TASKRUN-OAS-61-001 DONE: Created `docs/api/taskrunner-openapi.yaml` OpenAPI 3.1 specification documenting all TaskRunner WebService APIs: POST /v1/task-runner/simulations (simulate task pack), POST /v1/task-runner/runs (create run), GET /v1/task-runner/runs/{runId} (get state), GET /v1/task-runner/runs/{runId}/logs (NDJSON streaming), GET /v1/task-runner/runs/{runId}/artifacts (list artifacts), POST /v1/task-runner/runs/{runId}/approvals/{approvalId} (apply decision), POST /v1/task-runner/runs/{runId}/cancel (cancel run), GET /.well-known/openapi (metadata). Includes LoopInfo, ConditionalInfo, PolicyInfo schemas for new control-flow steps. Examples provided for all endpoints. | Implementer |
|
||||||
| 2025-12-06 | TASKRUN-42-001 DONE: Extended `PackRunStepKind` enum with `Loop` and `Conditional`. Added `PackRunLoopConfig`, `PackRunConditionalConfig`, `PackRunPolicyGateConfig` record types to `PackRunExecutionGraph.cs`. Updated `PackRunExecutionGraphBuilder` to extract loop/conditional/policy gate configs. Extended `PackRunSimulationEngine` and `PackRunSimulationModels.cs` with `WillIterate`/`WillBranch` statuses and simulation info records. Added `TaskPackLoopStep`, `TaskPackConditionalStep` manifest models. Updated `TaskPackPlanner` with `BuildLoopStep`/`BuildConditionalStep` methods. Updated `TaskPackManifestValidator` for loop/conditional validation. Added 3 new simulation tests (loop, conditional, policy gate); 128 total tests passing. | Implementer |
|
| 2025-12-06 | TASKRUN-42-001 DONE: Extended `PackRunStepKind` enum with `Loop` and `Conditional`. Added `PackRunLoopConfig`, `PackRunConditionalConfig`, `PackRunPolicyGateConfig` record types to `PackRunExecutionGraph.cs`. Updated `PackRunExecutionGraphBuilder` to extract loop/conditional/policy gate configs. Extended `PackRunSimulationEngine` and `PackRunSimulationModels.cs` with `WillIterate`/`WillBranch` statuses and simulation info records. Added `TaskPackLoopStep`, `TaskPackConditionalStep` manifest models. Updated `TaskPackPlanner` with `BuildLoopStep`/`BuildConditionalStep` methods. Updated `TaskPackManifestValidator` for loop/conditional validation. Added 3 new simulation tests (loop, conditional, policy gate); 128 total tests passing. | Implementer |
|
||||||
| 2025-12-06 | TASKRUN-OBS-53-001 DONE: Created `PackRunEvidenceSnapshot.cs` domain model with Merkle root computation for hash chain integrity. Created `IPackRunEvidenceSnapshotService.cs` with service for capturing run completion, step execution, approval decisions, and policy evaluations. Created `IPackRunEvidenceStore.cs` with InMemoryPackRunEvidenceStore for testing. Created `IPackRunRedactionGuard.cs` with PackRunRedactionGuard for sensitive data redaction (bearer tokens, passwords, emails, identities). Added 29 comprehensive tests in `PackRunEvidenceSnapshotTests.cs`. Build verified (0 errors), all tests passing. | Implementer |
|
| 2025-12-06 | TASKRUN-OBS-53-001 DONE: Created `PackRunEvidenceSnapshot.cs` domain model with Merkle root computation for hash chain integrity. Created `IPackRunEvidenceSnapshotService.cs` with service for capturing run completion, step execution, approval decisions, and policy evaluations. Created `IPackRunEvidenceStore.cs` with InMemoryPackRunEvidenceStore for testing. Created `IPackRunRedactionGuard.cs` with PackRunRedactionGuard for sensitive data redaction (bearer tokens, passwords, emails, identities). Added 29 comprehensive tests in `PackRunEvidenceSnapshotTests.cs`. Build verified (0 errors), all tests passing. | Implementer |
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Sprint 204 - Experience & SDKs · 180.A) Cli.IV
|
|
||||||
|
|
||||||
> **BLOCKED Tasks:** Before working on BLOCKED tasks, review [BLOCKED_DEPENDENCY_TREE.md](./BLOCKED_DEPENDENCY_TREE.md) for root blockers and dependencies.
|
|
||||||
|
|
||||||
Active items only. Completed/historic work now resides in docs/implplan/archived/tasks.md (updated 2025-11-08).
|
|
||||||
|
|
||||||
[Experience & SDKs] 180.A) Cli.IV
|
|
||||||
Depends on: Sprint 180.A - Cli.III
|
|
||||||
Summary: Experience & SDKs focus on Cli (phase IV).
|
|
||||||
Task ID | State | Task description | Owners (Source)
|
|
||||||
--- | --- | --- | ---
|
|
||||||
CLI-POLICY-27-002 | DONE | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. Dependencies: CLI-POLICY-27-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-POLICY-27-003 | DONE | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. Dependencies: CLI-POLICY-27-002. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-POLICY-27-004 | DONE | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. Dependencies: CLI-POLICY-27-003. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-POLICY-27-005 | DONE | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. Dependencies: CLI-POLICY-27-004. | DevEx/CLI Guild, Docs Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-POLICY-27-006 | DONE | Update CLI policy profiles/help text to request the new Policy Studio scope family, surface ProblemDetails guidance for `invalid_scope`, and adjust regression tests for scope failures. Dependencies: CLI-POLICY-27-005. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-RISK-66-001 | DONE | Implement `stella risk profile list` with category filtering, pagination, and JSON output. | DevEx/CLI Guild, Policy Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-RISK-66-002 | DONE | Ship `stella risk simulate` supporting SBOM/asset inputs, diff mode, and export to JSON/CSV. Dependencies: CLI-RISK-66-001. | DevEx/CLI Guild, Risk Engine Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-RISK-67-001 | DONE | Provide `stella risk results` with filtering, severity thresholds, explainability fetch. Dependencies: CLI-RISK-66-002. | DevEx/CLI Guild, Findings Ledger Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-RISK-68-001 | DONE | Add `stella risk bundle verify` and integrate with offline risk bundles. Dependencies: CLI-RISK-67-001. | DevEx/CLI Guild, Export Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-SDK-62-001 | DONE | Replace bespoke HTTP clients with official SDK (TS/Go) for all CLI commands; ensure modular transport for air-gapped mode. | DevEx/CLI Guild, SDK Generator Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-SDK-62-002 | DONE | Update CLI error handling to surface standardized API error envelope with `error.code` and `trace_id`. Dependencies: CLI-SDK-62-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-SDK-63-001 | DONE | Expose `stella api spec download` command retrieving aggregate OAS and verifying checksum/ETag. Dependencies: CLI-SDK-62-002. | DevEx/CLI Guild, API Governance Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-SDK-64-001 | DONE | Add CLI subcommand `stella sdk update` to fetch latest SDK manifests/changelogs; integrate with Notifications for deprecations. Dependencies: CLI-SDK-63-001. | DevEx/CLI Guild, SDK Release Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-SIG-26-001 | DONE | Implement `stella reachability upload-callgraph` and `stella reachability list/explain` commands with streaming upload, pagination, and exit codes. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-SIG-26-002 | DONE | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). Dependencies: CLI-SIG-26-001. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Sprint 205 - Experience & SDKs · 180.A) Cli.V
|
|
||||||
|
|
||||||
> **BLOCKED Tasks:** Before working on BLOCKED tasks, review [BLOCKED_DEPENDENCY_TREE.md](./BLOCKED_DEPENDENCY_TREE.md) for root blockers and dependencies.
|
|
||||||
|
|
||||||
Active items only. Completed/historic work now resides in docs/implplan/archived/tasks.md (updated 2025-11-08).
|
|
||||||
|
|
||||||
[Experience & SDKs] 180.A) Cli.V
|
|
||||||
Depends on: Sprint 180.A - Cli.IV
|
|
||||||
Summary: Experience & SDKs focus on Cli (phase V).
|
|
||||||
Task ID | State | Task description | Owners (Source)
|
|
||||||
--- | --- | --- | ---
|
|
||||||
CLI-TEN-47-001 | DONE | Implement `stella login`, `whoami`, `tenants list`, persistent profiles, secure token storage, and `--tenant` override with validation. Completed: `auth login`/`auth whoami` existed; `tenants list`/`use`/`current`/`clear` commands added; TenantProfileStore for persistent profiles at ~/.stellaops/profile.json; global `--tenant` option with profile fallback; tenant validation against Authority when available. Token storage uses existing file cache at ~/.stellaops/tokens/. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-TEN-49-001 | DONE | Add service account token minting, delegation (`stella token delegate`), impersonation banner, and audit-friendly logging. Completed: `auth token mint` and `auth token delegate` commands; TokenMint/DelegateRequest/Response models; AuthorityConsoleClient extended with MintTokenAsync, DelegateTokenAsync, IntrospectTokenAsync; CheckAndDisplayImpersonationBannerAsync helper for audit-aware impersonation notices. Note: Authority service endpoints (POST /console/token/mint, /delegate, /introspect) need backend implementation. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VEX-30-001 | DONE | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. Completed: VexModels.cs with request/response records; IBackendOperationsClient.ListVexConsensusAsync; BackendOperationsClient implementation calling GET /api/vex/consensus; BuildVexCommand in CommandFactory.cs with `vex consensus list` subcommand; HandleVexConsensusListAsync handler with table/JSON/CSV output, tenant resolution via TenantProfileStore, pagination support. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VEX-30-002 | DONE | Implement `stella vex consensus show` displaying quorum, evidence, rationale, signature status. Dependencies: CLI-VEX-30-001. Completed: VexConsensusDetailResponse with quorum/rationale/signature/evidence models; IBackendOperationsClient.GetVexConsensusAsync; BackendOperationsClient implementation calling GET /api/vex/consensus/{vulnId}/{productKey}; `vex consensus show` subcommand in CommandFactory.cs; HandleVexConsensusShowAsync handler with rich Spectre.Console formatted output including panels and tables for all sections. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VEX-30-003 | DONE | Implement `stella vex simulate` for trust/threshold overrides with JSON diff output. Dependencies: CLI-VEX-30-002. Completed: VexSimulationRequest/Response models with TrustOverrides, ThresholdOverride, QuorumOverride, ExcludeProviders; SimulateVexConsensusAsync interface and implementation calling POST /api/vex/consensus/simulate; `vex simulate` command with --trust provider=weight, --threshold, --quorum, --exclude, --include-only, --changed-only options; HandleVexSimulateAsync handler with before/after diff table and summary panel. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VEX-30-004 | DONE | Implement `stella vex export` for consensus NDJSON bundles with signature verification helper. Dependencies: CLI-VEX-30-003. Completed: VexExportRequest/Response models with format, signed, filter options; VexExportVerifyRequest/Result for local verification; IBackendOperationsClient.ExportVexConsensusAsync (POST /api/vex/consensus/export) and DownloadVexExportAsync (GET /api/vex/consensus/export/{exportId}); `vex export` command with --vuln-id, --product-key, --purl, --status, --output, --unsigned filters; `vex export verify` subcommand with --expected-digest and --public-key for local digest/signature verification; HandleVexExportAsync handler with download and progress display; HandleVexVerifyAsync for offline verification with SHA-256 digest calculation. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VULN-29-001 | DONE | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. Completed: VulnModels.cs with VulnListRequest/Response, VulnItem, VulnSeverityInfo, VulnAffectedPackage, VulnGroupingInfo, VulnGroup and all models for CLI-VULN-29-002 through CLI-VULN-29-005; IBackendOperationsClient extended with ListVulnerabilitiesAsync, GetVulnerabilityAsync, ExecuteVulnWorkflowAsync, SimulateVulnerabilitiesAsync, ExportVulnerabilitiesAsync, DownloadVulnExportAsync; BackendOperationsClient HTTP implementations calling GET/POST /api/vuln/*; `vuln list` command with --vuln-id, --severity, --status, --purl, --cpe, --sbom-id, --policy-id, --policy-version, --group-by, --limit, --offset, --cursor, --tenant, --json, --csv options; HandleVulnListAsync handler with grouped and individual table output, CSV output, color-coded severity/status display. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VULN-29-002 | DONE | Implement `stella vuln show` displaying evidence, policy rationale, paths, ledger summary; support `--json` for automation. Dependencies: CLI-VULN-29-001. Completed: `vuln show` subcommand with vulnerability-id argument, --tenant, --json, --verbose options; HandleVulnShowAsync handler; RenderVulnDetail helper with Spectre.Console panels and tables for: header (ID, status, severity, VEX, aliases, assignee, dates), description, affected packages table, policy rationale panel with rules, evidence table, dependency paths, workflow ledger history table, references list. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VULN-29-003 | DONE | Add workflow commands (`assign`, `comment`, `accept-risk`, `verify-fix`, `target-fix`, `reopen`) with filter selection (`--filter`) and idempotent retries. Dependencies: CLI-VULN-29-002. Completed: Six workflow subcommands under `vuln` command: `assign <assignee>`, `comment <text>`, `accept-risk <justification> [--due-date]`, `verify-fix <fix-version>`, `target-fix <version> [--due-date]`, `reopen <comment>`. All commands share common options: --vuln-id (multi-value), --filter-severity, --filter-status, --filter-purl, --filter-sbom for bulk operations; --tenant, --idempotency-key for retries, --json for automation. HandleVulnWorkflowAsync handler builds VulnWorkflowRequest with action-specific fields, calls ExecuteVulnWorkflowAsync (POST /api/vuln/workflow), renders success/error table with affected counts. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VULN-29-004 | DONE | Implement `stella vuln simulate` producing delta summaries and optional Markdown report for CI. Dependencies: CLI-VULN-29-003. Completed: `vuln simulate` subcommand with --policy-id, --policy-version, --vex-override vulnId=status (multi), --severity-threshold, --sbom-id (multi), --markdown, --changed-only, --output (file), --tenant, --json options; HandleVulnSimulateAsync handler parsing VEX overrides into Dictionary, building VulnSimulationRequest, calling SimulateVulnerabilitiesAsync (POST /api/vuln/simulate); output includes simulation summary panel (total/changed/upgrades/downgrades/nochange), delta table with before/after status and change indicator (UPGRADE/DOWNGRADE), optional Markdown report to file or console for CI integration. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VULN-29-005 | DONE | Add `stella vuln export` and `stella vuln bundle verify` commands to trigger/download evidence bundles and verify signatures. Dependencies: CLI-VULN-29-004. Completed: `vuln export` command with --vuln-id (multi), --sbom-id (multi), --policy-id, --format (ndjson/json), --include-evidence, --include-ledger, --signed (defaults true), --output (required), --tenant options; HandleVulnExportAsync handler calling ExportVulnerabilitiesAsync (POST /api/vuln/export) and DownloadVulnExportAsync to stream bundle to file; output displays item count, format, signature info, digest; `vuln export verify` subcommand with file argument, --expected-digest, --public-key options; HandleVulnExportVerifyAsync performs SHA-256 digest calculation, optional signature file detection (.sig), renders verification panel with pass/fail status. | DevEx/CLI Guild (src/Cli/StellaOps.Cli)
|
|
||||||
CLI-VULN-29-006 | DONE | Update CLI docs/examples for Vulnerability Explorer with compliance checklist and CI snippets. Dependencies: CLI-VULN-29-005. Completed: Created docs/modules/cli/guides/vuln-explorer-cli.md with comprehensive documentation covering: Prerequisites (scopes, connectivity); vuln list with filters, grouping, pagination, --json/--csv; vuln show with all output sections; Workflow commands (assign, comment, accept-risk, verify-fix, target-fix, reopen) with idempotency support; vuln simulate for policy/VEX delta analysis with CI Markdown output; vuln export and export verify for compliance bundles; Exit codes table; Compliance checklist (inventory, SLA, risk acceptance audit, evidence bundles); CI pipeline snippets for GitHub Actions, GitLab CI, Jenkins; Offline operation guidance. | DevEx/CLI Guild, Docs Guild (src/Cli/StellaOps.Cli)
|
|
||||||
@@ -59,6 +59,8 @@
|
|||||||
| 2 | Deliver reachability evidence fixture (columns, call paths, overlays) for SIG-26 chain. | Signals Guild | 2025-12-04 | TODO |
|
| 2 | Deliver reachability evidence fixture (columns, call paths, overlays) for SIG-26 chain. | Signals Guild | 2025-12-04 | TODO |
|
||||||
| 3 | Define SBOM Graph overlay performance budget (FPS target, node count, halo rendering limits). | UI Guild | 2025-12-05 | TODO |
|
| 3 | Define SBOM Graph overlay performance budget (FPS target, node count, halo rendering limits). | UI Guild | 2025-12-05 | TODO |
|
||||||
| 4 | Align UI III work to `src/Web/StellaOps.Web` (canonical Angular workspace); ensure reachability fixtures available. | DevEx · UI Guild | 2025-12-06 | TODO |
|
| 4 | Align UI III work to `src/Web/StellaOps.Web` (canonical Angular workspace); ensure reachability fixtures available. | DevEx · UI Guild | 2025-12-06 | TODO |
|
||||||
|
| 5 | Publish generated `graph:*` scope exports package (SDK 0208) and drop link/hash for UI consumption. | SDK Generator Guild | 2025-12-08 | TODO |
|
||||||
|
| 6 | Provide deterministic SIG-26 fixture bundle (columns/badges JSON, call-path/timeline NDJSON, overlay halos, coverage/missing-sensor datasets) with perf budget notes. | Signals Guild · Graph Platform Guild | 2025-12-09 | TODO |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
| Risk | Impact | Mitigation | Owner / Signal |
|
| Risk | Impact | Mitigation | Owner / Signal |
|
||||||
|
|||||||
@@ -61,6 +61,13 @@
|
|||||||
- VEX Lens spec PLVL0103 + SSE envelope excerpt for console streams (owner: VEX Lens Guild; due: 2025-12-06; status: new action to unblock CONSOLE-VEX-30-001 and keep samples consistent across `docs/api/console/samples/`).
|
- VEX Lens spec PLVL0103 + SSE envelope excerpt for console streams (owner: VEX Lens Guild; due: 2025-12-06; status: new action to unblock CONSOLE-VEX-30-001 and keep samples consistent across `docs/api/console/samples/`).
|
||||||
- Advisory AI gateway policy/contract snapshot for `/advisory/ai/*` routes (owner: BE-Base Platform; due: 2025-12-05; status: new action to unblock WEB-AIAI-31-001/002/003).
|
- Advisory AI gateway policy/contract snapshot for `/advisory/ai/*` routes (owner: BE-Base Platform; due: 2025-12-05; status: new action to unblock WEB-AIAI-31-001/002/003).
|
||||||
- Restore workspace disk/PTY availability so Web console implementation can proceed (owner: DevOps Guild; due: 2025-12-02; status: in progress 2025-12-01).
|
- Restore workspace disk/PTY availability so Web console implementation can proceed (owner: DevOps Guild; due: 2025-12-02; status: in progress 2025-12-01).
|
||||||
|
| # | Action | Owner | Due | Status |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Publish console export bundle orchestration contract + manifest schema and streaming limits; add samples to `docs/api/console/samples/`. | Policy Guild · Console Guild | 2025-12-08 | TODO |
|
||||||
|
| 2 | Define caching/tie-break rules and download manifest format (signed metadata) for `/console/search` + `/console/downloads`. | Policy Guild · DevOps Guild | 2025-12-09 | TODO |
|
||||||
|
| 3 | Provide exception schema, RBAC scopes, audit + rate-limit rules for `/exceptions` CRUD; attach to sprint and `docs/api/console/`. | Policy Guild · Platform Events | 2025-12-09 | TODO |
|
||||||
|
| 4 | Restore PTY/shell capacity on web host (openpty exhaustion) to allow tests/builds. | DevOps Guild | 2025-12-07 | TODO |
|
||||||
|
| 5 | Publish advisory AI gateway location + RBAC/ABAC + rate-limit policy. | BE-Base Platform | 2025-12-08 | TODO |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
| Risk | Impact | Mitigation | Owner | Status |
|
| Risk | Impact | Mitigation | Owner | Status |
|
||||||
@@ -85,6 +92,7 @@
|
|||||||
| 2025-12-04 | Re-reviewed CONSOLE-VULN-29-001 and CONSOLE-VEX-30-001: WEB-CONSOLE-23-001 and Excititor console contract are complete, but Concelier graph schema snapshot and VEX Lens PLVL0103 spec/SSE envelope remain outstanding; keeping both tasks BLOCKED. | Project Mgmt |
|
| 2025-12-04 | Re-reviewed CONSOLE-VULN-29-001 and CONSOLE-VEX-30-001: WEB-CONSOLE-23-001 and Excititor console contract are complete, but Concelier graph schema snapshot and VEX Lens PLVL0103 spec/SSE envelope remain outstanding; keeping both tasks BLOCKED. | Project Mgmt |
|
||||||
| 2025-12-06 | Marked WEB-CONSOLE-23-003/004/005 and WEB-EXC-25-001 BLOCKED pending export/exception contracts (bundle orchestration, caching rules, signed manifest metadata, exception audit policy). No code changes applied until contracts land. | Implementer |
|
| 2025-12-06 | Marked WEB-CONSOLE-23-003/004/005 and WEB-EXC-25-001 BLOCKED pending export/exception contracts (bundle orchestration, caching rules, signed manifest metadata, exception audit policy). No code changes applied until contracts land. | Implementer |
|
||||||
| 2025-12-06 | Added ordered unblock plan for Web I (exports, exceptions, PTY restore, advisory AI). | Project Mgmt |
|
| 2025-12-06 | Added ordered unblock plan for Web I (exports, exceptions, PTY restore, advisory AI). | Project Mgmt |
|
||||||
|
| 2025-12-06 | Created placeholder contract docs: `docs/api/gateway/export-center.md` (export bundles) and `docs/api/console/exception-schema.md` (exceptions CRUD). Awaiting owner inputs to replace placeholders. | Project Mgmt |
|
||||||
| 2025-12-01 | Started WEB-CONSOLE-23-002: added console status client (polling) + SSE run stream, store/service, and UI component; unit specs added. Commands/tests not executed locally due to PTY/disk constraint. | BE-Base Platform Guild |
|
| 2025-12-01 | Started WEB-CONSOLE-23-002: added console status client (polling) + SSE run stream, store/service, and UI component; unit specs added. Commands/tests not executed locally due to PTY/disk constraint. | BE-Base Platform Guild |
|
||||||
| 2025-11-07 | Enforced unknown-field detection, added shared `AocError` payload (HTTP + CLI), refreshed guard docs, and extended tests/endpoint helpers. | BE-Base Platform Guild |
|
| 2025-11-07 | Enforced unknown-field detection, added shared `AocError` payload (HTTP + CLI), refreshed guard docs, and extended tests/endpoint helpers. | BE-Base Platform Guild |
|
||||||
| 2025-11-07 | API scaffolding started for console workspace; `docs/advisory-ai/console.md` using placeholder responses while endpoints wire up. | Console Guild |
|
| 2025-11-07 | API scaffolding started for console workspace; `docs/advisory-ai/console.md` using placeholder responses while endpoints wire up. | Console Guild |
|
||||||
|
|||||||
@@ -64,6 +64,9 @@
|
|||||||
| Clear PTY exhaustion on dev host to restore shell access | DevOps Guild | 2025-11-30 | Blocked: `openpty: No space left on device` when starting shells; required before implementation proceeds. |
|
| Clear PTY exhaustion on dev host to restore shell access | DevOps Guild | 2025-11-30 | Blocked: `openpty: No space left on device` when starting shells; required before implementation proceeds. |
|
||||||
| Publish ratified Graph overlay/cache schema snapshot to sprint attachments | Graph Platform Guild | 2025-12-02 | Open |
|
| Publish ratified Graph overlay/cache schema snapshot to sprint attachments | Graph Platform Guild | 2025-12-02 | Open |
|
||||||
| Confirm Export Center streaming/range limits and signed URL policy for gateway | Export Center Guild | 2025-12-03 | Open |
|
| Confirm Export Center streaming/range limits and signed URL policy for gateway | Export Center Guild | 2025-12-03 | Open |
|
||||||
|
| Provide Export Center profile/run/download/distribution contracts + retention/encryption params; add samples to `docs/api/export-center/`. | Export Center Guild | 2025-12-08 | TODO |
|
||||||
|
| Deliver advisory service schema + RBAC scopes and VEX Lens PLVL0103 SSE envelope with samples to `docs/api/console/workspaces.md`. | Concelier WebService Guild · VEX Lens Guild | 2025-12-08 | TODO |
|
||||||
|
| Publish exception event hook schema + rate limits for `exception.*` notifications. | Platform Events Guild | 2025-12-09 | TODO |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
| Risk | Impact | Mitigation | Owner | Status |
|
| Risk | Impact | Mitigation | Owner | Status |
|
||||||
@@ -87,4 +90,5 @@
|
|||||||
| 2025-11-30 | Resolved duplicate Graph task IDs: `WEB-GRAPH-24-002` (assets endpoints), `WEB-GRAPH-24-003` (AOC summaries), `WEB-GRAPH-24-004` (telemetry). Synced tasks-all entries accordingly. | Project Mgmt |
|
| 2025-11-30 | Resolved duplicate Graph task IDs: `WEB-GRAPH-24-002` (assets endpoints), `WEB-GRAPH-24-003` (AOC summaries), `WEB-GRAPH-24-004` (telemetry). Synced tasks-all entries accordingly. | Project Mgmt |
|
||||||
| 2025-11-30 | Marked WEB-EXC-25-002 BLOCKED due to host PTY exhaustion (`openpty: No space left on device`); need shell access restored to continue implementation. | Implementer |
|
| 2025-11-30 | Marked WEB-EXC-25-002 BLOCKED due to host PTY exhaustion (`openpty: No space left on device`); need shell access restored to continue implementation. | Implementer |
|
||||||
| 2025-12-06 | Marked WEB-EXC-25-003, WEB-EXPORT-35/36/37-001, WEB-GRAPH-21-003/004, WEB-GRAPH-24-001/002/003/004, WEB-LNM-21-001/002 BLOCKED pending upstream contracts (Export Center, Graph overlay, advisory/VEX schemas) and restoration of shell capacity. No code changes made. | Implementer |
|
| 2025-12-06 | Marked WEB-EXC-25-003, WEB-EXPORT-35/36/37-001, WEB-GRAPH-21-003/004, WEB-GRAPH-24-001/002/003/004, WEB-LNM-21-001/002 BLOCKED pending upstream contracts (Export Center, Graph overlay, advisory/VEX schemas) and restoration of shell capacity. No code changes made. | Implementer |
|
||||||
|
| 2025-12-06 | Added placeholder docs: `docs/api/gateway/export-center.md` (Export Center gateway), `docs/api/graph/overlay-schema.md`, and `docs/api/console/exception-schema.md` to capture required inputs; awaiting owner-provided schemas/fixtures. | Project Mgmt |
|
||||||
| 2025-12-06 | Added ordered unblock plan for Web II (Export Center → Graph overlay → advisory/VEX schemas → shell restore → exception hooks). | Project Mgmt |
|
| 2025-12-06 | Added ordered unblock plan for Web II (Export Center → Graph overlay → advisory/VEX schemas → shell restore → exception hooks). | Project Mgmt |
|
||||||
|
|||||||
@@ -59,7 +59,13 @@
|
|||||||
- 2025-12-06 (UTC) · Notifications event schema review for severity transitions (BE-Base Platform Guild · Notifications Guild).
|
- 2025-12-06 (UTC) · Notifications event schema review for severity transitions (BE-Base Platform Guild · Notifications Guild).
|
||||||
|
|
||||||
## Action Tracker
|
## Action Tracker
|
||||||
- Covered by Delivery Tracker rows 16–18 (contract docs for tenant headers/ABAC, Findings Ledger proxy, and notifier schema); keep due dates aligned with checkpoints.
|
| # | Action | Owner | Due (UTC) | Status |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Provide stable npm install path (mirror or node_modules tarball) to clear `npm ci` hangs for risk/signals gateway tests. | Platform Ops | 2025-12-07 | TODO |
|
||||||
|
| 2 | Publish Signals API contract + fixtures (callgraphs/facts, reachability scoring) for WEB-SIG-26-001..003. | Signals Guild | 2025-12-08 | TODO |
|
||||||
|
| 3 | If any ABAC header mapping delta beyond v1.0 exists, publish update note + sample request. | BE-Base Platform Guild | 2025-12-08 | TODO |
|
||||||
|
| 4 | Publish VEX consensus stream contract (RBAC/ABAC, caching, SSE payload) and sample to `docs/api/vex/consensus.md`. | VEX Lens Guild | 2025-12-09 | TODO |
|
||||||
|
| 5 | Provide Findings Ledger idempotency header wiring example for gateway vuln workflow (forwarding). | Findings Ledger Guild | 2025-12-09 | TODO |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
| Risk | Impact | Mitigation | Owner | Status |
|
| Risk | Impact | Mitigation | Owner | Status |
|
||||||
@@ -108,4 +114,5 @@
|
|||||||
| 2025-11-30 | Added contract/doc tasks (rows 16–18) for tenant headers/ABAC, Findings Ledger proxy headers, and notifier severity events; aligned Action Tracker with Delivery Tracker; no status changes to feature tracks. | Project Mgmt |
|
| 2025-11-30 | Added contract/doc tasks (rows 16–18) for tenant headers/ABAC, Findings Ledger proxy headers, and notifier severity events; aligned Action Tracker with Delivery Tracker; no status changes to feature tracks. | Project Mgmt |
|
||||||
| 2025-11-30 | Normalized sprint to standard template and renamed file from `SPRINT_216_web_v.md` to `SPRINT_0216_0001_0001_web_v.md`; no task status changes. | Project Mgmt |
|
| 2025-11-30 | Normalized sprint to standard template and renamed file from `SPRINT_216_web_v.md` to `SPRINT_0216_0001_0001_web_v.md`; no task status changes. | Project Mgmt |
|
||||||
| 2025-12-06 | Added ordered unblock plan for Web V (env/npm fix → Signals contract → tenant/ABAC delta → VEX consensus → Findings Ledger wiring → rerun specs). | Project Mgmt |
|
| 2025-12-06 | Added ordered unblock plan for Web V (env/npm fix → Signals contract → tenant/ABAC delta → VEX consensus → Findings Ledger wiring → rerun specs). | Project Mgmt |
|
||||||
|
| 2025-12-06 | Created placeholder docs: `docs/api/signals/reachability-contract.md` and `docs/api/vex-consensus.md` to collect required contracts/fixtures; awaiting guild inputs. | Project Mgmt |
|
||||||
| 2025-12-06 | Propagated BLOCKED status from WEB-RISK-66-001 to downstream risk chain (66-002/67-001/68-001) and from missing Signals/tenant/VEX contracts to WEB-SIG-26-001..003 and WEB-VEX/VULN chain. No code changes applied until contracts and install env stabilise. | Implementer |
|
| 2025-12-06 | Propagated BLOCKED status from WEB-RISK-66-001 to downstream risk chain (66-002/67-001/68-001) and from missing Signals/tenant/VEX contracts to WEB-SIG-26-001..003 and WEB-VEX/VULN chain. No code changes applied until contracts and install env stabilise. | Implementer |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Sprint 0309 · Documentation & Process · Docs Tasks Md IX
|
# Sprint 0309 · Documentation & Process · Docs Tasks Md IX
|
||||||
|
|
||||||
## Topic & Scope
|
## Topic & Scope
|
||||||
- Phase Md.IX of the docs ladder, covering risk UI/CLI flows, offline risk bundles, SDK overview/language guides, auth/redaction security docs, and the reachability/signals doc chain (states, callgraphs, runtime facts, weighting, UI overlays, CLI, API).
|
- Phase Md.IX of the docs ladder, covering risk UI/CLI flows, offline risk bundles, SDK overview/language guides, auth/redaction security docs, and the reachability/signals doc chain (states, callgraphs, runtime facts, weighting, UI overlays, CLI, API).
|
||||||
@@ -41,34 +41,34 @@
|
|||||||
## Wave Detail Snapshots
|
## Wave Detail Snapshots
|
||||||
- No additional wave snapshots; Delivery Tracker ordering suffices for this single-wave sprint.
|
- No additional wave snapshots; Delivery Tracker ordering suffices for this single-wave sprint.
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2025-12-05 | Normalised sprint to standard template; clarified header; moved interlocks into Decisions & Risks; no task status changes. | Project Mgmt |
|
| 2025-12-05 | Normalised sprint to standard template; clarified header; moved interlocks into Decisions & Risks; no task status changes. | Project Mgmt |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
- **Decision:** Keep Md.IX scope limited to risk/SDK/security/signals doc set; defer new module docs until upstream assets arrive (Docs Guild, due 2025-12-05).
|
- **Decision:** Keep Md.IX scope limited to risk/SDK/security/signals doc set; defer new module docs until upstream assets arrive (Docs Guild, due 2025-12-05).
|
||||||
- **Risk:** DOCS-RISK-67-002 and console assets not yet delivered, blocking DOCS-RISK-67-003/004/68-001/68-002 chain. Mitigation: track in `BLOCKED_DEPENDENCY_TREE.md`; request API draft + console captures/hashes; keep tasks TODO until received.
|
- **Risk:** DOCS-RISK-67-002 and console assets not yet delivered, blocking DOCS-RISK-67-003/004/68-001/68-002 chain. Mitigation: track in `BLOCKED_DEPENDENCY_TREE.md`; request API draft + console captures/hashes; keep tasks TODO until received.
|
||||||
- **Risk:** Signals chain (DOCS-SIG-26-001..007) depends on schema/asset hand-offs from Signals, UI, and CLI guilds. Mitigation: maintain Action Tracker reminders; do not start without assets.
|
- **Risk:** Signals chain (DOCS-SIG-26-001..007) depends on schema/asset hand-offs from Signals, UI, and CLI guilds. Mitigation: maintain Action Tracker reminders; do not start without assets.
|
||||||
- **Risk:** SDK deliverable requires generator outputs across four languages; drift risk if guides proceed without samples. Mitigation: block on generator outputs; cross-check hashes on arrival.
|
- **Risk:** SDK deliverable requires generator outputs across four languages; drift risk if guides proceed without samples. Mitigation: block on generator outputs; cross-check hashes on arrival.
|
||||||
|
|
||||||
## Next Checkpoints
|
## Next Checkpoints
|
||||||
- 2025-12-08 · Md.VIII → Md.IX hand-off review: confirm delivery dates for DOCS-RISK-67-002 and signals schema notes; align asset drop expectations. Owners: Docs Guild · Console Guild · Signals Guild.
|
- 2025-12-08 · Md.VIII → Md.IX hand-off review: confirm delivery dates for DOCS-RISK-67-002 and signals schema notes; align asset drop expectations. Owners: Docs Guild · Console Guild · Signals Guild.
|
||||||
- 2025-12-12 · Md.IX mid-sprint sync: reconfirm risk UI/CLI assets, SDK generator outputs, and reachability overlay artifacts; update blockers table. Owners: Docs Guild · CLI Guild · UI Guild · SDK Generator Guild.
|
- 2025-12-12 · Md.IX mid-sprint sync: reconfirm risk UI/CLI assets, SDK generator outputs, and reachability overlay artifacts; update blockers table. Owners: Docs Guild · CLI Guild · UI Guild · SDK Generator Guild.
|
||||||
|
|
||||||
## Action Tracker
|
## Action Tracker
|
||||||
- Collect console risk UI captures + deterministic hashes for DOCS-RISK-67-003 — Console Guild — Due 2025-12-10 — Open.
|
- Collect console risk UI captures + deterministic hashes for DOCS-RISK-67-003 — Console Guild — Due 2025-12-10 — Open.
|
||||||
- Deliver SDK generator sample outputs for TS/Python/Go/Java to unblock DOCS-SDK-62-001 — SDK Generator Guild — Due 2025-12-11 — Open.
|
- Deliver SDK generator sample outputs for TS/Python/Go/Java to unblock DOCS-SDK-62-001 — SDK Generator Guild — Due 2025-12-11 — Open.
|
||||||
- Provide DOCS-RISK-67-002 draft (risk API) so DOCS-RISK-67-003 outline can be finalized — API Guild — Due 2025-12-09 — Open.
|
- Provide DOCS-RISK-67-002 draft (risk API) so DOCS-RISK-67-003 outline can be finalized — API Guild — Due 2025-12-09 — Open.
|
||||||
- Share signals schema/overlay assets (states, callgraphs, UI overlays) needed for DOCS-SIG-26-001..005 — Signals Guild · UI Guild — Due 2025-12-09 — Open.
|
- Share signals schema/overlay assets (states, callgraphs, UI overlays) needed for DOCS-SIG-26-001..005 — Signals Guild · UI Guild — Due 2025-12-09 — ✅ DONE (2025-12-06: `docs/schemas/signals-integration.schema.json` created).
|
||||||
- Send export bundle shapes + hashing inputs for DOCS-RISK-68-001 — Export Guild — Due 2025-12-11 — Open.
|
- Send export bundle shapes + hashing inputs for DOCS-RISK-68-001 — Export Guild — Due 2025-12-11 — Open.
|
||||||
- Deliver OAuth2/PAT scope matrix + tenancy header rules for DOCS-SEC-62-001 — Security Guild · Authority Core — Due 2025-12-11 — Open.
|
- Deliver OAuth2/PAT scope matrix + tenancy header rules for DOCS-SEC-62-001 — Security Guild · Authority Core — Due 2025-12-11 — Open.
|
||||||
- Provide telemetry privacy controls + opt-in debug flow for DOCS-SEC-OBS-50-001 — Security Guild — Due 2025-12-11 — Open.
|
- Provide telemetry privacy controls + opt-in debug flow for DOCS-SEC-OBS-50-001 — Security Guild — Due 2025-12-11 — Open.
|
||||||
- Supply SPL weighting guidance + sample predicates for DOCS-SIG-26-004 — Policy Guild — Due 2025-12-10 — Open.
|
- Supply SPL weighting guidance + sample predicates for DOCS-SIG-26-004 — Policy Guild — Due 2025-12-10 — Open.
|
||||||
- Provide CLI reachability command updates and automation recipes for DOCS-SIG-26-006 — DevEx/CLI Guild — Due 2025-12-12 — Open.
|
- Provide CLI reachability command updates and automation recipes for DOCS-SIG-26-006 — DevEx/CLI Guild — Due 2025-12-12 — Open.
|
||||||
- Hand over incident-mode activation/escalation checklist for DOCS-RUNBOOK-55-001 — Ops Guild — Due 2025-12-10 — Open.
|
- Hand over incident-mode activation/escalation checklist for DOCS-RUNBOOK-55-001 — Ops Guild — Due 2025-12-10 — Open.
|
||||||
- Escalate to Guild leads if any Md.IX inputs miss due dates (12-09..12) and re-plan by 2025-12-13 — Docs Guild — Due 2025-12-13 — Open.
|
- Escalate to Guild leads if any Md.IX inputs miss due dates (12-09..12) and re-plan by 2025-12-13 — Docs Guild — Due 2025-12-13 — Open.
|
||||||
- Send reminder pings to all Md.IX owning guilds 24h before due dates (start 2025-12-09) — Project Mgmt — Due 2025-12-09 — Open.
|
- Send reminder pings to all Md.IX owning guilds 24h before due dates (start 2025-12-09) — Project Mgmt — Due 2025-12-09 — Open.
|
||||||
| Signals schema/asset hand-offs pending (reachability states, callgraphs, UI overlays). | Blocks DOCS-SIG-26-001..007 sequence. | Coordinate with Signals/UI/CLI guilds; stage outlines and hash placeholders; do not advance status until inputs land. |
|
| Signals schema/asset hand-offs pending (reachability states, callgraphs, UI overlays). | Blocks DOCS-SIG-26-001..007 sequence. | Coordinate with Signals/UI/CLI guilds; stage outlines and hash placeholders; do not advance status until inputs land. |
|
||||||
| SDK generator outputs not finalized across four languages. | Delays DOCS-SDK-62-001 and downstream language guides. | Ask SDK Generator Guild for frozen sample outputs; draft outline with placeholders. |
|
| SDK generator outputs not finalized across four languages. | Delays DOCS-SDK-62-001 and downstream language guides. | Ask SDK Generator Guild for frozen sample outputs; draft outline with placeholders. |
|
||||||
| Md.IX input due dates (Dec 9–12) slip without re-plan. | Pushes all Md.IX docs; risks missing sprint window. | Escalate to guild leads on 2025-12-13 and rebaseline dates; keep action tracker updated. |
|
| Md.IX input due dates (Dec 9–12) slip without re-plan. | Pushes all Md.IX docs; risks missing sprint window. | Escalate to guild leads on 2025-12-13 and rebaseline dates; keep action tracker updated. |
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ public async Task MultipleInstances_ShouldNotApplyMigrationsTwice()
|
|||||||
| 2025-12-03 | Note: CLI build blocked by pre-existing Scanner module errors | Claude |
|
| 2025-12-03 | Note: CLI build blocked by pre-existing Scanner module errors | Claude |
|
||||||
| 2025-12-06 | Added CLI AGENTS.md to unblock MIG-T2.8; CLI build still pending Scanner fixes; integration tests not yet added. | Project Mgmt |
|
| 2025-12-06 | Added CLI AGENTS.md to unblock MIG-T2.8; CLI build still pending Scanner fixes; integration tests not yet added. | Project Mgmt |
|
||||||
| 2025-12-06 | Wired `system migrations-*` commands to MigrationRunner/Status with connection overrides and release guard; awaiting DB to add integration tests. | Implementer |
|
| 2025-12-06 | Wired `system migrations-*` commands to MigrationRunner/Status with connection overrides and release guard; awaiting DB to add integration tests. | Implementer |
|
||||||
|
| 2025-12-06 | dotnet test for CLI ran with SDK 10.0.100; blocked by upstream Concelier connector compile errors (missing Mongo storage types). MIG-T2.8 remains partially verified. | Implementer |
|
||||||
|
|
||||||
---
|
---
|
||||||
*Reference: docs/db/MIGRATION_STRATEGY.md*
|
*Reference: docs/db/MIGRATION_STRATEGY.md*
|
||||||
|
|||||||
47
docs/implplan/archived/SPRINT_0204_0001_0004_cli_iv.md
Normal file
47
docs/implplan/archived/SPRINT_0204_0001_0004_cli_iv.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Sprint 204 - Experience & SDKs · 180.A) Cli.IV
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Experience & SDKs focus on CLI (phase IV) covering policy lifecycle, risk workflows, SDK uplift, and reachability commands.
|
||||||
|
- Deliver CLI parity with Policy Studio outputs and offline-friendly risk/simulator flows.
|
||||||
|
- Working directory: `src/Cli` (StellaOps.Cli and docs).
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on Sprint 180.A - Cli.III deliverables.
|
||||||
|
- Review `BLOCKED_DEPENDENCY_TREE.md` before resuming any deferred follow-ups.
|
||||||
|
- Historical tasks are mirrored in `docs/implplan/archived/tasks.md` (2025-11-08).
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- docs/README.md
|
||||||
|
- docs/modules/platform/architecture-overview.md
|
||||||
|
- docs/modules/cli/architecture.md
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | CLI-POLICY-27-002 | DONE | Depends on CLI-POLICY-27-001 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. |
|
||||||
|
| 2 | CLI-POLICY-27-003 | DONE | Depends on CLI-POLICY-27-002 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. |
|
||||||
|
| 3 | CLI-POLICY-27-004 | DONE | Depends on CLI-POLICY-27-003 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. |
|
||||||
|
| 4 | CLI-POLICY-27-005 | DONE | Depends on CLI-POLICY-27-004 | DevEx/CLI Guild; Docs Guild (`src/Cli/StellaOps.Cli`) | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. |
|
||||||
|
| 5 | CLI-POLICY-27-006 | DONE | Depends on CLI-POLICY-27-005 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Update CLI policy profiles/help text to request the new Policy Studio scope family; surface ProblemDetails guidance for `invalid_scope`; adjust regression tests for scope failures. |
|
||||||
|
| 6 | CLI-RISK-66-001 | DONE | None | DevEx/CLI Guild; Policy Guild (`src/Cli/StellaOps.Cli`) | Implement `stella risk profile list` with category filtering, pagination, and JSON output. |
|
||||||
|
| 7 | CLI-RISK-66-002 | DONE | Depends on CLI-RISK-66-001 | DevEx/CLI Guild; Risk Engine Guild (`src/Cli/StellaOps.Cli`) | Ship `stella risk simulate` supporting SBOM/asset inputs, diff mode, and export to JSON/CSV. |
|
||||||
|
| 8 | CLI-RISK-67-001 | DONE | Depends on CLI-RISK-66-002 | DevEx/CLI Guild; Findings Ledger Guild (`src/Cli/StellaOps.Cli`) | Provide `stella risk results` with filtering, severity thresholds, explainability fetch. |
|
||||||
|
| 9 | CLI-RISK-68-001 | DONE | Depends on CLI-RISK-67-001 | DevEx/CLI Guild; Export Guild (`src/Cli/StellaOps.Cli`) | Add `stella risk bundle verify` and integrate with offline risk bundles. |
|
||||||
|
| 10 | CLI-SDK-62-001 | DONE | None | DevEx/CLI Guild; SDK Generator Guild (`src/Cli/StellaOps.Cli`) | Replace bespoke HTTP clients with official SDK (TS/Go) for all CLI commands; ensure modular transport for air-gapped mode. |
|
||||||
|
| 11 | CLI-SDK-62-002 | DONE | Depends on CLI-SDK-62-001 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Update CLI error handling to surface standardized API error envelope with `error.code` and `trace_id`. |
|
||||||
|
| 12 | CLI-SDK-63-001 | DONE | Depends on CLI-SDK-62-002 | DevEx/CLI Guild; API Governance Guild (`src/Cli/StellaOps.Cli`) | Expose `stella api spec download` command retrieving aggregate OAS and verifying checksum/ETag. |
|
||||||
|
| 13 | CLI-SDK-64-001 | DONE | Depends on CLI-SDK-63-001 | DevEx/CLI Guild; SDK Release Guild (`src/Cli/StellaOps.Cli`) | Add CLI subcommand `stella sdk update` to fetch latest SDK manifests/changelogs; integrate with Notifications for deprecations. |
|
||||||
|
| 14 | CLI-SIG-26-001 | DONE | None | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella reachability upload-callgraph` and `stella reachability list/explain` commands with streaming upload, pagination, and exit codes. |
|
||||||
|
| 15 | CLI-SIG-26-002 | DONE | Depends on CLI-SIG-26-001 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Extend `stella policy simulate` with reachability override flags (`--reachability-state`, `--reachability-score`). |
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | Archived to docs/implplan/archived/SPRINT_0204_0001_0004_cli_iv.md; all tasks DONE. | Project Mgmt |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Policy and reachability command set is complete; relies on upstream Policy Studio scopes and API envelopes already adopted.
|
||||||
|
- No open implementation risks noted; monitor downstream SDK release cadence for compatibility.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- Archived 2025-12-06; no further checkpoints scheduled.
|
||||||
43
docs/implplan/archived/SPRINT_0205_0001_0005_cli_v.md
Normal file
43
docs/implplan/archived/SPRINT_0205_0001_0005_cli_v.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Sprint 205 - Experience & SDKs · 180.A) Cli.V
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Experience & SDKs focus on CLI (phase V) completing tenant flows and VEX/vulnerability command set.
|
||||||
|
- Harden authentication/tenant profile management and round out VEX + vulnerability workflows with exports and simulations.
|
||||||
|
- Working directory: `src/Cli` (StellaOps.Cli and docs).
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Depends on Sprint 180.A - Cli.IV deliverables.
|
||||||
|
- Historical tasks are mirrored in `docs/implplan/archived/tasks.md` (2025-11-08).
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- docs/README.md
|
||||||
|
- docs/modules/platform/architecture-overview.md
|
||||||
|
- docs/modules/cli/architecture.md
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | CLI-TEN-47-001 | DONE | None | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella login`, `whoami`, `tenants list`, persistent profiles, secure token storage, and `--tenant` override with validation (TenantProfileStore; ~/.stellaops/profile.json). |
|
||||||
|
| 2 | CLI-TEN-49-001 | DONE | Depends on CLI-TEN-47-001 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Add service account token minting, delegation, impersonation banner, and audit-friendly logging. Authority service endpoints for mint/delegate/introspect still required server-side. |
|
||||||
|
| 3 | CLI-VEX-30-001 | DONE | None | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella vex consensus list` with filters, paging, policy selection, `--json/--csv`. |
|
||||||
|
| 4 | CLI-VEX-30-002 | DONE | Depends on CLI-VEX-30-001 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella vex consensus show` displaying quorum, evidence, rationale, signature status. |
|
||||||
|
| 5 | CLI-VEX-30-003 | DONE | Depends on CLI-VEX-30-002 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella vex simulate` for trust/threshold overrides with JSON diff output. |
|
||||||
|
| 6 | CLI-VEX-30-004 | DONE | Depends on CLI-VEX-30-003 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella vex export` for consensus NDJSON bundles with signature verification helper. |
|
||||||
|
| 7 | CLI-VULN-29-001 | DONE | None | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella vuln list` with grouping, paging, filters, `--json/--csv`, and policy selection. |
|
||||||
|
| 8 | CLI-VULN-29-002 | DONE | Depends on CLI-VULN-29-001 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella vuln show` displaying evidence, policy rationale, paths, ledger summary; support `--json` for automation. |
|
||||||
|
| 9 | CLI-VULN-29-003 | DONE | Depends on CLI-VULN-29-002 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Add workflow commands (`assign`, `comment`, `accept-risk`, `verify-fix`, `target-fix`, `reopen`) with filter selection and idempotent retries. |
|
||||||
|
| 10 | CLI-VULN-29-004 | DONE | Depends on CLI-VULN-29-003 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Implement `stella vuln simulate` producing delta summaries and optional Markdown report for CI. |
|
||||||
|
| 11 | CLI-VULN-29-005 | DONE | Depends on CLI-VULN-29-004 | DevEx/CLI Guild (`src/Cli/StellaOps.Cli`) | Add `stella vuln export` and `stella vuln bundle verify` commands to trigger/download evidence bundles and verify signatures. |
|
||||||
|
| 12 | CLI-VULN-29-006 | DONE | Depends on CLI-VULN-29-005 | DevEx/CLI Guild; Docs Guild (`src/Cli/StellaOps.Cli`) | Update CLI docs/examples for Vulnerability Explorer with compliance checklist and CI snippets. |
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2025-12-06 | Archived to docs/implplan/archived/SPRINT_0205_0001_0005_cli_v.md; all tasks DONE. | Project Mgmt |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Authority service endpoints for token mint/delegate/introspect must exist server-side to fully activate CLI-TEN-49-001; track in Authority backlog.
|
||||||
|
- VEX/ vulnerability command set complete and aligned to current backend contracts; monitor for API drift.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- Archived 2025-12-06; no further checkpoints scheduled.
|
||||||
@@ -344,6 +344,7 @@ public static class HmacPurpose
|
|||||||
|
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
|------------|--------|-------|
|
|------------|--------|-------|
|
||||||
|
| 2025-12-06 | Archived to docs/implplan/archived/SPRINT_0515_0001_0001_crypto_compliance_migration.md; all tasks DONE. | Project Mgmt |
|
||||||
| 2025-12-05 | Completed CanonicalJsonHasher.cs migration and all callers | Implementer |
|
| 2025-12-05 | Completed CanonicalJsonHasher.cs migration and all callers | Implementer |
|
||||||
| 2025-12-05 | Completed MerkleTreeBuilder.cs migration and all callers | Implementer |
|
| 2025-12-05 | Completed MerkleTreeBuilder.cs migration and all callers | Implementer |
|
||||||
| 2025-12-05 | Completed DeterministicHash.cs migration to static method pattern | Implementer |
|
| 2025-12-05 | Completed DeterministicHash.cs migration to static method pattern | Implementer |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# component_architecture_scanner.md — **Stella Ops Scanner** (2025Q4)
|
# component_architecture_scanner.md — **Stella Ops Scanner** (2025Q4)
|
||||||
|
|
||||||
> Aligned with Epic 6 – Vulnerability Explorer and Epic 10 – Export Center.
|
> Aligned with Epic 6 – Vulnerability Explorer and Epic 10 – Export Center.
|
||||||
|
|
||||||
> **Scope.** Implementation‑ready architecture for the **Scanner** subsystem: WebService, Workers, analyzers, SBOM assembly (inventory & usage), per‑layer caching, three‑way diffs, artifact catalog (RustFS default + Mongo, S3-compatible fallback), attestation hand‑off, and scale/security posture. This document is the contract between the scanning plane and everything else (Policy, Excititor, Concelier, UI, CLI).
|
> **Scope.** Implementation‑ready architecture for the **Scanner** subsystem: WebService, Workers, analyzers, SBOM assembly (inventory & usage), per‑layer caching, three‑way diffs, artifact catalog (RustFS default + Mongo, S3-compatible fallback), attestation hand‑off, and scale/security posture. This document is the contract between the scanning plane and everything else (Policy, Excititor, Concelier, UI, CLI).
|
||||||
|
|
||||||
@@ -30,31 +30,31 @@ src/
|
|||||||
├─ StellaOps.Scanner.Cache/ # layer cache; file CAS; bloom/bitmap indexes
|
├─ StellaOps.Scanner.Cache/ # layer cache; file CAS; bloom/bitmap indexes
|
||||||
├─ StellaOps.Scanner.EntryTrace/ # ENTRYPOINT/CMD → terminal program resolver (shell AST)
|
├─ StellaOps.Scanner.EntryTrace/ # ENTRYPOINT/CMD → terminal program resolver (shell AST)
|
||||||
├─ StellaOps.Scanner.Analyzers.OS.[Apk|Dpkg|Rpm]/
|
├─ StellaOps.Scanner.Analyzers.OS.[Apk|Dpkg|Rpm]/
|
||||||
├─ StellaOps.Scanner.Analyzers.Lang.[Java|Node|Python|Go|DotNet|Rust]/
|
├─ StellaOps.Scanner.Analyzers.Lang.[Java|Node|Bun|Python|Go|DotNet|Rust|Ruby|Php]/
|
||||||
├─ StellaOps.Scanner.Analyzers.Native.[ELF|PE|MachO]/ # PE/Mach-O planned (M2)
|
├─ StellaOps.Scanner.Analyzers.Native.[ELF|PE|MachO]/ # PE/Mach-O planned (M2)
|
||||||
├─ StellaOps.Scanner.Symbols.Native/ # NEW – native symbol reader/demangler (Sprint 401)
|
├─ StellaOps.Scanner.Symbols.Native/ # NEW – native symbol reader/demangler (Sprint 401)
|
||||||
├─ StellaOps.Scanner.CallGraph.Native/ # NEW – function/call-edge builder + CAS emitter
|
├─ StellaOps.Scanner.CallGraph.Native/ # NEW – function/call-edge builder + CAS emitter
|
||||||
├─ StellaOps.Scanner.Emit.CDX/ # CycloneDX (JSON + Protobuf)
|
├─ StellaOps.Scanner.Emit.CDX/ # CycloneDX (JSON + Protobuf)
|
||||||
├─ StellaOps.Scanner.Emit.SPDX/ # SPDX 3.0.1 JSON
|
├─ StellaOps.Scanner.Emit.SPDX/ # SPDX 3.0.1 JSON
|
||||||
├─ StellaOps.Scanner.Diff/ # image→layer→component three‑way diff
|
├─ StellaOps.Scanner.Diff/ # image→layer→component three‑way diff
|
||||||
├─ StellaOps.Scanner.Index/ # BOM‑Index sidecar (purls + roaring bitmaps)
|
├─ StellaOps.Scanner.Index/ # BOM‑Index sidecar (purls + roaring bitmaps)
|
||||||
├─ StellaOps.Scanner.Tests.* # unit/integration/e2e fixtures
|
├─ StellaOps.Scanner.Tests.* # unit/integration/e2e fixtures
|
||||||
└─ Tools/
|
└─ Tools/
|
||||||
├─ StellaOps.Scanner.Sbomer.BuildXPlugin/ # BuildKit generator (image referrer SBOMs)
|
├─ StellaOps.Scanner.Sbomer.BuildXPlugin/ # BuildKit generator (image referrer SBOMs)
|
||||||
└─ StellaOps.Scanner.Sbomer.DockerImage/ # CLI‑driven scanner container
|
└─ StellaOps.Scanner.Sbomer.DockerImage/ # CLI‑driven scanner container
|
||||||
```
|
```
|
||||||
|
|
||||||
Analyzer assemblies and buildx generators are packaged as **restart-time plug-ins** under `plugins/scanner/**` with manifests; services must restart to activate new plug-ins.
|
Analyzer assemblies and buildx generators are packaged as **restart-time plug-ins** under `plugins/scanner/**` with manifests; services must restart to activate new plug-ins.
|
||||||
|
|
||||||
### 1.2 Native reachability upgrades (Nov 2026)
|
### 1.2 Native reachability upgrades (Nov 2026)
|
||||||
|
|
||||||
- **Stripped-binary pipeline**: native analyzers must recover functions even without symbols (prolog patterns, xrefs, PLT/GOT, vtables). Emit a tool-agnostic neutral JSON (NJIF) with functions, CFG/CG, and evidence tags. Keep heuristics deterministic and record toolchain hashes in the scan manifest.
|
- **Stripped-binary pipeline**: native analyzers must recover functions even without symbols (prolog patterns, xrefs, PLT/GOT, vtables). Emit a tool-agnostic neutral JSON (NJIF) with functions, CFG/CG, and evidence tags. Keep heuristics deterministic and record toolchain hashes in the scan manifest.
|
||||||
- **Synthetic roots**: treat `.preinit_array`, `.init_array`, legacy `.ctors`, and `_init` as graph entrypoints; add roots for constructors in each `DT_NEEDED` dependency. Tag edges from these roots with `phase=load` for explainers.
|
- **Synthetic roots**: treat `.preinit_array`, `.init_array`, legacy `.ctors`, and `_init` as graph entrypoints; add roots for constructors in each `DT_NEEDED` dependency. Tag edges from these roots with `phase=load` for explainers.
|
||||||
- **Build-id capture**: read `.note.gnu.build-id` for every ELF, store hex build-id alongside soname/path, propagate into `SymbolID`/`code_id`, and expose it to SBOM + runtime joiners. If missing, fall back to file hash and mark source accordingly.
|
- **Build-id capture**: read `.note.gnu.build-id` for every ELF, store hex build-id alongside soname/path, propagate into `SymbolID`/`code_id`, and expose it to SBOM + runtime joiners. If missing, fall back to file hash and mark source accordingly.
|
||||||
- **PURL-resolved edges**: annotate call edges with the callee purl and `symbol_digest` so graphs merge with SBOM components. See `docs/reachability/purl-resolved-edges.md` for schema rules and acceptance tests.
|
- **PURL-resolved edges**: annotate call edges with the callee purl and `symbol_digest` so graphs merge with SBOM components. See `docs/reachability/purl-resolved-edges.md` for schema rules and acceptance tests.
|
||||||
- **Unknowns emission**: when symbol → purl mapping or edge targets remain unresolved, emit structured Unknowns to Signals (see `docs/signals/unknowns-registry.md`) instead of dropping evidence.
|
- **Unknowns emission**: when symbol → purl mapping or edge targets remain unresolved, emit structured Unknowns to Signals (see `docs/signals/unknowns-registry.md`) instead of dropping evidence.
|
||||||
- **Hybrid attestation**: emit **graph-level DSSE** for every `richgraph-v1` (mandatory) and optional **edge-bundle DSSE** (≤512 edges) for runtime/init-root/contested edges or third-party provenance. Publish graph DSSE digests to Rekor by default; edge-bundle Rekor publish is policy-driven. CAS layout: `cas://reachability/graphs/{blake3}` for graph body, `.../{blake3}.dsse` for envelope, and `cas://reachability/edges/{graph_hash}/{bundle_id}[.dsse]` for bundles. Deterministic ordering before hashing/signing is required.
|
- **Hybrid attestation**: emit **graph-level DSSE** for every `richgraph-v1` (mandatory) and optional **edge-bundle DSSE** (≤512 edges) for runtime/init-root/contested edges or third-party provenance. Publish graph DSSE digests to Rekor by default; edge-bundle Rekor publish is policy-driven. CAS layout: `cas://reachability/graphs/{blake3}` for graph body, `.../{blake3}.dsse` for envelope, and `cas://reachability/edges/{graph_hash}/{bundle_id}[.dsse]` for bundles. Deterministic ordering before hashing/signing is required.
|
||||||
- **Deterministic call-graph manifest**: capture analyzer versions, feed hashes, toolchain digests, and flags in a manifest stored alongside `richgraph-v1`; replaying with the same manifest MUST yield identical node/edge sets and hashes (see `docs/reachability/lead.md`).
|
- **Deterministic call-graph manifest**: capture analyzer versions, feed hashes, toolchain digests, and flags in a manifest stored alongside `richgraph-v1`; replaying with the same manifest MUST yield identical node/edge sets and hashes (see `docs/reachability/lead.md`).
|
||||||
|
|
||||||
### 1.1 Queue backbone (Redis / NATS)
|
### 1.1 Queue backbone (Redis / NATS)
|
||||||
|
|
||||||
@@ -144,9 +144,10 @@ No confidences. Either a fact is proven with listed mechanisms, or it is not cla
|
|||||||
* `images { imageDigest, repo, tag?, arch, createdAt, lastSeen }`
|
* `images { imageDigest, repo, tag?, arch, createdAt, lastSeen }`
|
||||||
* `layers { layerDigest, mediaType, size, createdAt, lastSeen }`
|
* `layers { layerDigest, mediaType, size, createdAt, lastSeen }`
|
||||||
* `links { fromType, fromDigest, artifactId }` // image/layer -> artifact
|
* `links { fromType, fromDigest, artifactId }` // image/layer -> artifact
|
||||||
* `jobs { _id, kind, args, state, startedAt, heartbeatAt, endedAt, error }`
|
* `jobs { _id, kind, args, state, startedAt, heartbeatAt, endedAt, error }`
|
||||||
* `lifecycleRules { ruleId, scope, ttlDays, retainIfReferenced, immutable }`
|
* `lifecycleRules { ruleId, scope, ttlDays, retainIfReferenced, immutable }`
|
||||||
* `ruby.packages { _id: scanId, imageDigest, generatedAtUtc, packages[] }` // decoded `RubyPackageInventory` documents for CLI/Policy reuse
|
* `ruby.packages { _id: scanId, imageDigest, generatedAtUtc, packages[] }` // decoded `RubyPackageInventory` documents for CLI/Policy reuse
|
||||||
|
* `bun.packages { _id: scanId, imageDigest, generatedAtUtc, packages[] }` // decoded `BunPackageInventory` documents for CLI/Policy reuse
|
||||||
|
|
||||||
### 3.3 Object store layout (RustFS)
|
### 3.3 Object store layout (RustFS)
|
||||||
|
|
||||||
@@ -175,10 +176,11 @@ All under `/api/v1/scanner`. Auth: **OpTok** (DPoP/mTLS); RBAC scopes.
|
|||||||
|
|
||||||
```
|
```
|
||||||
POST /scans { imageRef|digest, force?:bool } → { scanId }
|
POST /scans { imageRef|digest, force?:bool } → { scanId }
|
||||||
GET /scans/{id} → { status, imageDigest, artifacts[], rekor? }
|
GET /scans/{id} → { status, imageDigest, artifacts[], rekor? }
|
||||||
GET /sboms/{imageDigest} ?format=cdx-json|cdx-pb|spdx-json&view=inventory|usage → bytes
|
GET /sboms/{imageDigest} ?format=cdx-json|cdx-pb|spdx-json&view=inventory|usage → bytes
|
||||||
GET /scans/{id}/ruby-packages → { scanId, imageDigest, generatedAt, packages[] }
|
GET /scans/{id}/ruby-packages → { scanId, imageDigest, generatedAt, packages[] }
|
||||||
GET /diff?old=<digest>&new=<digest>&view=inventory|usage → diff.json
|
GET /scans/{id}/bun-packages → { scanId, imageDigest, generatedAt, packages[] }
|
||||||
|
GET /diff?old=<digest>&new=<digest>&view=inventory|usage → diff.json
|
||||||
POST /exports { imageDigest, format, view, attest?:bool } → { artifactId, rekor? }
|
POST /exports { imageDigest, format, view, attest?:bool } → { artifactId, rekor? }
|
||||||
POST /reports { imageDigest, policyRevision? } → { reportId, rekor? } # delegates to backend policy+vex
|
POST /reports { imageDigest, policyRevision? } → { reportId, rekor? } # delegates to backend policy+vex
|
||||||
GET /catalog/artifacts/{id} → { meta }
|
GET /catalog/artifacts/{id} → { meta }
|
||||||
@@ -223,6 +225,7 @@ When `scanner.events.enabled = true`, the WebService serialises the signed repor
|
|||||||
|
|
||||||
* **Java**: `META-INF/maven/*/pom.properties`, MANIFEST → `pkg:maven/...`
|
* **Java**: `META-INF/maven/*/pom.properties`, MANIFEST → `pkg:maven/...`
|
||||||
* **Node**: `node_modules/**/package.json` → `pkg:npm/...`
|
* **Node**: `node_modules/**/package.json` → `pkg:npm/...`
|
||||||
|
* **Bun**: `bun.lock` (JSONC text) + `node_modules/**/package.json` + `node_modules/.bun/**/package.json` (isolated linker) → `pkg:npm/...`; `bun.lockb` (binary) emits remediation guidance
|
||||||
* **Python**: `*.dist-info/{METADATA,RECORD}` → `pkg:pypi/...`
|
* **Python**: `*.dist-info/{METADATA,RECORD}` → `pkg:pypi/...`
|
||||||
* **Go**: Go **buildinfo** in binaries → `pkg:golang/...`
|
* **Go**: Go **buildinfo** in binaries → `pkg:golang/...`
|
||||||
* **.NET**: `*.deps.json` + assembly metadata → `pkg:nuget/...`
|
* **.NET**: `*.deps.json` + assembly metadata → `pkg:nuget/...`
|
||||||
@@ -230,18 +233,18 @@ When `scanner.events.enabled = true`, the WebService serialises the signed repor
|
|||||||
|
|
||||||
> **Rule:** We only report components proven **on disk** with authoritative metadata. Lockfiles are evidence only.
|
> **Rule:** We only report components proven **on disk** with authoritative metadata. Lockfiles are evidence only.
|
||||||
|
|
||||||
**C) Native link graph**
|
**C) Native link graph**
|
||||||
|
|
||||||
* **ELF**: parse `PT_INTERP`, `DT_NEEDED`, RPATH/RUNPATH, **GNU symbol versions**; map **SONAMEs** to file paths; link executables → libs.
|
* **ELF**: parse `PT_INTERP`, `DT_NEEDED`, RPATH/RUNPATH, **GNU symbol versions**; map **SONAMEs** to file paths; link executables → libs.
|
||||||
* **PE/Mach‑O** (planned M2): import table, delay‑imports; version resources; code signatures.
|
* **PE/Mach‑O** (planned M2): import table, delay‑imports; version resources; code signatures.
|
||||||
* Map libs back to **OS packages** if possible (via file lists); else emit `bin:{sha256}` components.
|
* Map libs back to **OS packages** if possible (via file lists); else emit `bin:{sha256}` components.
|
||||||
* The exported metadata (`stellaops.os.*` properties, license list, source package) feeds policy scoring and export pipelines
|
* The exported metadata (`stellaops.os.*` properties, license list, source package) feeds policy scoring and export pipelines
|
||||||
directly – Policy evaluates quiet rules against package provenance while Exporters forward the enriched fields into
|
directly – Policy evaluates quiet rules against package provenance while Exporters forward the enriched fields into
|
||||||
downstream JSON/Trivy payloads.
|
downstream JSON/Trivy payloads.
|
||||||
* **Reachability lattice**: analyzers + runtime probes emit `Evidence`/`Mitigation` records (see `docs/reachability/lattice.md`). The lattice engine joins static path evidence, runtime hits (EventPipe/JFR), taint flows, environment gates, and mitigations into `ReachDecision` documents that feed VEX gating and event graph storage.
|
* **Reachability lattice**: analyzers + runtime probes emit `Evidence`/`Mitigation` records (see `docs/reachability/lattice.md`). The lattice engine joins static path evidence, runtime hits (EventPipe/JFR), taint flows, environment gates, and mitigations into `ReachDecision` documents that feed VEX gating and event graph storage.
|
||||||
* Sprint 401 introduces `StellaOps.Scanner.Symbols.Native` (DWARF/PDB reader + demangler) and `StellaOps.Scanner.CallGraph.Native`
|
* Sprint 401 introduces `StellaOps.Scanner.Symbols.Native` (DWARF/PDB reader + demangler) and `StellaOps.Scanner.CallGraph.Native`
|
||||||
(function boundary detector + call-edge builder). These libraries feed `FuncNode`/`CallEdge` CAS bundles and enrich reachability
|
(function boundary detector + call-edge builder). These libraries feed `FuncNode`/`CallEdge` CAS bundles and enrich reachability
|
||||||
graphs with `{code_id, confidence, evidence}` so Signals/Policy/UI can cite function-level justifications.
|
graphs with `{code_id, confidence, evidence}` so Signals/Policy/UI can cite function-level justifications.
|
||||||
|
|
||||||
**D) EntryTrace (ENTRYPOINT/CMD → terminal program)**
|
**D) EntryTrace (ENTRYPOINT/CMD → terminal program)**
|
||||||
|
|
||||||
@@ -273,10 +276,10 @@ The emitted `buildId` metadata is preserved in component hashes, diff payloads,
|
|||||||
|
|
||||||
### 5.6 DSSE attestation (via Signer/Attestor)
|
### 5.6 DSSE attestation (via Signer/Attestor)
|
||||||
|
|
||||||
* WebService constructs **predicate** with `image_digest`, `stellaops_version`, `license_id`, `policy_digest?` (when emitting **final reports**), timestamps.
|
* WebService constructs **predicate** with `image_digest`, `stellaops_version`, `license_id`, `policy_digest?` (when emitting **final reports**), timestamps.
|
||||||
* Calls **Signer** (requires **OpTok + PoE**); Signer verifies **entitlement + scanner image integrity** and returns **DSSE bundle**.
|
* Calls **Signer** (requires **OpTok + PoE**); Signer verifies **entitlement + scanner image integrity** and returns **DSSE bundle**.
|
||||||
* **Attestor** logs to **Rekor v2**; returns `{uuid,index,proof}` → stored in `artifacts.rekor`.
|
* **Attestor** logs to **Rekor v2**; returns `{uuid,index,proof}` → stored in `artifacts.rekor`.
|
||||||
* Operator enablement runbooks (toggles, env-var map, rollout guidance) live in [`operations/dsse-rekor-operator-guide.md`](operations/dsse-rekor-operator-guide.md) per SCANNER-ENG-0015.
|
* Operator enablement runbooks (toggles, env-var map, rollout guidance) live in [`operations/dsse-rekor-operator-guide.md`](operations/dsse-rekor-operator-guide.md) per SCANNER-ENG-0015.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -333,7 +336,7 @@ scanner:
|
|||||||
objectLock: "governance" # or 'compliance'
|
objectLock: "governance" # or 'compliance'
|
||||||
analyzers:
|
analyzers:
|
||||||
os: { apk: true, dpkg: true, rpm: true }
|
os: { apk: true, dpkg: true, rpm: true }
|
||||||
lang: { java: true, node: true, python: true, go: true, dotnet: true, rust: true }
|
lang: { java: true, node: true, bun: true, python: true, go: true, dotnet: true, rust: true, ruby: true, php: true }
|
||||||
native: { elf: true, pe: false, macho: false } # PE/Mach-O in M2
|
native: { elf: true, pe: false, macho: false } # PE/Mach-O in M2
|
||||||
entryTrace: { enabled: true, shellMaxDepth: 64, followRunParts: true }
|
entryTrace: { enabled: true, shellMaxDepth: 64, followRunParts: true }
|
||||||
emit:
|
emit:
|
||||||
@@ -478,17 +481,17 @@ ResolveEntrypoint(ImageConfig cfg, RootFs fs):
|
|||||||
return Unknown(reason)
|
return Unknown(reason)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Appendix A.1 — EntryTrace Explainability
|
### Appendix A.1 — EntryTrace Explainability
|
||||||
|
|
||||||
### Appendix A.0 — Replay / Record mode
|
### Appendix A.0 — Replay / Record mode
|
||||||
|
|
||||||
- WebService ships a **RecordModeService** that assembles replay manifests (schema v1) with policy/feed/tool pins and reachability references, then writes deterministic input/output bundles to the configured object store (RustFS default, S3/Minio fallback) under `replay/<head>/<digest>.tar.zst`.
|
- WebService ships a **RecordModeService** that assembles replay manifests (schema v1) with policy/feed/tool pins and reachability references, then writes deterministic input/output bundles to the configured object store (RustFS default, S3/Minio fallback) under `replay/<head>/<digest>.tar.zst`.
|
||||||
- Bundles contain canonical manifest JSON plus inputs (policy/feed/tool/analyzer digests) and outputs (SBOM, findings, optional VEX/logs); CAS URIs follow `cas://replay/...` and are attached to scan snapshots as `ReplayArtifacts`.
|
- Bundles contain canonical manifest JSON plus inputs (policy/feed/tool/analyzer digests) and outputs (SBOM, findings, optional VEX/logs); CAS URIs follow `cas://replay/...` and are attached to scan snapshots as `ReplayArtifacts`.
|
||||||
- Reachability graphs/traces are folded into the manifest via `ReachabilityReplayWriter`; manifests and bundles hash with stable ordering for replay verification (`docs/replay/DETERMINISTIC_REPLAY.md`).
|
- Reachability graphs/traces are folded into the manifest via `ReachabilityReplayWriter`; manifests and bundles hash with stable ordering for replay verification (`docs/replay/DETERMINISTIC_REPLAY.md`).
|
||||||
- Worker sealed-mode intake reads `replay.bundle.uri` + `replay.bundle.sha256` (plus determinism feed/policy pins) from job metadata, persists bundle refs in analysis and surface manifest, and validates hashes before use.
|
- Worker sealed-mode intake reads `replay.bundle.uri` + `replay.bundle.sha256` (plus determinism feed/policy pins) from job metadata, persists bundle refs in analysis and surface manifest, and validates hashes before use.
|
||||||
- Deterministic execution switches (`docs/modules/scanner/deterministic-execution.md`) must be enabled when generating replay bundles to keep hashes stable.
|
- Deterministic execution switches (`docs/modules/scanner/deterministic-execution.md`) must be enabled when generating replay bundles to keep hashes stable.
|
||||||
|
|
||||||
EntryTrace emits structured diagnostics and metrics so operators can quickly understand why resolution succeeded or degraded:
|
EntryTrace emits structured diagnostics and metrics so operators can quickly understand why resolution succeeded or degraded:
|
||||||
|
|
||||||
| Reason | Description | Typical Mitigation |
|
| Reason | Description | Typical Mitigation |
|
||||||
|--------|-------------|--------------------|
|
|--------|-------------|--------------------|
|
||||||
|
|||||||
146
docs/modules/scanner/bun-analyzer-gotchas.md
Normal file
146
docs/modules/scanner/bun-analyzer-gotchas.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Bun Analyzer Developer Gotchas
|
||||||
|
|
||||||
|
This document covers common pitfalls and considerations when working with the Bun analyzer.
|
||||||
|
|
||||||
|
## 1. Isolated Installs Are Symlink-Heavy
|
||||||
|
|
||||||
|
Bun's isolated linker (`bun install --linker isolated`) creates a flat store under `node_modules/.bun/` with symlinks for package resolution. This differs from the default hoisted layout.
|
||||||
|
|
||||||
|
**Implications:**
|
||||||
|
- The analyzer must traverse `node_modules/.bun/**/package.json` in addition to `node_modules/**/package.json`
|
||||||
|
- Symlink safety guards are critical to prevent infinite loops and out-of-root traversal
|
||||||
|
- Both logical and real paths are recorded in evidence for traceability
|
||||||
|
- Performance guards (`MaxSymlinkDepth=10`, `MaxFilesPerRoot=50000`) are enforced
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Use the `IsolatedLinkerInstallIsParsedAsync` test fixture to verify `.bun/` traversal
|
||||||
|
- Use the `SymlinkSafetyIsEnforcedAsync` test fixture for symlink corner cases
|
||||||
|
|
||||||
|
## 2. `node_modules/.bun/` Scanning Requirement
|
||||||
|
|
||||||
|
Unlike Node.js, Bun may store packages entirely under `node_modules/.bun/` with only symlinks in the top-level `node_modules/`. If your scanner configuration excludes `.bun/` directories, you will miss dependencies.
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
- Ensure glob patterns include `.bun/` subdirectories
|
||||||
|
- Do not filter out hidden directories in container scans
|
||||||
|
- Verify evidence shows packages from both `node_modules/` and `node_modules/.bun/`
|
||||||
|
|
||||||
|
## 3. `bun.lockb` Migration Path
|
||||||
|
|
||||||
|
The binary lockfile (`bun.lockb`) format is undocumented and unstable. The analyzer treats it as **unsupported** and emits a remediation finding.
|
||||||
|
|
||||||
|
**Migration command:**
|
||||||
|
```bash
|
||||||
|
bun install --save-text-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates `bun.lock` (JSONC text format) which the analyzer can parse.
|
||||||
|
|
||||||
|
**WebService response:** When only `bun.lockb` is present:
|
||||||
|
- The scan completes but reports unsupported status
|
||||||
|
- Remediation guidance is included in findings
|
||||||
|
- No package inventory is generated
|
||||||
|
|
||||||
|
## 4. JSONC Lockfile Format
|
||||||
|
|
||||||
|
`bun.lock` uses JSONC (JSON with Comments) format supporting:
|
||||||
|
- Single-line comments (`// ...`)
|
||||||
|
- Multi-line comments (`/* ... */`)
|
||||||
|
- Trailing commas in arrays and objects
|
||||||
|
|
||||||
|
**Parser considerations:**
|
||||||
|
- The `BunLockParser` tolerates these JSONC features
|
||||||
|
- Standard JSON parsers will fail on `bun.lock` files
|
||||||
|
- Format may evolve with Bun releases; parser is intentionally tolerant
|
||||||
|
|
||||||
|
## 5. Multi-Stage Build Implications
|
||||||
|
|
||||||
|
In multi-stage Docker builds, the final image may contain only production artifacts without the lockfile or `node_modules/.bun/` directory.
|
||||||
|
|
||||||
|
**Scanning strategies:**
|
||||||
|
1. **Image scanning (recommended for production):** Scans the final image filesystem. Set `include_dev: false` to filter dev dependencies
|
||||||
|
2. **Repository scanning:** Scans `bun.lock` from source. Includes all dependencies by default (`include_dev: true`)
|
||||||
|
|
||||||
|
**Best practice:** Scan both the repository (for complete visibility) and production images (for runtime accuracy).
|
||||||
|
|
||||||
|
## 6. npm Ecosystem Reuse
|
||||||
|
|
||||||
|
Bun packages are npm packages. The analyzer:
|
||||||
|
- Emits `pkg:npm/<name>@<version>` PURLs (same as Node analyzer)
|
||||||
|
- Uses `ecosystem = npm` for vulnerability lookups
|
||||||
|
- Adds `package_manager = bun` metadata for differentiation
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Vulnerability intelligence is shared with Node analyzer
|
||||||
|
- VEX statements for npm packages apply to Bun
|
||||||
|
- No separate Bun-specific advisory database is needed
|
||||||
|
|
||||||
|
## 7. Source Detection in Lockfile
|
||||||
|
|
||||||
|
`bun.lock` entries include source information that determines package type:
|
||||||
|
|
||||||
|
| Source Pattern | Type | Example |
|
||||||
|
|---------------|------|---------|
|
||||||
|
| No source / default registry | `registry` | `lodash@4.17.21` |
|
||||||
|
| `git+https://...` or `git://...` | `git` | VCS dependency |
|
||||||
|
| `file:` or `link:` | `tarball` | Local package |
|
||||||
|
| `workspace:` | `workspace` | Monorepo member |
|
||||||
|
|
||||||
|
The analyzer records source type in evidence for provenance tracking.
|
||||||
|
|
||||||
|
## 8. Workspace/Monorepo Handling
|
||||||
|
|
||||||
|
Bun workspaces use a single `bun.lock` at the root with multiple `package.json` files in subdirectories.
|
||||||
|
|
||||||
|
**Analyzer behavior:**
|
||||||
|
- Discovers the root by presence of `bun.lock` + `package.json`
|
||||||
|
- Traverses all `node_modules/` directories under the root
|
||||||
|
- Deduplicates packages by `(name, version)` while accumulating occurrence paths
|
||||||
|
- Records workspace member paths in metadata
|
||||||
|
|
||||||
|
**Testing:** Use the `WorkspacesAreParsedAsync` test fixture.
|
||||||
|
|
||||||
|
## 9. Dev/Prod Dependency Filtering
|
||||||
|
|
||||||
|
The `include_dev` configuration option controls whether dev dependencies are included:
|
||||||
|
|
||||||
|
| Context | Default `include_dev` | Rationale |
|
||||||
|
|---------|----------------------|-----------|
|
||||||
|
| Repository scan (lockfile-only) | `true` | Full visibility for developers |
|
||||||
|
| Image scan (installed packages) | `true` | Packages are present regardless of intent |
|
||||||
|
|
||||||
|
**Override:** Set `include_dev: false` in scan configuration to exclude dev dependencies from results.
|
||||||
|
|
||||||
|
## 10. Evidence Model
|
||||||
|
|
||||||
|
Each Bun package includes evidence with:
|
||||||
|
- `source`: Where the package was found (`node_modules`, `bun.lock`, `node_modules/.bun`)
|
||||||
|
- `locator`: File path to the evidence
|
||||||
|
- `resolved`: The resolved URL from lockfile (if available)
|
||||||
|
- `integrity`: SHA hash from lockfile (if available)
|
||||||
|
- `sha256`: File hash for installed packages
|
||||||
|
|
||||||
|
Evidence enables:
|
||||||
|
- Tracing packages to their origin
|
||||||
|
- Validating integrity
|
||||||
|
- Explaining presence in SBOM
|
||||||
|
|
||||||
|
## CLI Reference
|
||||||
|
|
||||||
|
### Inspect local workspace
|
||||||
|
```bash
|
||||||
|
stellaops-cli bun inspect --root /path/to/project
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolve packages from scan
|
||||||
|
```bash
|
||||||
|
stellaops-cli bun resolve --scan-id <id>
|
||||||
|
stellaops-cli bun resolve --digest sha256:<hash>
|
||||||
|
stellaops-cli bun resolve --ref myregistry.io/myapp:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output formats
|
||||||
|
```bash
|
||||||
|
stellaops-cli bun inspect --format json > packages.json
|
||||||
|
stellaops-cli bun inspect --format table
|
||||||
|
```
|
||||||
664
docs/schemas/evidence-pointer.schema.json
Normal file
664
docs/schemas/evidence-pointer.schema.json
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/evidence-pointer.schema.json",
|
||||||
|
"title": "StellaOps Evidence Pointer Schema",
|
||||||
|
"description": "Schema for evidence pointers used in timeline events, evidence locker snapshots, and DSSE attestations. Unblocks TASKRUN-OBS-52-001, TASKRUN-OBS-53-001, TASKRUN-OBS-54-001, TASKRUN-OBS-55-001.",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"EvidencePointer": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Pointer to evidence artifact in the evidence locker",
|
||||||
|
"required": ["pointer_id", "artifact_type", "digest", "created_at"],
|
||||||
|
"properties": {
|
||||||
|
"pointer_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Unique identifier for this evidence pointer"
|
||||||
|
},
|
||||||
|
"artifact_type": {
|
||||||
|
"$ref": "#/definitions/ArtifactType"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"$ref": "#/definitions/Digest"
|
||||||
|
},
|
||||||
|
"uri": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "URI to retrieve the artifact (may be presigned)"
|
||||||
|
},
|
||||||
|
"storage_backend": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["cas", "evidence", "attestation", "local", "s3", "azure-blob", "gcs"],
|
||||||
|
"description": "Storage backend where artifact resides"
|
||||||
|
},
|
||||||
|
"bucket": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Bucket/container name in object storage"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Object key/path within bucket"
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Size of artifact in bytes"
|
||||||
|
},
|
||||||
|
"media_type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "MIME type of the artifact"
|
||||||
|
},
|
||||||
|
"compression": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["none", "gzip", "zstd", "brotli"],
|
||||||
|
"default": "none"
|
||||||
|
},
|
||||||
|
"encryption": {
|
||||||
|
"$ref": "#/definitions/EncryptionInfo"
|
||||||
|
},
|
||||||
|
"chain_position": {
|
||||||
|
"$ref": "#/definitions/ChainPosition"
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"$ref": "#/definitions/EvidenceProvenance"
|
||||||
|
},
|
||||||
|
"redaction": {
|
||||||
|
"$ref": "#/definitions/RedactionInfo"
|
||||||
|
},
|
||||||
|
"retention": {
|
||||||
|
"$ref": "#/definitions/RetentionPolicy"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ArtifactType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"sbom",
|
||||||
|
"vex",
|
||||||
|
"attestation",
|
||||||
|
"signature",
|
||||||
|
"callgraph",
|
||||||
|
"scan_result",
|
||||||
|
"policy_evaluation",
|
||||||
|
"timeline_transcript",
|
||||||
|
"evidence_bundle",
|
||||||
|
"audit_log",
|
||||||
|
"manifest",
|
||||||
|
"provenance",
|
||||||
|
"rekor_receipt",
|
||||||
|
"runtime_trace",
|
||||||
|
"coverage_report",
|
||||||
|
"diff_report"
|
||||||
|
],
|
||||||
|
"description": "Type of evidence artifact"
|
||||||
|
},
|
||||||
|
"Digest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cryptographic digest of artifact content",
|
||||||
|
"required": ["algorithm", "value"],
|
||||||
|
"properties": {
|
||||||
|
"algorithm": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["sha256", "sha384", "sha512", "sha3-256", "sha3-384", "sha3-512"],
|
||||||
|
"default": "sha256"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-f0-9]+$",
|
||||||
|
"description": "Hex-encoded digest value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EncryptionInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Encryption information for protected artifacts",
|
||||||
|
"properties": {
|
||||||
|
"encrypted": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"algorithm": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["AES-256-GCM", "ChaCha20-Poly1305"],
|
||||||
|
"description": "Encryption algorithm used"
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Key identifier for decryption"
|
||||||
|
},
|
||||||
|
"key_provider": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["kms", "vault", "local"],
|
||||||
|
"description": "Key management provider"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ChainPosition": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Position in evidence hash chain for tamper detection",
|
||||||
|
"properties": {
|
||||||
|
"chain_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Evidence chain identifier"
|
||||||
|
},
|
||||||
|
"sequence": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Sequence number in chain"
|
||||||
|
},
|
||||||
|
"previous_digest": {
|
||||||
|
"$ref": "#/definitions/Digest"
|
||||||
|
},
|
||||||
|
"merkle_root": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-f0-9]{64}$",
|
||||||
|
"description": "Merkle tree root at this position"
|
||||||
|
},
|
||||||
|
"merkle_proof": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-f0-9]{64}$"
|
||||||
|
},
|
||||||
|
"description": "Merkle inclusion proof"
|
||||||
|
},
|
||||||
|
"anchored_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When chain was anchored to transparency log"
|
||||||
|
},
|
||||||
|
"anchor_receipt": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Receipt from transparency log (e.g., Rekor)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EvidenceProvenance": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Provenance information for evidence artifact",
|
||||||
|
"properties": {
|
||||||
|
"producer": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Service/component that produced the evidence"
|
||||||
|
},
|
||||||
|
"producer_version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"build_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "CI/CD build identifier"
|
||||||
|
},
|
||||||
|
"source_ref": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Source reference (e.g., git commit)"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"correlation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Trace correlation ID"
|
||||||
|
},
|
||||||
|
"parent_pointers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"description": "Parent evidence pointers this derives from"
|
||||||
|
},
|
||||||
|
"attestation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Associated attestation if signed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RedactionInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Redaction policy for evidence artifact",
|
||||||
|
"properties": {
|
||||||
|
"redaction_applied": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"redaction_policy": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Policy identifier that was applied"
|
||||||
|
},
|
||||||
|
"redacted_fields": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "JSON paths of redacted fields"
|
||||||
|
},
|
||||||
|
"original_digest": {
|
||||||
|
"$ref": "#/definitions/Digest"
|
||||||
|
},
|
||||||
|
"redaction_timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RetentionPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Retention policy for evidence artifact",
|
||||||
|
"properties": {
|
||||||
|
"policy_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retention_days": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"legal_hold": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"deletion_scheduled_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"immutable_until": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Cannot be modified/deleted until this time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EvidenceSnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Point-in-time snapshot of evidence locker state",
|
||||||
|
"required": ["snapshot_id", "timestamp", "pointers"],
|
||||||
|
"properties": {
|
||||||
|
"snapshot_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"snapshot_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["full", "incremental", "incident"],
|
||||||
|
"default": "incremental"
|
||||||
|
},
|
||||||
|
"pointers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/EvidencePointer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aggregate_digest": {
|
||||||
|
"$ref": "#/definitions/Digest"
|
||||||
|
},
|
||||||
|
"previous_snapshot_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"statistics": {
|
||||||
|
"$ref": "#/definitions/SnapshotStatistics"
|
||||||
|
},
|
||||||
|
"manifest_uri": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"attestation": {
|
||||||
|
"$ref": "#/definitions/SnapshotAttestation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SnapshotStatistics": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Statistics about evidence snapshot",
|
||||||
|
"properties": {
|
||||||
|
"total_artifacts": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"total_size_bytes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"artifacts_by_type": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"new_since_last": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"modified_since_last": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"deleted_since_last": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SnapshotAttestation": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "DSSE attestation for snapshot integrity",
|
||||||
|
"properties": {
|
||||||
|
"attestation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"predicate_type": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "https://stella-ops.org/attestations/evidence-snapshot/v1"
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Base64-encoded signature"
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"signed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"rekor_log_index": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Rekor transparency log index"
|
||||||
|
},
|
||||||
|
"rekor_log_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TimelineEvidenceEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Evidence entry in timeline event stream",
|
||||||
|
"required": ["entry_id", "event_type", "timestamp", "pointer"],
|
||||||
|
"properties": {
|
||||||
|
"entry_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"evidence.created",
|
||||||
|
"evidence.updated",
|
||||||
|
"evidence.accessed",
|
||||||
|
"evidence.deleted",
|
||||||
|
"evidence.redacted",
|
||||||
|
"evidence.exported",
|
||||||
|
"evidence.verified",
|
||||||
|
"evidence.anchored",
|
||||||
|
"snapshot.created",
|
||||||
|
"snapshot.verified",
|
||||||
|
"incident.started",
|
||||||
|
"incident.ended"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"pointer": {
|
||||||
|
"$ref": "#/definitions/EvidencePointer"
|
||||||
|
},
|
||||||
|
"actor": {
|
||||||
|
"$ref": "#/definitions/Actor"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pack_run_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"scan_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"previous_entry_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Actor": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Actor who performed the action",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["user", "service", "system", "automation"]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IncidentModeConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Configuration for incident mode evidence capture",
|
||||||
|
"required": ["incident_id", "started_at"],
|
||||||
|
"properties": {
|
||||||
|
"incident_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"ended_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["critical", "high", "medium", "low"]
|
||||||
|
},
|
||||||
|
"capture_mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["all", "selective", "enhanced"],
|
||||||
|
"default": "enhanced",
|
||||||
|
"description": "Level of evidence capture during incident"
|
||||||
|
},
|
||||||
|
"enhanced_retention_days": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 365,
|
||||||
|
"description": "Extended retention for incident evidence"
|
||||||
|
},
|
||||||
|
"legal_hold": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"snapshot_interval_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 15,
|
||||||
|
"description": "How often to take snapshots during incident"
|
||||||
|
},
|
||||||
|
"affected_tenants": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"affected_components": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root_cause_evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"description": "Pointer IDs of root cause evidence"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EvidenceQuery": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Query parameters for evidence retrieval",
|
||||||
|
"properties": {
|
||||||
|
"artifact_types": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ArtifactType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"$ref": "#/definitions/Digest"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"correlation_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"time_range": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include_redacted": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"include_expired": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"chain_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 1000,
|
||||||
|
"default": 100
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EvidenceQueryResult": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Result of evidence query",
|
||||||
|
"required": ["pointers", "total_count"],
|
||||||
|
"properties": {
|
||||||
|
"pointers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/EvidencePointer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"next_cursor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"query_time_ms": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/EvidencePointer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"pointer_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"artifact_type": "sbom",
|
||||||
|
"digest": {
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"value": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
|
||||||
|
},
|
||||||
|
"uri": "s3://stellaops-evidence/sbom/2025/12/06/sbom-abc123.json",
|
||||||
|
"storage_backend": "evidence",
|
||||||
|
"bucket": "stellaops-evidence",
|
||||||
|
"key": "sbom/2025/12/06/sbom-abc123.json",
|
||||||
|
"size_bytes": 45678,
|
||||||
|
"media_type": "application/vnd.cyclonedx+json",
|
||||||
|
"compression": "gzip",
|
||||||
|
"chain_position": {
|
||||||
|
"chain_id": "660e8400-e29b-41d4-a716-446655440002",
|
||||||
|
"sequence": 42,
|
||||||
|
"merkle_root": "b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567a"
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"producer": "stellaops-scanner",
|
||||||
|
"producer_version": "2025.10.0",
|
||||||
|
"tenant_id": "770e8400-e29b-41d4-a716-446655440003",
|
||||||
|
"correlation_id": "880e8400-e29b-41d4-a716-446655440004"
|
||||||
|
},
|
||||||
|
"retention": {
|
||||||
|
"retention_days": 365,
|
||||||
|
"legal_hold": false
|
||||||
|
},
|
||||||
|
"created_at": "2025-12-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
901
docs/schemas/signals-integration.schema.json
Normal file
901
docs/schemas/signals-integration.schema.json
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/signals-integration.schema.json",
|
||||||
|
"title": "StellaOps Signals Integration Schema",
|
||||||
|
"description": "Schema for runtime signals integration, callgraph formats, and signal weighting. Unblocks DOCS-SIG-26-001 through DOCS-SIG-26-007.",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"SignalState": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"active",
|
||||||
|
"inactive",
|
||||||
|
"pending",
|
||||||
|
"stale",
|
||||||
|
"error",
|
||||||
|
"unknown"
|
||||||
|
],
|
||||||
|
"description": "Current state of a signal"
|
||||||
|
},
|
||||||
|
"SignalScore": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Computed signal score with confidence",
|
||||||
|
"required": ["value", "confidence"],
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Normalized score value (0-1)"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Confidence level in the score"
|
||||||
|
},
|
||||||
|
"raw_value": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Original unnormalized value"
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ScoreComponent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ScoreComponent": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Individual component contributing to score",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1
|
||||||
|
},
|
||||||
|
"contribution": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RuntimeSignal": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Runtime observation signal from instrumented application",
|
||||||
|
"required": ["signal_id", "signal_type", "observed_at"],
|
||||||
|
"properties": {
|
||||||
|
"signal_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"signal_type": {
|
||||||
|
"$ref": "#/definitions/RuntimeSignalType"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"$ref": "#/definitions/SignalState"
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"$ref": "#/definitions/SignalScore"
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"$ref": "#/definitions/SignalSubject"
|
||||||
|
},
|
||||||
|
"observation": {
|
||||||
|
"$ref": "#/definitions/RuntimeObservation"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"$ref": "#/definitions/RuntimeEnvironment"
|
||||||
|
},
|
||||||
|
"retention": {
|
||||||
|
"$ref": "#/definitions/SignalRetention"
|
||||||
|
},
|
||||||
|
"observed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RuntimeSignalType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"function_invocation",
|
||||||
|
"code_path_execution",
|
||||||
|
"module_load",
|
||||||
|
"dependency_resolution",
|
||||||
|
"network_call",
|
||||||
|
"file_access",
|
||||||
|
"database_query",
|
||||||
|
"crypto_operation",
|
||||||
|
"serialization",
|
||||||
|
"reflection",
|
||||||
|
"dynamic_code",
|
||||||
|
"process_spawn",
|
||||||
|
"memory_allocation",
|
||||||
|
"exception_thrown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SignalSubject": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Subject of the signal (what was observed)",
|
||||||
|
"properties": {
|
||||||
|
"purl": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package URL of component"
|
||||||
|
},
|
||||||
|
"symbol": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fully qualified symbol name"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"class": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"cve_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^CVE-[0-9]{4}-[0-9]+$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RuntimeObservation": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Details of the runtime observation",
|
||||||
|
"properties": {
|
||||||
|
"call_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"first_seen": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"last_seen": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"observation_window": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Duration of observation (e.g., '7d', '30d')"
|
||||||
|
},
|
||||||
|
"sample_rate": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Sampling rate if not 100%"
|
||||||
|
},
|
||||||
|
"call_stack": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/StackFrame"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"arguments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ArgumentSummary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"StackFrame": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Stack frame in call stack",
|
||||||
|
"properties": {
|
||||||
|
"symbol": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"line": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ArgumentSummary": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Summary of argument (privacy-preserving)",
|
||||||
|
"properties": {
|
||||||
|
"position": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"is_sensitive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"hash": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Hash of value for correlation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RuntimeEnvironment": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Runtime environment context",
|
||||||
|
"properties": {
|
||||||
|
"environment": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["production", "staging", "development", "test"]
|
||||||
|
},
|
||||||
|
"deployment_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"instance_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"runtime": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Runtime platform (e.g., 'node-20.10', 'python-3.12')"
|
||||||
|
},
|
||||||
|
"container_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pod_name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalRetention": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Retention policy for signal data",
|
||||||
|
"properties": {
|
||||||
|
"retention_days": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"aggregation_after_days": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Days after which to aggregate raw data"
|
||||||
|
},
|
||||||
|
"privacy_policy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["full", "anonymized", "aggregated_only"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CallgraphFormat": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Callgraph representation format",
|
||||||
|
"required": ["format", "version"],
|
||||||
|
"properties": {
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["richgraph-v1", "dot", "json-graph", "sarif", "spdx-lite"],
|
||||||
|
"description": "Callgraph serialization format"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"generator": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"generator_version": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Callgraph": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Static or dynamic callgraph",
|
||||||
|
"required": ["callgraph_id", "format", "nodes"],
|
||||||
|
"properties": {
|
||||||
|
"callgraph_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"$ref": "#/definitions/CallgraphFormat"
|
||||||
|
},
|
||||||
|
"analysis_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["static", "dynamic", "hybrid"]
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CallgraphNode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edges": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CallgraphEdge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entry_points": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Node IDs of entry points"
|
||||||
|
},
|
||||||
|
"vulnerable_nodes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Node IDs of vulnerable symbols"
|
||||||
|
},
|
||||||
|
"statistics": {
|
||||||
|
"$ref": "#/definitions/CallgraphStatistics"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
},
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CallgraphNode": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Node in callgraph",
|
||||||
|
"required": ["id", "symbol"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"symbol": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Fully qualified symbol name"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["function", "method", "class", "module", "package", "external"]
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"line_start": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"line_end": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"purl": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"is_entry_point": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"is_vulnerable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"is_sink": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"vulnerability_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attributes": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CallgraphEdge": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Edge in callgraph",
|
||||||
|
"required": ["source", "target"],
|
||||||
|
"properties": {
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Source node ID"
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Target node ID"
|
||||||
|
},
|
||||||
|
"call_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["direct", "indirect", "virtual", "reflection", "dynamic", "callback", "async"]
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Edge weight for path analysis"
|
||||||
|
},
|
||||||
|
"call_site": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": { "type": "string" },
|
||||||
|
"line": { "type": "integer" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observed_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Call count if from dynamic analysis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CallgraphStatistics": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Statistics about callgraph",
|
||||||
|
"properties": {
|
||||||
|
"total_nodes": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_edges": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"entry_point_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"vulnerable_node_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"max_depth": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"coverage_percent": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"packages_analyzed": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CallgraphValidationError": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Validation error in callgraph",
|
||||||
|
"required": ["code", "message"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"INVALID_FORMAT",
|
||||||
|
"MISSING_REQUIRED_FIELD",
|
||||||
|
"INVALID_NODE_REFERENCE",
|
||||||
|
"CYCLE_DETECTED",
|
||||||
|
"ORPHAN_NODE",
|
||||||
|
"DUPLICATE_NODE_ID",
|
||||||
|
"INVALID_SYMBOL_FORMAT",
|
||||||
|
"UNSUPPORTED_VERSION",
|
||||||
|
"INCOMPLETE_COVERAGE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON path to error location"
|
||||||
|
},
|
||||||
|
"node_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["error", "warning", "info"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalWeightingConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Configuration for signal weighting in policy evaluation",
|
||||||
|
"required": ["config_id", "weights"],
|
||||||
|
"properties": {
|
||||||
|
"config_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"weights": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/SignalWeight"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decay_function": {
|
||||||
|
"$ref": "#/definitions/DecayFunction"
|
||||||
|
},
|
||||||
|
"aggregation_method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["weighted_average", "max", "min", "product", "custom"],
|
||||||
|
"default": "weighted_average"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"$ref": "#/definitions/SignalThresholds"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"effective_from": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"effective_until": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalWeight": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Weight configuration for a signal type",
|
||||||
|
"required": ["signal_type", "weight"],
|
||||||
|
"properties": {
|
||||||
|
"signal_type": {
|
||||||
|
"$ref": "#/definitions/RuntimeSignalType"
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 10,
|
||||||
|
"description": "Weight multiplier for this signal type"
|
||||||
|
},
|
||||||
|
"min_observations": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 1,
|
||||||
|
"description": "Minimum observations before signal is considered"
|
||||||
|
},
|
||||||
|
"confidence_boost": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Boost to apply when high confidence"
|
||||||
|
},
|
||||||
|
"environment_modifiers": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"description": "Weight modifiers by environment (e.g., production: 1.5)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DecayFunction": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Time decay function for signal freshness",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["linear", "exponential", "step", "none"],
|
||||||
|
"default": "exponential"
|
||||||
|
},
|
||||||
|
"half_life_hours": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 168,
|
||||||
|
"description": "Hours for signal to decay to 50% weight"
|
||||||
|
},
|
||||||
|
"min_weight": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"default": 0.1,
|
||||||
|
"description": "Minimum weight after decay"
|
||||||
|
},
|
||||||
|
"max_age_hours": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum age before signal is ignored"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalThresholds": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Thresholds for signal-based decisions",
|
||||||
|
"properties": {
|
||||||
|
"reachable_threshold": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"default": 0.7,
|
||||||
|
"description": "Score above which symbol is considered reachable"
|
||||||
|
},
|
||||||
|
"unreachable_threshold": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"default": 0.3,
|
||||||
|
"description": "Score below which symbol is considered unreachable"
|
||||||
|
},
|
||||||
|
"confidence_minimum": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"default": 0.5,
|
||||||
|
"description": "Minimum confidence to use signal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalOverlay": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "UI overlay data for signal visualization",
|
||||||
|
"required": ["overlay_id", "component"],
|
||||||
|
"properties": {
|
||||||
|
"overlay_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "PURL or component identifier"
|
||||||
|
},
|
||||||
|
"display": {
|
||||||
|
"$ref": "#/definitions/OverlayDisplay"
|
||||||
|
},
|
||||||
|
"badges": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/SignalBadge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeline_events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/TimelineOverlayEvent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/OverlayShortcut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"OverlayDisplay": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Display properties for overlay",
|
||||||
|
"properties": {
|
||||||
|
"reachability_state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["reachable", "unreachable", "potentially_reachable", "unknown"]
|
||||||
|
},
|
||||||
|
"reachability_icon": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["check", "x", "question", "warning"]
|
||||||
|
},
|
||||||
|
"reachability_color": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["green", "red", "yellow", "gray"]
|
||||||
|
},
|
||||||
|
"confidence_display": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["high", "medium", "low"]
|
||||||
|
},
|
||||||
|
"last_observed_label": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalBadge": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Badge to display on component",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["reachability", "runtime", "coverage", "age", "confidence"]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TimelineOverlayEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Event for timeline visualization",
|
||||||
|
"properties": {
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"OverlayShortcut": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Keyboard/UI shortcut pattern",
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalAPIEndpoint": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "API endpoint specification for signals",
|
||||||
|
"required": ["path", "method"],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"request_schema": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON Schema reference"
|
||||||
|
},
|
||||||
|
"response_schema": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON Schema reference"
|
||||||
|
},
|
||||||
|
"error_model": {
|
||||||
|
"$ref": "#/definitions/SignalAPIError"
|
||||||
|
},
|
||||||
|
"etag_support": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SignalAPIError": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "API error response",
|
||||||
|
"required": ["code", "message"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"signals": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RuntimeSignal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"callgraphs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Callgraph"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"weighting_config": {
|
||||||
|
"$ref": "#/definitions/SignalWeightingConfig"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"signals": [
|
||||||
|
{
|
||||||
|
"signal_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"signal_type": "function_invocation",
|
||||||
|
"state": "active",
|
||||||
|
"score": {
|
||||||
|
"value": 0.85,
|
||||||
|
"confidence": 0.92
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"purl": "pkg:npm/lodash@4.17.21",
|
||||||
|
"symbol": "lodash.template",
|
||||||
|
"cve_id": "CVE-2021-23337"
|
||||||
|
},
|
||||||
|
"observation": {
|
||||||
|
"call_count": 1247,
|
||||||
|
"first_seen": "2025-11-01T00:00:00Z",
|
||||||
|
"last_seen": "2025-12-06T10:00:00Z",
|
||||||
|
"observation_window": "30d"
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"environment": "production",
|
||||||
|
"runtime": "node-20.10"
|
||||||
|
},
|
||||||
|
"observed_at": "2025-12-06T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weighting_config": {
|
||||||
|
"config_id": "660e8400-e29b-41d4-a716-446655440002",
|
||||||
|
"name": "default-production",
|
||||||
|
"weights": [
|
||||||
|
{
|
||||||
|
"signal_type": "function_invocation",
|
||||||
|
"weight": 2.0,
|
||||||
|
"min_observations": 10,
|
||||||
|
"environment_modifiers": {
|
||||||
|
"production": 1.5,
|
||||||
|
"staging": 1.0,
|
||||||
|
"development": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decay_function": {
|
||||||
|
"type": "exponential",
|
||||||
|
"half_life_hours": 168,
|
||||||
|
"min_weight": 0.1
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"reachable_threshold": 0.7,
|
||||||
|
"unreachable_threshold": 0.3,
|
||||||
|
"confidence_minimum": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ internal static class CommandFactory
|
|||||||
root.Add(BuildRubyCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildRubyCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildPhpCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildPhpCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildPythonCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildPythonCommand(services, verboseOption, cancellationToken));
|
||||||
|
root.Add(BuildBunCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
|
||||||
@@ -72,10 +73,10 @@ internal static class CommandFactory
|
|||||||
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
|
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
|
||||||
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
|
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
|
||||||
|
|
||||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||||
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
|
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
|
||||||
@@ -370,6 +371,74 @@ internal static class CommandFactory
|
|||||||
return python;
|
return python;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Command BuildBunCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var bun = new Command("bun", "Work with Bun analyzer outputs.");
|
||||||
|
|
||||||
|
var inspect = new Command("inspect", "Inspect a local Bun workspace.");
|
||||||
|
var inspectRootOption = new Option<string?>("--root")
|
||||||
|
{
|
||||||
|
Description = "Path to the Bun workspace (defaults to current directory)."
|
||||||
|
};
|
||||||
|
var inspectFormatOption = new Option<string?>("--format")
|
||||||
|
{
|
||||||
|
Description = "Output format (table or json)."
|
||||||
|
};
|
||||||
|
|
||||||
|
inspect.Add(inspectRootOption);
|
||||||
|
inspect.Add(inspectFormatOption);
|
||||||
|
inspect.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var root = parseResult.GetValue(inspectRootOption);
|
||||||
|
var format = parseResult.GetValue(inspectFormatOption) ?? "table";
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
|
||||||
|
return CommandHandlers.HandleBunInspectAsync(
|
||||||
|
services,
|
||||||
|
root,
|
||||||
|
format,
|
||||||
|
verbose,
|
||||||
|
cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
var resolve = new Command("resolve", "Fetch Bun packages for a completed scan.");
|
||||||
|
var resolveImageOption = new Option<string?>("--image")
|
||||||
|
{
|
||||||
|
Description = "Image reference (digest or tag) used by the scan."
|
||||||
|
};
|
||||||
|
var resolveScanIdOption = new Option<string?>("--scan-id")
|
||||||
|
{
|
||||||
|
Description = "Explicit scan identifier."
|
||||||
|
};
|
||||||
|
var resolveFormatOption = new Option<string?>("--format")
|
||||||
|
{
|
||||||
|
Description = "Output format (table or json)."
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve.Add(resolveImageOption);
|
||||||
|
resolve.Add(resolveScanIdOption);
|
||||||
|
resolve.Add(resolveFormatOption);
|
||||||
|
resolve.SetAction((parseResult, _) =>
|
||||||
|
{
|
||||||
|
var image = parseResult.GetValue(resolveImageOption);
|
||||||
|
var scanId = parseResult.GetValue(resolveScanIdOption);
|
||||||
|
var format = parseResult.GetValue(resolveFormatOption) ?? "table";
|
||||||
|
var verbose = parseResult.GetValue(verboseOption);
|
||||||
|
|
||||||
|
return CommandHandlers.HandleBunResolveAsync(
|
||||||
|
services,
|
||||||
|
image,
|
||||||
|
scanId,
|
||||||
|
format,
|
||||||
|
verbose,
|
||||||
|
cancellationToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
bun.Add(inspect);
|
||||||
|
bun.Add(resolve);
|
||||||
|
return bun;
|
||||||
|
}
|
||||||
|
|
||||||
private static Command BuildKmsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
private static Command BuildKmsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var kms = new Command("kms", "Manage file-backed signing keys.");
|
var kms = new Command("kms", "Manage file-backed signing keys.");
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ using StellaOps.Cli.Prompts;
|
|||||||
using StellaOps.Cli.Services;
|
using StellaOps.Cli.Services;
|
||||||
using StellaOps.Cli.Services.Models;
|
using StellaOps.Cli.Services.Models;
|
||||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||||
|
using StellaOps.Cli.Services.Models.Bun;
|
||||||
using StellaOps.Cli.Services.Models.Ruby;
|
using StellaOps.Cli.Services.Models.Ruby;
|
||||||
using StellaOps.Cli.Telemetry;
|
using StellaOps.Cli.Telemetry;
|
||||||
using StellaOps.Cryptography;
|
using StellaOps.Cryptography;
|
||||||
@@ -40,6 +41,7 @@ using StellaOps.Scanner.Analyzers.Lang.Node;
|
|||||||
using StellaOps.Scanner.Analyzers.Lang.Python;
|
using StellaOps.Scanner.Analyzers.Lang.Python;
|
||||||
using StellaOps.Scanner.Analyzers.Lang.Ruby;
|
using StellaOps.Scanner.Analyzers.Lang.Ruby;
|
||||||
using StellaOps.Scanner.Analyzers.Lang.Php;
|
using StellaOps.Scanner.Analyzers.Lang.Php;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Bun;
|
||||||
using StellaOps.Policy;
|
using StellaOps.Policy;
|
||||||
using StellaOps.PolicyDsl;
|
using StellaOps.PolicyDsl;
|
||||||
|
|
||||||
@@ -8327,6 +8329,191 @@ internal static class CommandHandlers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task HandleBunInspectAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
string? rootPath,
|
||||||
|
string format,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-inspect");
|
||||||
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||||
|
var previousLevel = verbosity.MinimumLevel;
|
||||||
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||||
|
|
||||||
|
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.inspect", ActivityKind.Internal);
|
||||||
|
activity?.SetTag("stellaops.cli.command", "bun inspect");
|
||||||
|
using var duration = CliMetrics.MeasureCommandDuration("bun inspect");
|
||||||
|
|
||||||
|
var outcome = "unknown";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var normalizedFormat = string.IsNullOrWhiteSpace(format)
|
||||||
|
? "table"
|
||||||
|
: format.Trim().ToLowerInvariant();
|
||||||
|
if (normalizedFormat is not ("table" or "json"))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
|
||||||
|
? Directory.GetCurrentDirectory()
|
||||||
|
: Path.GetFullPath(rootPath);
|
||||||
|
if (!Directory.Exists(targetRoot))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Inspecting Bun workspace in {Root}.", targetRoot);
|
||||||
|
activity?.SetTag("stellaops.cli.bun.root", targetRoot);
|
||||||
|
|
||||||
|
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new BunLanguageAnalyzer() });
|
||||||
|
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
|
||||||
|
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
var report = BunInspectReport.Create(result.ToSnapshots());
|
||||||
|
|
||||||
|
activity?.SetTag("stellaops.cli.bun.package_count", report.Packages.Count);
|
||||||
|
|
||||||
|
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RenderBunInspectReport(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome = report.Packages.Count == 0 ? "empty" : "ok";
|
||||||
|
Environment.ExitCode = 0;
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException ex)
|
||||||
|
{
|
||||||
|
outcome = "not_found";
|
||||||
|
logger.LogError(ex.Message);
|
||||||
|
Environment.ExitCode = 71;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
outcome = "invalid";
|
||||||
|
logger.LogError(ex.Message);
|
||||||
|
Environment.ExitCode = 64;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
outcome = "error";
|
||||||
|
logger.LogError(ex, "Bun inspect failed.");
|
||||||
|
Environment.ExitCode = 70;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
verbosity.MinimumLevel = previousLevel;
|
||||||
|
CliMetrics.RecordBunInspect(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task HandleBunResolveAsync(
|
||||||
|
IServiceProvider services,
|
||||||
|
string? imageReference,
|
||||||
|
string? scanId,
|
||||||
|
string format,
|
||||||
|
bool verbose,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var scope = services.CreateAsyncScope();
|
||||||
|
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-resolve");
|
||||||
|
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||||
|
var previousLevel = verbosity.MinimumLevel;
|
||||||
|
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||||
|
|
||||||
|
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.resolve", ActivityKind.Client);
|
||||||
|
activity?.SetTag("stellaops.cli.command", "bun resolve");
|
||||||
|
using var duration = CliMetrics.MeasureCommandDuration("bun resolve");
|
||||||
|
|
||||||
|
var outcome = "unknown";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var normalizedFormat = string.IsNullOrWhiteSpace(format)
|
||||||
|
? "table"
|
||||||
|
: format.Trim().ToLowerInvariant();
|
||||||
|
if (normalizedFormat is not ("table" or "json"))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var identifier = !string.IsNullOrWhiteSpace(scanId)
|
||||||
|
? scanId!.Trim()
|
||||||
|
: imageReference?.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(identifier))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("An --image or --scan-id value is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Resolving Bun packages for scan {ScanId}.", identifier);
|
||||||
|
activity?.SetTag("stellaops.cli.scan_id", identifier);
|
||||||
|
|
||||||
|
var inventory = await client.GetBunPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (inventory is null)
|
||||||
|
{
|
||||||
|
outcome = "empty";
|
||||||
|
Environment.ExitCode = 0;
|
||||||
|
AnsiConsole.MarkupLine("[yellow]Bun package inventory is not available for scan {0}.[/]", Markup.Escape(identifier));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = BunResolveReport.Create(inventory);
|
||||||
|
|
||||||
|
if (!report.HasPackages)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[yellow]No Bun packages found for scan {0}.[/]", Markup.Escape(identifier));
|
||||||
|
}
|
||||||
|
else if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RenderBunResolveReport(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome = report.HasPackages ? "ok" : "empty";
|
||||||
|
Environment.ExitCode = 0;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
outcome = "invalid";
|
||||||
|
logger.LogError(ex.Message);
|
||||||
|
Environment.ExitCode = 64;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
outcome = "network_error";
|
||||||
|
logger.LogError(ex, "Failed to resolve Bun packages.");
|
||||||
|
Environment.ExitCode = 69;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
outcome = "error";
|
||||||
|
logger.LogError(ex, "Bun resolve failed.");
|
||||||
|
Environment.ExitCode = 70;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
verbosity.MinimumLevel = previousLevel;
|
||||||
|
CliMetrics.RecordBunResolve(outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void RenderPythonInspectReport(IReadOnlyList<LanguageComponentSnapshot> snapshots)
|
private static void RenderPythonInspectReport(IReadOnlyList<LanguageComponentSnapshot> snapshots)
|
||||||
{
|
{
|
||||||
if (snapshots.Count == 0)
|
if (snapshots.Count == 0)
|
||||||
@@ -8384,6 +8571,64 @@ internal static class CommandHandlers
|
|||||||
AnsiConsole.Write(table);
|
AnsiConsole.Write(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void RenderBunInspectReport(BunInspectReport report)
|
||||||
|
{
|
||||||
|
if (!report.Packages.Any())
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[yellow]No Bun packages detected.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = new Table().Border(TableBorder.Rounded);
|
||||||
|
table.AddColumn("Package");
|
||||||
|
table.AddColumn("Version");
|
||||||
|
table.AddColumn("Source");
|
||||||
|
table.AddColumn("Dev");
|
||||||
|
table.AddColumn("Direct");
|
||||||
|
|
||||||
|
foreach (var entry in report.Packages)
|
||||||
|
{
|
||||||
|
var dev = entry.IsDev ? "[grey]yes[/]" : "-";
|
||||||
|
var direct = entry.IsDirect ? "[blue]yes[/]" : "-";
|
||||||
|
table.AddRow(
|
||||||
|
Markup.Escape(entry.Name),
|
||||||
|
Markup.Escape(entry.Version ?? "-"),
|
||||||
|
Markup.Escape(entry.Source ?? "-"),
|
||||||
|
dev,
|
||||||
|
direct);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.Write(table);
|
||||||
|
AnsiConsole.MarkupLine($"[grey]Total packages: {report.Packages.Count}[/]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RenderBunResolveReport(BunResolveReport report)
|
||||||
|
{
|
||||||
|
if (!report.HasPackages)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[yellow]No Bun packages found.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = new Table().Border(TableBorder.Rounded);
|
||||||
|
table.AddColumn("Package");
|
||||||
|
table.AddColumn("Version");
|
||||||
|
table.AddColumn("Source");
|
||||||
|
table.AddColumn("Integrity");
|
||||||
|
|
||||||
|
foreach (var entry in report.Packages)
|
||||||
|
{
|
||||||
|
table.AddRow(
|
||||||
|
Markup.Escape(entry.Name),
|
||||||
|
Markup.Escape(entry.Version ?? "-"),
|
||||||
|
Markup.Escape(entry.Source ?? "-"),
|
||||||
|
Markup.Escape(entry.Integrity ?? "-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.Write(table);
|
||||||
|
AnsiConsole.MarkupLine($"[grey]Scan: {Markup.Escape(report.ScanId ?? "-")} • Total: {report.Packages.Count}[/]");
|
||||||
|
}
|
||||||
|
|
||||||
private static void RenderRubyInspectReport(RubyInspectReport report)
|
private static void RenderRubyInspectReport(RubyInspectReport report)
|
||||||
{
|
{
|
||||||
if (!report.Packages.Any())
|
if (!report.Packages.Any())
|
||||||
@@ -8999,6 +9244,163 @@ internal static class CommandHandlers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class BunInspectReport
|
||||||
|
{
|
||||||
|
[JsonPropertyName("packages")]
|
||||||
|
public IReadOnlyList<BunInspectEntry> Packages { get; }
|
||||||
|
|
||||||
|
private BunInspectReport(IReadOnlyList<BunInspectEntry> packages)
|
||||||
|
{
|
||||||
|
Packages = packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BunInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
|
||||||
|
{
|
||||||
|
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
|
||||||
|
|
||||||
|
var entries = source
|
||||||
|
.Where(static snapshot => string.Equals(snapshot.Type, "npm", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(BunInspectEntry.FromSnapshot)
|
||||||
|
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new BunInspectReport(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record BunInspectEntry(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("version")] string? Version,
|
||||||
|
[property: JsonPropertyName("source")] string? Source,
|
||||||
|
[property: JsonPropertyName("isDev")] bool IsDev,
|
||||||
|
[property: JsonPropertyName("isDirect")] bool IsDirect,
|
||||||
|
[property: JsonPropertyName("resolved")] string? Resolved,
|
||||||
|
[property: JsonPropertyName("integrity")] string? Integrity)
|
||||||
|
{
|
||||||
|
public static BunInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var metadata = BunMetadataHelpers.Clone(snapshot.Metadata);
|
||||||
|
var source = BunMetadataHelpers.GetString(metadata, "source");
|
||||||
|
var isDev = BunMetadataHelpers.GetBool(metadata, "dev") ?? false;
|
||||||
|
var isDirect = BunMetadataHelpers.GetBool(metadata, "direct") ?? false;
|
||||||
|
var resolved = BunMetadataHelpers.GetString(metadata, "resolved");
|
||||||
|
var integrity = BunMetadataHelpers.GetString(metadata, "integrity");
|
||||||
|
|
||||||
|
return new BunInspectEntry(
|
||||||
|
snapshot.Name ?? "-",
|
||||||
|
snapshot.Version,
|
||||||
|
source,
|
||||||
|
isDev,
|
||||||
|
isDirect,
|
||||||
|
resolved,
|
||||||
|
integrity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class BunMetadataHelpers
|
||||||
|
{
|
||||||
|
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
|
||||||
|
{
|
||||||
|
if (metadata is null || metadata.Count == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var pair in metadata)
|
||||||
|
{
|
||||||
|
clone[pair.Key] = pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? GetString(IDictionary<string, string?> metadata, string key)
|
||||||
|
{
|
||||||
|
if (metadata.TryGetValue(key, out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in metadata)
|
||||||
|
{
|
||||||
|
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return pair.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
|
||||||
|
{
|
||||||
|
var value = GetString(metadata, key);
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bool.TryParse(value, out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BunResolveReport
|
||||||
|
{
|
||||||
|
[JsonPropertyName("scanId")]
|
||||||
|
public string? ScanId { get; }
|
||||||
|
|
||||||
|
[JsonPropertyName("packages")]
|
||||||
|
public IReadOnlyList<BunResolveEntry> Packages { get; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool HasPackages => Packages.Count > 0;
|
||||||
|
|
||||||
|
private BunResolveReport(string? scanId, IReadOnlyList<BunResolveEntry> packages)
|
||||||
|
{
|
||||||
|
ScanId = scanId;
|
||||||
|
Packages = packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BunResolveReport Create(BunPackageInventory? inventory)
|
||||||
|
{
|
||||||
|
if (inventory is null)
|
||||||
|
{
|
||||||
|
return new BunResolveReport(null, Array.Empty<BunResolveEntry>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = inventory.Packages
|
||||||
|
.Select(BunResolveEntry.FromPackage)
|
||||||
|
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new BunResolveReport(inventory.ScanId, entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record BunResolveEntry(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("version")] string? Version,
|
||||||
|
[property: JsonPropertyName("source")] string? Source,
|
||||||
|
[property: JsonPropertyName("integrity")] string? Integrity)
|
||||||
|
{
|
||||||
|
public static BunResolveEntry FromPackage(BunPackageItem package)
|
||||||
|
{
|
||||||
|
return new BunResolveEntry(
|
||||||
|
package.Name,
|
||||||
|
package.Version,
|
||||||
|
package.Source,
|
||||||
|
package.Integrity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record LockValidationEntry(
|
private sealed record LockValidationEntry(
|
||||||
[property: JsonPropertyName("name")] string Name,
|
[property: JsonPropertyName("name")] string Name,
|
||||||
[property: JsonPropertyName("version")] string? Version,
|
[property: JsonPropertyName("version")] string? Version,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ using StellaOps.Auth.Client;
|
|||||||
using StellaOps.Cli.Configuration;
|
using StellaOps.Cli.Configuration;
|
||||||
using StellaOps.Cli.Services.Models;
|
using StellaOps.Cli.Services.Models;
|
||||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||||
|
using StellaOps.Cli.Services.Models.Bun;
|
||||||
using StellaOps.Cli.Services.Models.Ruby;
|
using StellaOps.Cli.Services.Models.Ruby;
|
||||||
using StellaOps.Cli.Services.Models.Transport;
|
using StellaOps.Cli.Services.Models.Transport;
|
||||||
|
|
||||||
@@ -960,6 +961,50 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<BunPackageInventory?> GetBunPackagesAsync(string scanId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
EnsureBackendConfigured();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(scanId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Scan identifier is required.", nameof(scanId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var encodedScanId = Uri.EscapeDataString(scanId);
|
||||||
|
using var request = CreateRequest(HttpMethod.Get, $"api/scans/{encodedScanId}/bun-packages");
|
||||||
|
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
throw new InvalidOperationException(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
var inventory = await response.Content
|
||||||
|
.ReadFromJsonAsync<BunPackageInventory>(SerializerOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (inventory is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Bun package response payload was empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedScanId = string.IsNullOrWhiteSpace(inventory.ScanId) ? scanId : inventory.ScanId;
|
||||||
|
var packages = inventory.Packages ?? Array.Empty<BunPackageItem>();
|
||||||
|
|
||||||
|
return inventory with
|
||||||
|
{
|
||||||
|
ScanId = normalizedScanId,
|
||||||
|
Packages = packages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
|
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
|
||||||
AdvisoryAiTaskType taskType,
|
AdvisoryAiTaskType taskType,
|
||||||
AdvisoryPipelinePlanRequestModel request,
|
AdvisoryPipelinePlanRequestModel request,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
|||||||
using StellaOps.Cli.Configuration;
|
using StellaOps.Cli.Configuration;
|
||||||
using StellaOps.Cli.Services.Models;
|
using StellaOps.Cli.Services.Models;
|
||||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||||
|
using StellaOps.Cli.Services.Models.Bun;
|
||||||
using StellaOps.Cli.Services.Models.Ruby;
|
using StellaOps.Cli.Services.Models.Ruby;
|
||||||
|
|
||||||
namespace StellaOps.Cli.Services;
|
namespace StellaOps.Cli.Services;
|
||||||
@@ -51,6 +52,8 @@ internal interface IBackendOperationsClient
|
|||||||
|
|
||||||
Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken);
|
Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<BunPackageInventory?> GetBunPackagesAsync(string scanId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
|
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.Cli.Services.Models.Bun;
|
||||||
|
|
||||||
|
internal sealed record BunPackageItem(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("version")] string? Version,
|
||||||
|
[property: JsonPropertyName("source")] string? Source,
|
||||||
|
[property: JsonPropertyName("resolved")] string? Resolved,
|
||||||
|
[property: JsonPropertyName("integrity")] string? Integrity,
|
||||||
|
[property: JsonPropertyName("isDev")] bool? IsDev,
|
||||||
|
[property: JsonPropertyName("isDirect")] bool? IsDirect,
|
||||||
|
[property: JsonPropertyName("isPatched")] bool? IsPatched,
|
||||||
|
[property: JsonPropertyName("customRegistry")] string? CustomRegistry,
|
||||||
|
[property: JsonPropertyName("metadata")] IDictionary<string, string?>? Metadata);
|
||||||
|
|
||||||
|
internal sealed record BunPackageInventory(
|
||||||
|
[property: JsonPropertyName("scanId")] string ScanId,
|
||||||
|
[property: JsonPropertyName("imageDigest")] string? ImageDigest,
|
||||||
|
[property: JsonPropertyName("generatedAt")] DateTimeOffset? GeneratedAt,
|
||||||
|
[property: JsonPropertyName("packages")] IReadOnlyList<BunPackageItem> Packages);
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
|
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
|
||||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
|
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
|
||||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/StellaOps.Scanner.Analyzers.Lang.Php.csproj" />
|
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/StellaOps.Scanner.Analyzers.Lang.Php.csproj" />
|
||||||
|
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Bun/StellaOps.Scanner.Analyzers.Lang.Bun.csproj" />
|
||||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||||
<ProjectReference Include="../../Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
<ProjectReference Include="../../Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||||
@@ -61,6 +62,12 @@
|
|||||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||||
|
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
|
||||||
|
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Storage.Postgres/StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||||
|
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||||
|
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||||
|
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
|
||||||
|
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ internal static class CliMetrics
|
|||||||
private static readonly Counter<long> RubyResolveCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.resolve.count");
|
private static readonly Counter<long> RubyResolveCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.resolve.count");
|
||||||
private static readonly Counter<long> PhpInspectCounter = Meter.CreateCounter<long>("stellaops.cli.php.inspect.count");
|
private static readonly Counter<long> PhpInspectCounter = Meter.CreateCounter<long>("stellaops.cli.php.inspect.count");
|
||||||
private static readonly Counter<long> PythonInspectCounter = Meter.CreateCounter<long>("stellaops.cli.python.inspect.count");
|
private static readonly Counter<long> PythonInspectCounter = Meter.CreateCounter<long>("stellaops.cli.python.inspect.count");
|
||||||
|
private static readonly Counter<long> BunInspectCounter = Meter.CreateCounter<long>("stellaops.cli.bun.inspect.count");
|
||||||
|
private static readonly Counter<long> BunResolveCounter = Meter.CreateCounter<long>("stellaops.cli.bun.resolve.count");
|
||||||
private static readonly Counter<long> AttestSignCounter = Meter.CreateCounter<long>("stellaops.cli.attest.sign.count");
|
private static readonly Counter<long> AttestSignCounter = Meter.CreateCounter<long>("stellaops.cli.attest.sign.count");
|
||||||
private static readonly Counter<long> AttestVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.attest.verify.count");
|
private static readonly Counter<long> AttestVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.attest.verify.count");
|
||||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||||
@@ -153,6 +155,14 @@ internal static class CliMetrics
|
|||||||
=> PythonInspectCounter.Add(1, WithSealedModeTag(
|
=> PythonInspectCounter.Add(1, WithSealedModeTag(
|
||||||
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
|
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
|
||||||
|
|
||||||
|
public static void RecordBunInspect(string outcome)
|
||||||
|
=> BunInspectCounter.Add(1, WithSealedModeTag(
|
||||||
|
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
|
||||||
|
|
||||||
|
public static void RecordBunResolve(string outcome)
|
||||||
|
=> BunResolveCounter.Add(1, WithSealedModeTag(
|
||||||
|
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a successful attestation signing operation (CLI-ATTEST-73-001).
|
/// Records a successful attestation signing operation (CLI-ATTEST-73-001).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -23,4 +23,17 @@ public sealed class CommandFactoryTests
|
|||||||
Assert.Contains(ruby.Subcommands, command => string.Equals(command.Name, "inspect", StringComparison.Ordinal));
|
Assert.Contains(ruby.Subcommands, command => string.Equals(command.Name, "inspect", StringComparison.Ordinal));
|
||||||
Assert.Contains(ruby.Subcommands, command => string.Equals(command.Name, "resolve", StringComparison.Ordinal));
|
Assert.Contains(ruby.Subcommands, command => string.Equals(command.Name, "resolve", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_ExposesBunInspectAndResolveCommands()
|
||||||
|
{
|
||||||
|
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||||
|
var services = new ServiceCollection().BuildServiceProvider();
|
||||||
|
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||||
|
|
||||||
|
var bun = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "bun", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
Assert.Contains(bun.Subcommands, command => string.Equals(command.Name, "inspect", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(bun.Subcommands, command => string.Equals(command.Name, "resolve", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ using StellaOps.Cli.Configuration;
|
|||||||
using StellaOps.Cli.Services;
|
using StellaOps.Cli.Services;
|
||||||
using StellaOps.Cli.Services.Models;
|
using StellaOps.Cli.Services.Models;
|
||||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||||
|
using StellaOps.Cli.Services.Models.Bun;
|
||||||
using StellaOps.Cli.Services.Models.Ruby;
|
using StellaOps.Cli.Services.Models.Ruby;
|
||||||
using StellaOps.Cli.Telemetry;
|
using StellaOps.Cli.Telemetry;
|
||||||
using StellaOps.Cli.Tests.Testing;
|
using StellaOps.Cli.Tests.Testing;
|
||||||
@@ -641,6 +642,161 @@ public sealed class CommandHandlersTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleBunInspectAsync_WritesJson()
|
||||||
|
{
|
||||||
|
var originalExit = Environment.ExitCode;
|
||||||
|
using var fixture = new TempDirectory();
|
||||||
|
CreateBunWorkspace(fixture.Path);
|
||||||
|
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = await CaptureTestConsoleAsync(async _ =>
|
||||||
|
{
|
||||||
|
await CommandHandlers.HandleBunInspectAsync(
|
||||||
|
provider,
|
||||||
|
fixture.Path,
|
||||||
|
"json",
|
||||||
|
verbose: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0, Environment.ExitCode);
|
||||||
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
||||||
|
var packages = document.RootElement.GetProperty("packages");
|
||||||
|
Assert.NotEmpty(packages.EnumerateArray());
|
||||||
|
|
||||||
|
Assert.Contains(packages.EnumerateArray(), p =>
|
||||||
|
string.Equals(p.GetProperty("name").GetString(), "lodash", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.Contains(packages.EnumerateArray(), p =>
|
||||||
|
string.Equals(p.GetProperty("name").GetString(), "express", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = originalExit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleBunResolveAsync_RendersPackages()
|
||||||
|
{
|
||||||
|
var originalExit = Environment.ExitCode;
|
||||||
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||||
|
{
|
||||||
|
BunInventory = CreateBunInventory(
|
||||||
|
"scan-bun",
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
CreateBunPackageItem("lodash", "4.17.21", isDev: false, isDirect: true),
|
||||||
|
CreateBunPackageItem("express", "4.18.2", isDev: false, isDirect: true),
|
||||||
|
CreateBunPackageItem("typescript", "5.3.3", isDev: true, isDirect: true)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
var provider = BuildServiceProvider(backend);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = await CaptureTestConsoleAsync(async _ =>
|
||||||
|
{
|
||||||
|
await CommandHandlers.HandleBunResolveAsync(
|
||||||
|
provider,
|
||||||
|
imageReference: null,
|
||||||
|
scanId: "scan-bun",
|
||||||
|
format: "table",
|
||||||
|
verbose: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0, Environment.ExitCode);
|
||||||
|
Assert.Equal("scan-bun", backend.LastBunPackagesScanId);
|
||||||
|
Assert.Contains("scan-bun", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("lodash", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("express", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = originalExit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleBunResolveAsync_WritesJson()
|
||||||
|
{
|
||||||
|
var originalExit = Environment.ExitCode;
|
||||||
|
const string identifier = "bun-scan-json";
|
||||||
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||||
|
{
|
||||||
|
BunInventory = CreateBunInventory(
|
||||||
|
identifier,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
CreateBunPackageItem("lodash", "4.17.21", isDev: false, isDirect: true)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
var provider = BuildServiceProvider(backend);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = await CaptureTestConsoleAsync(async _ =>
|
||||||
|
{
|
||||||
|
await CommandHandlers.HandleBunResolveAsync(
|
||||||
|
provider,
|
||||||
|
imageReference: identifier,
|
||||||
|
scanId: null,
|
||||||
|
format: "json",
|
||||||
|
verbose: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0, Environment.ExitCode);
|
||||||
|
Assert.Equal(identifier, backend.LastBunPackagesScanId);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(output.PlainBuffer);
|
||||||
|
Assert.Equal(identifier, document.RootElement.GetProperty("scanId").GetString());
|
||||||
|
|
||||||
|
var packages = document.RootElement.GetProperty("packages");
|
||||||
|
Assert.Single(packages.EnumerateArray());
|
||||||
|
|
||||||
|
var package = packages.EnumerateArray().First();
|
||||||
|
Assert.Equal("lodash", package.GetProperty("name").GetString());
|
||||||
|
Assert.Equal("4.17.21", package.GetProperty("version").GetString());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = originalExit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleBunResolveAsync_NotifiesWhenInventoryMissing()
|
||||||
|
{
|
||||||
|
var originalExit = Environment.ExitCode;
|
||||||
|
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||||
|
var provider = BuildServiceProvider(backend);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = await CaptureTestConsoleAsync(async _ =>
|
||||||
|
{
|
||||||
|
await CommandHandlers.HandleBunResolveAsync(
|
||||||
|
provider,
|
||||||
|
imageReference: null,
|
||||||
|
scanId: "scan-missing-bun",
|
||||||
|
format: "table",
|
||||||
|
verbose: false,
|
||||||
|
cancellationToken: CancellationToken.None);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0, Environment.ExitCode);
|
||||||
|
Assert.Contains("not available", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.ExitCode = originalExit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandleAdviseRunAsync_WritesOutputAndSetsExitCode()
|
public async Task HandleAdviseRunAsync_WritesOutputAndSetsExitCode()
|
||||||
{
|
{
|
||||||
@@ -4081,6 +4237,84 @@ spec:
|
|||||||
packages);
|
packages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void CreateBunWorkspace(string root)
|
||||||
|
{
|
||||||
|
var packageJson = """
|
||||||
|
{
|
||||||
|
"name": "test-bun-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "4.17.21",
|
||||||
|
"express": "4.18.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(root, "package.json"), packageJson);
|
||||||
|
|
||||||
|
var bunLock = """
|
||||||
|
{
|
||||||
|
"lockfileVersion": 0,
|
||||||
|
"packages": {
|
||||||
|
"lodash": ["lodash@4.17.21", { "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDE+k+xyz=" }],
|
||||||
|
"express": ["express@4.18.2", { "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", "integrity": "sha512-expr+k+abc=" }],
|
||||||
|
"typescript": ["typescript@5.3.3", { "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-ts+k+def=" }]
|
||||||
|
},
|
||||||
|
"workspaces": {}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(Path.Combine(root, "bun.lock"), bunLock);
|
||||||
|
|
||||||
|
var nodeModules = Path.Combine(root, "node_modules");
|
||||||
|
Directory.CreateDirectory(nodeModules);
|
||||||
|
|
||||||
|
var lodashDir = Path.Combine(nodeModules, "lodash");
|
||||||
|
Directory.CreateDirectory(lodashDir);
|
||||||
|
File.WriteAllText(Path.Combine(lodashDir, "package.json"), """{"name":"lodash","version":"4.17.21"}""");
|
||||||
|
|
||||||
|
var expressDir = Path.Combine(nodeModules, "express");
|
||||||
|
Directory.CreateDirectory(expressDir);
|
||||||
|
File.WriteAllText(Path.Combine(expressDir, "package.json"), """{"name":"express","version":"4.18.2"}""");
|
||||||
|
|
||||||
|
var typescriptDir = Path.Combine(nodeModules, "typescript");
|
||||||
|
Directory.CreateDirectory(typescriptDir);
|
||||||
|
File.WriteAllText(Path.Combine(typescriptDir, "package.json"), """{"name":"typescript","version":"5.3.3"}""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BunPackageItem CreateBunPackageItem(
|
||||||
|
string name,
|
||||||
|
string? version = null,
|
||||||
|
string? source = null,
|
||||||
|
bool? isDev = null,
|
||||||
|
bool? isDirect = null,
|
||||||
|
IDictionary<string, string?>? metadata = null)
|
||||||
|
{
|
||||||
|
return new BunPackageItem(
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
source ?? "registry",
|
||||||
|
$"https://registry.npmjs.org/{name}/-/{name}-{version ?? "1.0.0"}.tgz",
|
||||||
|
"sha512-abc123=",
|
||||||
|
isDev,
|
||||||
|
isDirect,
|
||||||
|
IsPatched: null,
|
||||||
|
CustomRegistry: null,
|
||||||
|
metadata ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BunPackageInventory CreateBunInventory(
|
||||||
|
string scanId,
|
||||||
|
IReadOnlyList<BunPackageItem> packages,
|
||||||
|
string? imageDigest = null)
|
||||||
|
{
|
||||||
|
return new BunPackageInventory(
|
||||||
|
scanId,
|
||||||
|
imageDigest ?? "sha256:bun-inventory",
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
packages);
|
||||||
|
}
|
||||||
|
|
||||||
private static string ComputeSha256Base64(string path)
|
private static string ComputeSha256Base64(string path)
|
||||||
{
|
{
|
||||||
@@ -4165,6 +4399,9 @@ spec:
|
|||||||
public RubyPackageInventoryModel? RubyInventory { get; set; }
|
public RubyPackageInventoryModel? RubyInventory { get; set; }
|
||||||
public Exception? RubyInventoryException { get; set; }
|
public Exception? RubyInventoryException { get; set; }
|
||||||
public string? LastRubyPackagesScanId { get; private set; }
|
public string? LastRubyPackagesScanId { get; private set; }
|
||||||
|
public BunPackageInventory? BunInventory { get; set; }
|
||||||
|
public Exception? BunInventoryException { get; set; }
|
||||||
|
public string? LastBunPackagesScanId { get; private set; }
|
||||||
public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new();
|
public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new();
|
||||||
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
|
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
|
||||||
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
|
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
|
||||||
@@ -4415,6 +4652,17 @@ spec:
|
|||||||
return Task.FromResult(RubyInventory);
|
return Task.FromResult(RubyInventory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<BunPackageInventory?> GetBunPackagesAsync(string scanId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastBunPackagesScanId = scanId;
|
||||||
|
if (BunInventoryException is not null)
|
||||||
|
{
|
||||||
|
throw BunInventoryException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(BunInventory);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken)
|
public Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
AdvisoryPlanRequests.Add((taskType, request));
|
AdvisoryPlanRequests.Add((taskType, request));
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
using StellaOps.Excititor.Core.Observations;
|
||||||
|
|
||||||
|
namespace StellaOps.Excititor.Core.RiskFeed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Risk-engine ready feed item containing VEX status, justification, and provenance
|
||||||
|
/// WITHOUT derived severity (aggregation-only contract per AOC baseline).
|
||||||
|
/// Aligns with docs/schemas/risk-scoring.schema.json.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RiskFeedItem
|
||||||
|
{
|
||||||
|
public RiskFeedItem(
|
||||||
|
string advisoryKey,
|
||||||
|
string artifact,
|
||||||
|
VexClaimStatus status,
|
||||||
|
VexJustification? justification,
|
||||||
|
RiskFeedProvenance provenance,
|
||||||
|
DateTimeOffset observedAt,
|
||||||
|
ImmutableArray<RiskFeedObservationSource> sources)
|
||||||
|
{
|
||||||
|
AdvisoryKey = EnsureNotNullOrWhiteSpace(advisoryKey, nameof(advisoryKey));
|
||||||
|
Artifact = EnsureNotNullOrWhiteSpace(artifact, nameof(artifact));
|
||||||
|
Status = status;
|
||||||
|
Justification = justification;
|
||||||
|
Provenance = provenance ?? throw new ArgumentNullException(nameof(provenance));
|
||||||
|
ObservedAt = observedAt.ToUniversalTime();
|
||||||
|
Sources = sources.IsDefault ? ImmutableArray<RiskFeedObservationSource>.Empty : sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advisory/CVE identifier (e.g., "CVE-2025-13579").
|
||||||
|
/// </summary>
|
||||||
|
public string AdvisoryKey { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Package URL or product key of affected artifact.
|
||||||
|
/// </summary>
|
||||||
|
public string Artifact { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VEX status (affected, not_affected, fixed, under_investigation).
|
||||||
|
/// No derived severity - status is passed through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public VexClaimStatus Status { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Justification for not_affected status.
|
||||||
|
/// </summary>
|
||||||
|
public VexJustification? Justification { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provenance chain for auditability.
|
||||||
|
/// </summary>
|
||||||
|
public RiskFeedProvenance Provenance { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When this observation was made (UTC).
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset ObservedAt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source observations contributing to this feed item.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<RiskFeedObservationSource> Sources { get; }
|
||||||
|
|
||||||
|
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{name} must be provided.", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provenance metadata for risk feed items - tracks origin and chain of custody.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RiskFeedProvenance
|
||||||
|
{
|
||||||
|
public RiskFeedProvenance(
|
||||||
|
string tenantId,
|
||||||
|
string linksetId,
|
||||||
|
string contentHash,
|
||||||
|
VexLinksetConfidence confidence,
|
||||||
|
bool hasConflicts,
|
||||||
|
DateTimeOffset generatedAt,
|
||||||
|
string? attestationId = null)
|
||||||
|
{
|
||||||
|
TenantId = EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId)).ToLowerInvariant();
|
||||||
|
LinksetId = EnsureNotNullOrWhiteSpace(linksetId, nameof(linksetId));
|
||||||
|
ContentHash = EnsureNotNullOrWhiteSpace(contentHash, nameof(contentHash));
|
||||||
|
Confidence = confidence;
|
||||||
|
HasConflicts = hasConflicts;
|
||||||
|
GeneratedAt = generatedAt.ToUniversalTime();
|
||||||
|
AttestationId = string.IsNullOrWhiteSpace(attestationId) ? null : attestationId.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TenantId { get; }
|
||||||
|
|
||||||
|
public string LinksetId { get; }
|
||||||
|
|
||||||
|
public string ContentHash { get; }
|
||||||
|
|
||||||
|
public VexLinksetConfidence Confidence { get; }
|
||||||
|
|
||||||
|
public bool HasConflicts { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset GeneratedAt { get; }
|
||||||
|
|
||||||
|
public string? AttestationId { get; }
|
||||||
|
|
||||||
|
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{name} must be provided.", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source observation reference for risk feed provenance.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RiskFeedObservationSource
|
||||||
|
{
|
||||||
|
public RiskFeedObservationSource(
|
||||||
|
string observationId,
|
||||||
|
string providerId,
|
||||||
|
string status,
|
||||||
|
string? justification = null,
|
||||||
|
double? confidence = null)
|
||||||
|
{
|
||||||
|
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
|
||||||
|
ProviderId = EnsureNotNullOrWhiteSpace(providerId, nameof(providerId));
|
||||||
|
Status = EnsureNotNullOrWhiteSpace(status, nameof(status));
|
||||||
|
Justification = string.IsNullOrWhiteSpace(justification) ? null : justification.Trim();
|
||||||
|
Confidence = confidence is null ? null : Math.Clamp(confidence.Value, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ObservationId { get; }
|
||||||
|
|
||||||
|
public string ProviderId { get; }
|
||||||
|
|
||||||
|
public string Status { get; }
|
||||||
|
|
||||||
|
public string? Justification { get; }
|
||||||
|
|
||||||
|
public double? Confidence { get; }
|
||||||
|
|
||||||
|
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{name} must be provided.", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to generate risk feed for specified artifacts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RiskFeedRequest
|
||||||
|
{
|
||||||
|
public RiskFeedRequest(
|
||||||
|
string tenantId,
|
||||||
|
IEnumerable<string>? advisoryKeys = null,
|
||||||
|
IEnumerable<string>? artifacts = null,
|
||||||
|
DateTimeOffset? since = null,
|
||||||
|
int limit = 1000)
|
||||||
|
{
|
||||||
|
TenantId = EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId)).ToLowerInvariant();
|
||||||
|
AdvisoryKeys = NormalizeSet(advisoryKeys);
|
||||||
|
Artifacts = NormalizeSet(artifacts);
|
||||||
|
Since = since?.ToUniversalTime();
|
||||||
|
Limit = Math.Clamp(limit, 1, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TenantId { get; }
|
||||||
|
|
||||||
|
public ImmutableArray<string> AdvisoryKeys { get; }
|
||||||
|
|
||||||
|
public ImmutableArray<string> Artifacts { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset? Since { get; }
|
||||||
|
|
||||||
|
public int Limit { get; }
|
||||||
|
|
||||||
|
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"{name} must be provided.", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<string> NormalizeSet(IEnumerable<string>? values)
|
||||||
|
{
|
||||||
|
if (values is null)
|
||||||
|
{
|
||||||
|
return ImmutableArray<string>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var set = new SortedSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
var trimmed = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
if (trimmed is not null)
|
||||||
|
{
|
||||||
|
set.Add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response containing risk feed items.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RiskFeedResponse
|
||||||
|
{
|
||||||
|
public RiskFeedResponse(
|
||||||
|
IEnumerable<RiskFeedItem> items,
|
||||||
|
DateTimeOffset generatedAt,
|
||||||
|
string? nextPageToken = null)
|
||||||
|
{
|
||||||
|
Items = NormalizeItems(items);
|
||||||
|
GeneratedAt = generatedAt.ToUniversalTime();
|
||||||
|
NextPageToken = string.IsNullOrWhiteSpace(nextPageToken) ? null : nextPageToken.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImmutableArray<RiskFeedItem> Items { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset GeneratedAt { get; }
|
||||||
|
|
||||||
|
public string? NextPageToken { get; }
|
||||||
|
|
||||||
|
private static ImmutableArray<RiskFeedItem> NormalizeItems(IEnumerable<RiskFeedItem>? items)
|
||||||
|
{
|
||||||
|
if (items is null)
|
||||||
|
{
|
||||||
|
return ImmutableArray<RiskFeedItem>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = items.Where(i => i is not null).ToList();
|
||||||
|
return list.Count == 0 ? ImmutableArray<RiskFeedItem>.Empty : list.ToImmutableArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event published when risk feed is generated.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RiskFeedGeneratedEvent
|
||||||
|
{
|
||||||
|
public const string EventType = "excititor.risk_feed.generated";
|
||||||
|
|
||||||
|
public RiskFeedGeneratedEvent(
|
||||||
|
string tenantId,
|
||||||
|
string feedId,
|
||||||
|
int itemCount,
|
||||||
|
DateTimeOffset generatedAt,
|
||||||
|
string? correlationId = null)
|
||||||
|
{
|
||||||
|
Type = EventType;
|
||||||
|
TenantId = tenantId.ToLowerInvariant();
|
||||||
|
FeedId = feedId;
|
||||||
|
ItemCount = itemCount;
|
||||||
|
GeneratedAt = generatedAt.ToUniversalTime();
|
||||||
|
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Type { get; }
|
||||||
|
|
||||||
|
public string TenantId { get; }
|
||||||
|
|
||||||
|
public string FeedId { get; }
|
||||||
|
|
||||||
|
public int ItemCount { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset GeneratedAt { get; }
|
||||||
|
|
||||||
|
public string? CorrelationId { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using StellaOps.Scanner.Core.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.WebService.Contracts;
|
||||||
|
|
||||||
|
public sealed record BunPackagesResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("scanId")]
|
||||||
|
public string ScanId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("imageDigest")]
|
||||||
|
public string ImageDigest { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("generatedAt")]
|
||||||
|
public DateTimeOffset GeneratedAt { get; init; }
|
||||||
|
= DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
[JsonPropertyName("packages")]
|
||||||
|
public IReadOnlyList<BunPackageArtifact> Packages { get; init; }
|
||||||
|
= Array.Empty<BunPackageArtifact>();
|
||||||
|
}
|
||||||
@@ -71,6 +71,12 @@ internal static class ScanEndpoints
|
|||||||
.Produces<RubyPackagesResponse>(StatusCodes.Status200OK)
|
.Produces<RubyPackagesResponse>(StatusCodes.Status200OK)
|
||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||||
|
|
||||||
|
scans.MapGet("/{scanId}/bun-packages", HandleBunPackagesAsync)
|
||||||
|
.WithName("scanner.scans.bun-packages")
|
||||||
|
.Produces<BunPackagesResponse>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> HandleSubmitAsync(
|
private static async Task<IResult> HandleSubmitAsync(
|
||||||
@@ -497,6 +503,63 @@ internal static class ScanEndpoints
|
|||||||
return Json(response, StatusCodes.Status200OK);
|
return Json(response, StatusCodes.Status200OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleBunPackagesAsync(
|
||||||
|
string scanId,
|
||||||
|
IScanCoordinator coordinator,
|
||||||
|
IBunPackageInventoryStore inventoryStore,
|
||||||
|
HttpContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(coordinator);
|
||||||
|
ArgumentNullException.ThrowIfNull(inventoryStore);
|
||||||
|
|
||||||
|
if (!ScanId.TryParse(scanId, out var parsed))
|
||||||
|
{
|
||||||
|
return ProblemResultFactory.Create(
|
||||||
|
context,
|
||||||
|
ProblemTypes.Validation,
|
||||||
|
"Invalid scan identifier",
|
||||||
|
StatusCodes.Status400BadRequest,
|
||||||
|
detail: "Scan identifier is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var inventory = await inventoryStore.GetAsync(parsed.Value, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (inventory is null)
|
||||||
|
{
|
||||||
|
BunPackageInventory? fallback = null;
|
||||||
|
if (!LooksLikeScanId(scanId))
|
||||||
|
{
|
||||||
|
var snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (snapshot is not null)
|
||||||
|
{
|
||||||
|
fallback = await inventoryStore.GetAsync(snapshot.ScanId.Value, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback is null)
|
||||||
|
{
|
||||||
|
return ProblemResultFactory.Create(
|
||||||
|
context,
|
||||||
|
ProblemTypes.NotFound,
|
||||||
|
"Bun packages not found",
|
||||||
|
StatusCodes.Status404NotFound,
|
||||||
|
detail: "Bun package inventory is not available for the requested scan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
inventory = fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = new BunPackagesResponse
|
||||||
|
{
|
||||||
|
ScanId = inventory.ScanId,
|
||||||
|
ImageDigest = inventory.ImageDigest,
|
||||||
|
GeneratedAt = inventory.GeneratedAtUtc,
|
||||||
|
Packages = inventory.Packages
|
||||||
|
};
|
||||||
|
|
||||||
|
return Json(response, StatusCodes.Status200OK);
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
|
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
|
||||||
{
|
{
|
||||||
if (metadata is null || metadata.Count == 0)
|
if (metadata is null || metadata.Count == 0)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang;
|
||||||
|
using StellaOps.Scanner.Core.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Worker.Processing.Surface;
|
||||||
|
|
||||||
|
internal static class BunPackageInventoryBuilder
|
||||||
|
{
|
||||||
|
private const string AnalyzerId = "bun";
|
||||||
|
|
||||||
|
public static IReadOnlyList<BunPackageArtifact> Build(LanguageAnalyzerResult result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
|
||||||
|
var artifacts = new List<BunPackageArtifact>();
|
||||||
|
foreach (var component in result.Components)
|
||||||
|
{
|
||||||
|
if (!component.AnalyzerId.Equals(AnalyzerId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(component.Type, "npm", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata = component.Metadata ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var metadataCopy = new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var source = GetString(metadataCopy, "source");
|
||||||
|
var resolved = GetString(metadataCopy, "resolved");
|
||||||
|
var integrity = GetString(metadataCopy, "integrity");
|
||||||
|
var lockfile = GetString(metadataCopy, "lockfile");
|
||||||
|
var artifactLocator = GetString(metadataCopy, "artifact");
|
||||||
|
|
||||||
|
var isDev = TryParseBool(metadataCopy, "isDev");
|
||||||
|
var isDirect = TryParseBool(metadataCopy, "isDirect");
|
||||||
|
var isPatched = TryParseBool(metadataCopy, "isPatched");
|
||||||
|
|
||||||
|
var provenance = (source is not null || lockfile is not null || artifactLocator is not null)
|
||||||
|
? new BunPackageProvenance(source, lockfile, artifactLocator ?? lockfile)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
artifacts.Add(new BunPackageArtifact(
|
||||||
|
component.ComponentKey,
|
||||||
|
component.Name,
|
||||||
|
component.Version,
|
||||||
|
source,
|
||||||
|
resolved,
|
||||||
|
integrity,
|
||||||
|
isDev,
|
||||||
|
isDirect,
|
||||||
|
isPatched,
|
||||||
|
provenance,
|
||||||
|
metadataCopy));
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool? TryParseBool(IReadOnlyDictionary<string, string?> metadata, string key)
|
||||||
|
{
|
||||||
|
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bool.TryParse(value, out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(IReadOnlyDictionary<string, string?> metadata, string key)
|
||||||
|
{
|
||||||
|
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
|||||||
private readonly ILogger<SurfaceManifestStageExecutor> _logger;
|
private readonly ILogger<SurfaceManifestStageExecutor> _logger;
|
||||||
private readonly ICryptoHash _hash;
|
private readonly ICryptoHash _hash;
|
||||||
private readonly IRubyPackageInventoryStore _rubyPackageStore;
|
private readonly IRubyPackageInventoryStore _rubyPackageStore;
|
||||||
|
private readonly IBunPackageInventoryStore _bunPackageStore;
|
||||||
private readonly Determinism.DeterminismContext _determinism;
|
private readonly Determinism.DeterminismContext _determinism;
|
||||||
private readonly IDsseEnvelopeSigner _dsseSigner;
|
private readonly IDsseEnvelopeSigner _dsseSigner;
|
||||||
private readonly string _componentVersion;
|
private readonly string _componentVersion;
|
||||||
@@ -56,6 +57,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
|||||||
ILogger<SurfaceManifestStageExecutor> logger,
|
ILogger<SurfaceManifestStageExecutor> logger,
|
||||||
ICryptoHash hash,
|
ICryptoHash hash,
|
||||||
IRubyPackageInventoryStore rubyPackageStore,
|
IRubyPackageInventoryStore rubyPackageStore,
|
||||||
|
IBunPackageInventoryStore bunPackageStore,
|
||||||
Determinism.DeterminismContext determinism,
|
Determinism.DeterminismContext determinism,
|
||||||
IDsseEnvelopeSigner dsseSigner)
|
IDsseEnvelopeSigner dsseSigner)
|
||||||
{
|
{
|
||||||
@@ -67,6 +69,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
|||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||||
_rubyPackageStore = rubyPackageStore ?? throw new ArgumentNullException(nameof(rubyPackageStore));
|
_rubyPackageStore = rubyPackageStore ?? throw new ArgumentNullException(nameof(rubyPackageStore));
|
||||||
|
_bunPackageStore = bunPackageStore ?? throw new ArgumentNullException(nameof(bunPackageStore));
|
||||||
_determinism = determinism ?? throw new ArgumentNullException(nameof(determinism));
|
_determinism = determinism ?? throw new ArgumentNullException(nameof(determinism));
|
||||||
_dsseSigner = dsseSigner ?? throw new ArgumentNullException(nameof(dsseSigner));
|
_dsseSigner = dsseSigner ?? throw new ArgumentNullException(nameof(dsseSigner));
|
||||||
_componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
_componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||||
@@ -80,6 +83,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
|||||||
|
|
||||||
var payloads = CollectPayloads(context);
|
var payloads = CollectPayloads(context);
|
||||||
await PersistRubyPackagesAsync(context, cancellationToken).ConfigureAwait(false);
|
await PersistRubyPackagesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
await PersistBunPackagesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var determinismPayloads = BuildDeterminismPayloads(context, payloads, out var merkleRoot);
|
var determinismPayloads = BuildDeterminismPayloads(context, payloads, out var merkleRoot);
|
||||||
if (determinismPayloads is not null && determinismPayloads.Count > 0)
|
if (determinismPayloads is not null && determinismPayloads.Count > 0)
|
||||||
@@ -491,6 +495,33 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
|||||||
await _rubyPackageStore.StoreAsync(inventory, cancellationToken).ConfigureAwait(false);
|
await _rubyPackageStore.StoreAsync(inventory, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PersistBunPackagesAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!context.Analysis.TryGet<ReadOnlyDictionary<string, LanguageAnalyzerResult>>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results.TryGetValue("bun", out var bunResult) || bunResult is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages = BunPackageInventoryBuilder.Build(bunResult);
|
||||||
|
if (packages.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inventory = new BunPackageInventory(
|
||||||
|
context.ScanId,
|
||||||
|
ResolveImageDigest(context),
|
||||||
|
context.TimeProvider.GetUtcNow(),
|
||||||
|
packages);
|
||||||
|
|
||||||
|
await _bunPackageStore.StoreAsync(inventory, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task PersistPayloadsToSurfaceCacheAsync(
|
private async Task PersistPayloadsToSurfaceCacheAsync(
|
||||||
ScanJobContext context,
|
ScanJobContext context,
|
||||||
string tenant,
|
string tenant,
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ builder.Services.AddSingleton<IDsseEnvelopeSigner, HmacDsseEnvelopeSigner>();
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
builder.Services.TryAddSingleton<IRubyPackageInventoryStore, NullRubyPackageInventoryStore>();
|
builder.Services.TryAddSingleton<IRubyPackageInventoryStore, NullRubyPackageInventoryStore>();
|
||||||
|
builder.Services.TryAddSingleton<IBunPackageInventoryStore, NullBunPackageInventoryStore>();
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.Services.TryAddSingleton<IScanJobSource, NullScanJobSource>();
|
builder.Services.TryAddSingleton<IScanJobSource, NullScanJobSource>();
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a declared Java dependency with full GAV coordinates, scope, and exclusions.
|
||||||
|
/// Used across both Maven and Gradle parsers.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
public required string GroupId { get; init; }
|
||||||
|
|
||||||
|
public required string ArtifactId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version string. May contain property placeholders (e.g., "${spring.version}") that need resolution.
|
||||||
|
/// </summary>
|
||||||
|
public required string? Version { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dependency scope: compile, test, provided, runtime, system, import.
|
||||||
|
/// </summary>
|
||||||
|
public string? Scope { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Classifier for the artifact (e.g., "sources", "javadoc", "jdk11").
|
||||||
|
/// </summary>
|
||||||
|
public string? Classifier { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packaging type (e.g., "jar", "pom", "war").
|
||||||
|
/// </summary>
|
||||||
|
public string? Type { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this is an optional dependency.
|
||||||
|
/// </summary>
|
||||||
|
public bool Optional { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exclusions for transitive dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<JavaExclusion> Exclusions { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source of this declaration (e.g., "pom.xml", "build.gradle", "build.gradle.kts").
|
||||||
|
/// </summary>
|
||||||
|
public string? Source { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File path locator relative to the project root.
|
||||||
|
/// </summary>
|
||||||
|
public string? Locator { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates how the version was resolved.
|
||||||
|
/// </summary>
|
||||||
|
public JavaVersionSource VersionSource { get; init; } = JavaVersionSource.Direct;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Original property name if version came from a property (e.g., "spring.version").
|
||||||
|
/// </summary>
|
||||||
|
public string? VersionProperty { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether version is fully resolved (no remaining ${...} placeholders).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsVersionResolved => Version is not null &&
|
||||||
|
!Version.Contains("${", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate as "groupId:artifactId:version".
|
||||||
|
/// </summary>
|
||||||
|
public string Gav => Version is null
|
||||||
|
? $"{GroupId}:{ArtifactId}"
|
||||||
|
: $"{GroupId}:{ArtifactId}:{Version}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the unique key for deduplication.
|
||||||
|
/// </summary>
|
||||||
|
public string Key => BuildKey(GroupId, ArtifactId, Version ?? "*");
|
||||||
|
|
||||||
|
private static string BuildKey(string groupId, string artifactId, string version)
|
||||||
|
=> $"{groupId}:{artifactId}:{version}".ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an exclusion for transitive dependencies.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaExclusion(string GroupId, string ArtifactId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates the source of version resolution.
|
||||||
|
/// </summary>
|
||||||
|
internal enum JavaVersionSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Version declared directly in the dependency.
|
||||||
|
/// </summary>
|
||||||
|
Direct,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version inherited from parent POM.
|
||||||
|
/// </summary>
|
||||||
|
Parent,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version resolved from dependencyManagement in current POM.
|
||||||
|
/// </summary>
|
||||||
|
DependencyManagement,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version resolved from an imported BOM.
|
||||||
|
/// </summary>
|
||||||
|
Bom,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version resolved from a property placeholder.
|
||||||
|
/// </summary>
|
||||||
|
Property,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version resolved from Gradle version catalog.
|
||||||
|
/// </summary>
|
||||||
|
VersionCatalog,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version could not be resolved.
|
||||||
|
/// </summary>
|
||||||
|
Unresolved
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps dependency scopes to risk levels for security analysis.
|
||||||
|
/// </summary>
|
||||||
|
internal static class JavaScopeClassifier
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a Maven/Gradle scope to a risk level.
|
||||||
|
/// </summary>
|
||||||
|
public static string GetRiskLevel(string? scope) => scope?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
null or "" or "compile" or "implementation" or "api" => "production",
|
||||||
|
"runtime" or "runtimeOnly" => "production",
|
||||||
|
"test" or "testImplementation" or "testCompileOnly" or "testRuntimeOnly" => "development",
|
||||||
|
"provided" or "compileOnly" => "provided",
|
||||||
|
"system" => "system",
|
||||||
|
_ => "production" // Default to production for unknown scopes
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the scope indicates a direct (not transitive) dependency.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsDirect(string? scope) => scope?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"compile" or "implementation" or "api" or "test" or "testImplementation" => true,
|
||||||
|
"runtime" or "runtimeOnly" or "testRuntimeOnly" => false,
|
||||||
|
"provided" or "compileOnly" or "testCompileOnly" => true,
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents unified project metadata from Maven POM or Gradle build files.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaProjectMetadata
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Project group ID (Maven groupId or Gradle group).
|
||||||
|
/// </summary>
|
||||||
|
public string? GroupId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project artifact ID (Maven artifactId or Gradle name).
|
||||||
|
/// </summary>
|
||||||
|
public string? ArtifactId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project version.
|
||||||
|
/// </summary>
|
||||||
|
public string? Version { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packaging type (jar, war, pom, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public string? Packaging { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent project reference (Maven parent POM or Gradle parent project).
|
||||||
|
/// </summary>
|
||||||
|
public JavaParentReference? Parent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project properties (Maven properties or Gradle ext properties).
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableDictionary<string, string> Properties { get; init; } =
|
||||||
|
ImmutableDictionary<string, string>.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Declared licenses for the project.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<JavaLicenseInfo> Licenses { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dependencies declared in this project.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<JavaDependencyDeclaration> Dependencies { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dependency management entries (Maven dependencyManagement or Gradle platform).
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<JavaDependencyDeclaration> DependencyManagement { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source file path relative to the project root.
|
||||||
|
/// </summary>
|
||||||
|
public string? SourcePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build system type.
|
||||||
|
/// </summary>
|
||||||
|
public JavaBuildSystem BuildSystem { get; init; } = JavaBuildSystem.Unknown;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate of this project.
|
||||||
|
/// </summary>
|
||||||
|
public string? Gav => GroupId is not null && ArtifactId is not null
|
||||||
|
? Version is not null
|
||||||
|
? $"{GroupId}:{ArtifactId}:{Version}"
|
||||||
|
: $"{GroupId}:{ArtifactId}"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective group ID, falling back to parent if not set.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetEffectiveGroupId()
|
||||||
|
=> GroupId ?? Parent?.GroupId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the effective version, falling back to parent if not set.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetEffectiveVersion()
|
||||||
|
=> Version ?? Parent?.Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a reference to a parent project.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaParentReference
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parent group ID.
|
||||||
|
/// </summary>
|
||||||
|
public required string GroupId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent artifact ID.
|
||||||
|
/// </summary>
|
||||||
|
public required string ArtifactId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent version.
|
||||||
|
/// </summary>
|
||||||
|
public required string Version { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relative path to parent POM (Maven only).
|
||||||
|
/// </summary>
|
||||||
|
public string? RelativePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the parent was successfully resolved.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsResolved { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The resolved parent metadata (null if unresolved).
|
||||||
|
/// </summary>
|
||||||
|
public JavaProjectMetadata? ResolvedParent { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate of the parent.
|
||||||
|
/// </summary>
|
||||||
|
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents license information extracted from project metadata.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaLicenseInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// License name as declared in the project file.
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// License URL if available.
|
||||||
|
/// </summary>
|
||||||
|
public string? Url { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// License distribution type (repo, manual, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public string? Distribution { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comments about the license.
|
||||||
|
/// </summary>
|
||||||
|
public string? Comments { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalized SPDX identifier (null if not normalized).
|
||||||
|
/// </summary>
|
||||||
|
public string? SpdxId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confidence level of the SPDX normalization.
|
||||||
|
/// </summary>
|
||||||
|
public SpdxConfidence SpdxConfidence { get; init; } = SpdxConfidence.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confidence level for SPDX license normalization.
|
||||||
|
/// </summary>
|
||||||
|
internal enum SpdxConfidence
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No SPDX mapping available.
|
||||||
|
/// </summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Low confidence mapping (partial match).
|
||||||
|
/// </summary>
|
||||||
|
Low,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Medium confidence mapping (common name or URL match).
|
||||||
|
/// </summary>
|
||||||
|
Medium,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High confidence mapping (exact name or official URL).
|
||||||
|
/// </summary>
|
||||||
|
High
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build system type.
|
||||||
|
/// </summary>
|
||||||
|
internal enum JavaBuildSystem
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Maven,
|
||||||
|
GradleGroovy,
|
||||||
|
GradleKotlin,
|
||||||
|
Ant,
|
||||||
|
Bazel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a BOM (Bill of Materials) import.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaBomImport
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// BOM group ID.
|
||||||
|
/// </summary>
|
||||||
|
public required string GroupId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BOM artifact ID.
|
||||||
|
/// </summary>
|
||||||
|
public required string ArtifactId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BOM version.
|
||||||
|
/// </summary>
|
||||||
|
public required string Version { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the BOM was successfully resolved.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsResolved { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolved dependency management entries from the BOM.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<JavaDependencyDeclaration> ManagedDependencies { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate of the BOM.
|
||||||
|
/// </summary>
|
||||||
|
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Conflicts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects version conflicts where the same artifact appears with multiple versions.
|
||||||
|
/// </summary>
|
||||||
|
internal static class VersionConflictDetector
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes dependencies for version conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public static VersionConflictAnalysis Analyze(IEnumerable<JavaDependencyDeclaration> dependencies)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(dependencies);
|
||||||
|
|
||||||
|
var dependencyList = dependencies.ToList();
|
||||||
|
if (dependencyList.Count == 0)
|
||||||
|
{
|
||||||
|
return VersionConflictAnalysis.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by groupId:artifactId
|
||||||
|
var groups = dependencyList
|
||||||
|
.Where(d => !string.IsNullOrWhiteSpace(d.Version))
|
||||||
|
.GroupBy(d => $"{d.GroupId}:{d.ArtifactId}".ToLowerInvariant())
|
||||||
|
.Where(g => g.Select(d => d.Version).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
return VersionConflictAnalysis.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = new List<VersionConflict>();
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var versions = group
|
||||||
|
.Select(d => new VersionOccurrence(
|
||||||
|
d.Version!,
|
||||||
|
d.Source,
|
||||||
|
d.Locator,
|
||||||
|
d.Scope ?? "compile"))
|
||||||
|
.OrderBy(v => v.Version, VersionComparer.Instance)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
var parts = group.Key.Split(':');
|
||||||
|
var groupId = parts[0];
|
||||||
|
var artifactId = parts.Length > 1 ? parts[1] : string.Empty;
|
||||||
|
|
||||||
|
// Determine severity based on version distance
|
||||||
|
var severity = CalculateSeverity(versions);
|
||||||
|
|
||||||
|
conflicts.Add(new VersionConflict(
|
||||||
|
groupId,
|
||||||
|
artifactId,
|
||||||
|
versions,
|
||||||
|
severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VersionConflictAnalysis(
|
||||||
|
[.. conflicts.OrderBy(c => c.GroupId).ThenBy(c => c.ArtifactId)],
|
||||||
|
conflicts.Count,
|
||||||
|
conflicts.Max(c => c.Severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes artifacts (from JARs) for version conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public static VersionConflictAnalysis AnalyzeArtifacts(
|
||||||
|
IEnumerable<(string GroupId, string ArtifactId, string Version, string Source)> artifacts)
|
||||||
|
{
|
||||||
|
var dependencies = artifacts
|
||||||
|
.Select(a => new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = a.GroupId,
|
||||||
|
ArtifactId = a.ArtifactId,
|
||||||
|
Version = a.Version,
|
||||||
|
Source = a.Source,
|
||||||
|
Locator = a.Source
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Analyze(dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConflictSeverity CalculateSeverity(ImmutableArray<VersionOccurrence> versions)
|
||||||
|
{
|
||||||
|
var versionStrings = versions.Select(v => v.Version).Distinct().ToList();
|
||||||
|
|
||||||
|
if (versionStrings.Count == 1)
|
||||||
|
{
|
||||||
|
return ConflictSeverity.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as semantic versions
|
||||||
|
var semvers = versionStrings
|
||||||
|
.Select(TryParseSemanticVersion)
|
||||||
|
.Where(v => v is not null)
|
||||||
|
.Cast<SemanticVersion>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (semvers.Count < 2)
|
||||||
|
{
|
||||||
|
// Can't determine severity without parseable versions
|
||||||
|
return ConflictSeverity.Medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for major version differences (high severity)
|
||||||
|
var majorVersions = semvers.Select(v => v.Major).Distinct().ToList();
|
||||||
|
if (majorVersions.Count > 1)
|
||||||
|
{
|
||||||
|
return ConflictSeverity.High;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for minor version differences (medium severity)
|
||||||
|
var minorVersions = semvers.Select(v => v.Minor).Distinct().ToList();
|
||||||
|
if (minorVersions.Count > 1)
|
||||||
|
{
|
||||||
|
return ConflictSeverity.Medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only patch version differences (low severity)
|
||||||
|
return ConflictSeverity.Low;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SemanticVersion? TryParseSemanticVersion(string version)
|
||||||
|
{
|
||||||
|
// Handle versions like "1.2.3", "1.2.3-SNAPSHOT", "1.2.3.Final"
|
||||||
|
var cleanVersion = version
|
||||||
|
.Split('-')[0] // Remove suffix like -SNAPSHOT
|
||||||
|
.Split('.', 4); // Split into parts
|
||||||
|
|
||||||
|
if (cleanVersion.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(cleanVersion[0], out var major))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minor = cleanVersion.Length > 1 && int.TryParse(cleanVersion[1], out var m) ? m : 0;
|
||||||
|
var patch = cleanVersion.Length > 2 && int.TryParse(cleanVersion[2], out var p) ? p : 0;
|
||||||
|
|
||||||
|
return new SemanticVersion(major, minor, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SemanticVersion(int Major, int Minor, int Patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of version conflict analysis.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record VersionConflictAnalysis(
|
||||||
|
ImmutableArray<VersionConflict> 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 artifact.
|
||||||
|
/// </summary>
|
||||||
|
public VersionConflict? GetConflict(string groupId, string artifactId)
|
||||||
|
=> Conflicts.FirstOrDefault(c =>
|
||||||
|
string.Equals(c.GroupId, groupId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(c.ArtifactId, artifactId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a version conflict for a single artifact.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record VersionConflict(
|
||||||
|
string GroupId,
|
||||||
|
string ArtifactId,
|
||||||
|
ImmutableArray<VersionOccurrence> Versions,
|
||||||
|
ConflictSeverity Severity)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the artifact coordinate (groupId:artifactId).
|
||||||
|
/// </summary>
|
||||||
|
public string Coordinate => $"{GroupId}:{ArtifactId}";
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// Represents a single occurrence of a version.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record VersionOccurrence(
|
||||||
|
string Version,
|
||||||
|
string? Source,
|
||||||
|
string? Locator,
|
||||||
|
string Scope);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Severity level of a version conflict.
|
||||||
|
/// </summary>
|
||||||
|
internal enum ConflictSeverity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No conflict.
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only patch version differences (likely compatible).
|
||||||
|
/// </summary>
|
||||||
|
Low = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minor version differences (may have API changes).
|
||||||
|
/// </summary>
|
||||||
|
Medium = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Major version differences (likely incompatible).
|
||||||
|
/// </summary>
|
||||||
|
High = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comparer for semantic version strings.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class VersionComparer : IComparer<string>
|
||||||
|
{
|
||||||
|
public static readonly VersionComparer 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 xParts = x.Split(['.', '-'], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var yParts = y.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Discovery;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers Java/JVM build files in a directory tree.
|
||||||
|
/// </summary>
|
||||||
|
internal static class JavaBuildFileDiscovery
|
||||||
|
{
|
||||||
|
private static readonly string[] MavenFiles = ["pom.xml"];
|
||||||
|
private static readonly string[] GradleGroovyFiles = ["build.gradle", "settings.gradle"];
|
||||||
|
private static readonly string[] GradleKotlinFiles = ["build.gradle.kts", "settings.gradle.kts"];
|
||||||
|
private static readonly string[] GradleLockFiles = ["gradle.lockfile"];
|
||||||
|
private static readonly string[] GradlePropertiesFiles = ["gradle.properties"];
|
||||||
|
private static readonly string[] GradleVersionCatalogFiles = ["libs.versions.toml", "gradle/libs.versions.toml"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers all Java build files in the given directory tree.
|
||||||
|
/// </summary>
|
||||||
|
public static JavaBuildFiles Discover(string rootPath, int maxDepth = 10)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||||
|
|
||||||
|
if (!Directory.Exists(rootPath))
|
||||||
|
{
|
||||||
|
return JavaBuildFiles.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maven = new List<DiscoveredBuildFile>();
|
||||||
|
var gradleGroovy = new List<DiscoveredBuildFile>();
|
||||||
|
var gradleKotlin = new List<DiscoveredBuildFile>();
|
||||||
|
var gradleLock = new List<DiscoveredBuildFile>();
|
||||||
|
var gradleProperties = new List<DiscoveredBuildFile>();
|
||||||
|
var versionCatalogs = new List<DiscoveredBuildFile>();
|
||||||
|
|
||||||
|
DiscoverRecursive(rootPath, rootPath, 0, maxDepth,
|
||||||
|
maven, gradleGroovy, gradleKotlin, gradleLock, gradleProperties, versionCatalogs);
|
||||||
|
|
||||||
|
return new JavaBuildFiles(
|
||||||
|
[.. maven.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||||
|
[.. gradleGroovy.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||||
|
[.. gradleKotlin.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||||
|
[.. gradleLock.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||||
|
[.. gradleProperties.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||||
|
[.. versionCatalogs.OrderBy(f => f.RelativePath, StringComparer.Ordinal)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DiscoverRecursive(
|
||||||
|
string currentPath,
|
||||||
|
string rootPath,
|
||||||
|
int currentDepth,
|
||||||
|
int maxDepth,
|
||||||
|
List<DiscoveredBuildFile> maven,
|
||||||
|
List<DiscoveredBuildFile> gradleGroovy,
|
||||||
|
List<DiscoveredBuildFile> gradleKotlin,
|
||||||
|
List<DiscoveredBuildFile> gradleLock,
|
||||||
|
List<DiscoveredBuildFile> gradleProperties,
|
||||||
|
List<DiscoveredBuildFile> versionCatalogs)
|
||||||
|
{
|
||||||
|
if (currentDepth > maxDepth)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check for files in current directory
|
||||||
|
foreach (var file in MavenFiles)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(currentPath, file);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
maven.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.Maven));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in GradleGroovyFiles)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(currentPath, file);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
gradleGroovy.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in GradleKotlinFiles)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(currentPath, file);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
gradleKotlin.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleKotlin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in GradleLockFiles)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(currentPath, file);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
gradleLock.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var file in GradlePropertiesFiles)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(currentPath, file);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
gradleProperties.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for version catalog files (can be in root or gradle/ subdirectory)
|
||||||
|
foreach (var file in GradleVersionCatalogFiles)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(currentPath, file);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
versionCatalogs.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check gradle/dependency-locks directory for lock files
|
||||||
|
var dependencyLocksDir = Path.Combine(currentPath, "gradle", "dependency-locks");
|
||||||
|
if (Directory.Exists(dependencyLocksDir))
|
||||||
|
{
|
||||||
|
foreach (var lockFile in Directory.EnumerateFiles(dependencyLocksDir, "*.lockfile", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
gradleLock.Add(CreateDiscoveredFile(lockFile, rootPath, JavaBuildSystem.GradleGroovy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into subdirectories
|
||||||
|
foreach (var subDir in Directory.EnumerateDirectories(currentPath))
|
||||||
|
{
|
||||||
|
var dirName = Path.GetFileName(subDir);
|
||||||
|
|
||||||
|
// Skip common non-project directories
|
||||||
|
if (ShouldSkipDirectory(dirName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoverRecursive(subDir, rootPath, currentDepth + 1, maxDepth,
|
||||||
|
maven, gradleGroovy, gradleKotlin, gradleLock, gradleProperties, versionCatalogs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Skip directories we can't access
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
|
{
|
||||||
|
// Directory was deleted while scanning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DiscoveredBuildFile CreateDiscoveredFile(string absolutePath, string rootPath, JavaBuildSystem buildSystem)
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(rootPath, absolutePath).Replace('\\', '/');
|
||||||
|
var projectDirectory = Path.GetDirectoryName(relativePath) ?? ".";
|
||||||
|
if (string.IsNullOrEmpty(projectDirectory))
|
||||||
|
{
|
||||||
|
projectDirectory = ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DiscoveredBuildFile(
|
||||||
|
absolutePath,
|
||||||
|
relativePath,
|
||||||
|
projectDirectory,
|
||||||
|
Path.GetFileName(absolutePath),
|
||||||
|
buildSystem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldSkipDirectory(string dirName)
|
||||||
|
{
|
||||||
|
return dirName switch
|
||||||
|
{
|
||||||
|
"node_modules" or ".git" or ".svn" or ".hg" => true,
|
||||||
|
"target" or "build" or "out" or "bin" or "obj" => true,
|
||||||
|
".gradle" or ".idea" or ".vscode" or ".settings" => true,
|
||||||
|
"__pycache__" or "vendor" or "dist" => true,
|
||||||
|
_ when dirName.StartsWith('.') => true,
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a discovered build file.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record DiscoveredBuildFile(
|
||||||
|
string AbsolutePath,
|
||||||
|
string RelativePath,
|
||||||
|
string ProjectDirectory,
|
||||||
|
string FileName,
|
||||||
|
JavaBuildSystem BuildSystem);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collection of discovered Java build files.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaBuildFiles(
|
||||||
|
ImmutableArray<DiscoveredBuildFile> MavenPoms,
|
||||||
|
ImmutableArray<DiscoveredBuildFile> GradleGroovyFiles,
|
||||||
|
ImmutableArray<DiscoveredBuildFile> GradleKotlinFiles,
|
||||||
|
ImmutableArray<DiscoveredBuildFile> GradleLockFiles,
|
||||||
|
ImmutableArray<DiscoveredBuildFile> GradlePropertiesFiles,
|
||||||
|
ImmutableArray<DiscoveredBuildFile> VersionCatalogFiles)
|
||||||
|
{
|
||||||
|
public static readonly JavaBuildFiles Empty = new([], [], [], [], [], []);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if any build files were found.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasAny =>
|
||||||
|
MavenPoms.Length > 0 ||
|
||||||
|
GradleGroovyFiles.Length > 0 ||
|
||||||
|
GradleKotlinFiles.Length > 0 ||
|
||||||
|
GradleLockFiles.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the project uses Maven.
|
||||||
|
/// </summary>
|
||||||
|
public bool UsesMaven => MavenPoms.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the project uses Gradle.
|
||||||
|
/// </summary>
|
||||||
|
public bool UsesGradle =>
|
||||||
|
GradleGroovyFiles.Length > 0 ||
|
||||||
|
GradleKotlinFiles.Length > 0 ||
|
||||||
|
GradleLockFiles.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if Gradle lockfiles are present (preferred source).
|
||||||
|
/// </summary>
|
||||||
|
public bool HasGradleLockFiles => GradleLockFiles.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a version catalog is present.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasVersionCatalog => VersionCatalogFiles.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the primary build system.
|
||||||
|
/// </summary>
|
||||||
|
public JavaBuildSystem PrimaryBuildSystem
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Gradle lockfiles take precedence
|
||||||
|
if (HasGradleLockFiles)
|
||||||
|
{
|
||||||
|
return JavaBuildSystem.GradleGroovy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then Gradle build files
|
||||||
|
if (GradleKotlinFiles.Length > 0)
|
||||||
|
{
|
||||||
|
return JavaBuildSystem.GradleKotlin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GradleGroovyFiles.Length > 0)
|
||||||
|
{
|
||||||
|
return JavaBuildSystem.GradleGroovy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Maven
|
||||||
|
if (UsesMaven)
|
||||||
|
{
|
||||||
|
return JavaBuildSystem.Maven;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JavaBuildSystem.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all discovered projects grouped by directory.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<JavaProjectFiles> GetProjectsByDirectory()
|
||||||
|
{
|
||||||
|
var allFiles = MavenPoms
|
||||||
|
.Concat(GradleGroovyFiles)
|
||||||
|
.Concat(GradleKotlinFiles)
|
||||||
|
.Concat(GradleLockFiles)
|
||||||
|
.Concat(GradlePropertiesFiles)
|
||||||
|
.Concat(VersionCatalogFiles);
|
||||||
|
|
||||||
|
return allFiles
|
||||||
|
.GroupBy(f => f.ProjectDirectory, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => new JavaProjectFiles(
|
||||||
|
g.Key,
|
||||||
|
g.FirstOrDefault(f => f.FileName == "pom.xml"),
|
||||||
|
g.FirstOrDefault(f => f.FileName == "build.gradle"),
|
||||||
|
g.FirstOrDefault(f => f.FileName == "build.gradle.kts"),
|
||||||
|
g.FirstOrDefault(f => f.FileName == "gradle.lockfile"),
|
||||||
|
g.FirstOrDefault(f => f.FileName == "gradle.properties"),
|
||||||
|
g.FirstOrDefault(f => f.FileName == "libs.versions.toml")))
|
||||||
|
.OrderBy(p => p.Directory, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the build files for a single project directory.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record JavaProjectFiles(
|
||||||
|
string Directory,
|
||||||
|
DiscoveredBuildFile? PomXml,
|
||||||
|
DiscoveredBuildFile? BuildGradle,
|
||||||
|
DiscoveredBuildFile? BuildGradleKts,
|
||||||
|
DiscoveredBuildFile? GradleLockfile,
|
||||||
|
DiscoveredBuildFile? GradleProperties,
|
||||||
|
DiscoveredBuildFile? VersionCatalog)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Determines the primary build system for this project.
|
||||||
|
/// </summary>
|
||||||
|
public JavaBuildSystem PrimaryBuildSystem
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (GradleLockfile is not null || BuildGradle is not null)
|
||||||
|
{
|
||||||
|
return JavaBuildSystem.GradleGroovy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildGradleKts is not null)
|
||||||
|
{
|
||||||
|
return JavaBuildSystem.GradleKotlin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PomXml is not null)
|
||||||
|
{
|
||||||
|
return JavaBuildSystem.Maven;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JavaBuildSystem.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Gradle Groovy DSL build files (build.gradle).
|
||||||
|
/// Uses regex-based parsing to extract dependency declarations from common patterns.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class GradleGroovyParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gradle configuration names that indicate dependency declarations.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] DependencyConfigurations =
|
||||||
|
[
|
||||||
|
"implementation", "api", "compileOnly", "runtimeOnly",
|
||||||
|
"testImplementation", "testCompileOnly", "testRuntimeOnly",
|
||||||
|
"annotationProcessor", "kapt", "ksp",
|
||||||
|
"compile", "runtime", "testCompile", "testRuntime", // Legacy
|
||||||
|
"providedCompile", "providedRuntime" // Legacy WAR plugin
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a build.gradle file asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<GradleBuildFile> ParseAsync(
|
||||||
|
string path,
|
||||||
|
GradleProperties? properties = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return GradleBuildFile.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
return Parse(content, path, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses build.gradle content.
|
||||||
|
/// </summary>
|
||||||
|
public static GradleBuildFile Parse(string content, string sourcePath, GradleProperties? properties = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return GradleBuildFile.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dependencies = new List<JavaDependencyDeclaration>();
|
||||||
|
var plugins = new List<GradlePlugin>();
|
||||||
|
var unresolvedDependencies = new List<string>();
|
||||||
|
|
||||||
|
// Extract group and version from build file
|
||||||
|
var group = ExtractProperty(content, "group");
|
||||||
|
var version = ExtractProperty(content, "version");
|
||||||
|
|
||||||
|
// Parse plugins block
|
||||||
|
ParsePlugins(content, plugins);
|
||||||
|
|
||||||
|
// Parse dependencies block
|
||||||
|
ParseDependencies(content, sourcePath, properties, dependencies, unresolvedDependencies);
|
||||||
|
|
||||||
|
// Parse platform/BOM declarations
|
||||||
|
ParsePlatformDependencies(content, sourcePath, dependencies);
|
||||||
|
|
||||||
|
return new GradleBuildFile(
|
||||||
|
sourcePath,
|
||||||
|
JavaBuildSystem.GradleGroovy,
|
||||||
|
group,
|
||||||
|
version,
|
||||||
|
[.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)],
|
||||||
|
[.. plugins.OrderBy(p => p.Id, StringComparer.Ordinal)],
|
||||||
|
[.. unresolvedDependencies.Distinct().OrderBy(u => u, StringComparer.Ordinal)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractProperty(string content, string propertyName)
|
||||||
|
{
|
||||||
|
// Match: group = 'com.example' or group 'com.example'
|
||||||
|
var pattern = $@"(?:^|\s){propertyName}\s*[=]?\s*['""]([^'""]+)['""]";
|
||||||
|
var match = Regex.Match(content, pattern, RegexOptions.Multiline);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParsePlugins(string content, List<GradlePlugin> plugins)
|
||||||
|
{
|
||||||
|
// Match plugins { ... } block
|
||||||
|
var pluginsBlockMatch = PluginsBlockPattern().Match(content);
|
||||||
|
if (!pluginsBlockMatch.Success)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var block = pluginsBlockMatch.Groups[1].Value;
|
||||||
|
|
||||||
|
// Match id 'plugin-id' version 'x.y.z'
|
||||||
|
foreach (Match match in PluginPattern().Matches(block))
|
||||||
|
{
|
||||||
|
var id = match.Groups[1].Value;
|
||||||
|
var version = match.Groups.Count > 2 ? match.Groups[2].Value : null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
plugins.Add(new GradlePlugin(id, version));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseDependencies(
|
||||||
|
string content,
|
||||||
|
string sourcePath,
|
||||||
|
GradleProperties? properties,
|
||||||
|
List<JavaDependencyDeclaration> dependencies,
|
||||||
|
List<string> unresolved)
|
||||||
|
{
|
||||||
|
// Match dependencies { ... } block
|
||||||
|
var dependenciesBlock = ExtractDependenciesBlock(content);
|
||||||
|
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var config in DependencyConfigurations)
|
||||||
|
{
|
||||||
|
// Pattern 1: implementation 'group:artifact:version'
|
||||||
|
var stringPattern = $@"{config}\s+['""]([^'""]+)['""]";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, stringPattern))
|
||||||
|
{
|
||||||
|
var coordinate = match.Groups[1].Value;
|
||||||
|
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
|
||||||
|
if (dependency is not null)
|
||||||
|
{
|
||||||
|
dependencies.Add(dependency);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(coordinate))
|
||||||
|
{
|
||||||
|
unresolved.Add(coordinate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: implementation group: 'com.example', name: 'artifact', version: '1.0'
|
||||||
|
var mapPattern = $@"{config}\s+group:\s*['""]([^'""]+)['""]\s*,\s*name:\s*['""]([^'""]+)['""]\s*(?:,\s*version:\s*['""]([^'""]+)['""])?";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, mapPattern))
|
||||||
|
{
|
||||||
|
var groupId = match.Groups[1].Value;
|
||||||
|
var artifactId = match.Groups[2].Value;
|
||||||
|
var version = match.Groups.Count > 3 && match.Groups[3].Success
|
||||||
|
? match.Groups[3].Value
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
|
||||||
|
{
|
||||||
|
dependencies.Add(new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
ArtifactId = artifactId,
|
||||||
|
Version = ResolveVersionProperty(version, properties),
|
||||||
|
Scope = MapConfigurationToScope(config),
|
||||||
|
Source = "build.gradle",
|
||||||
|
Locator = sourcePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: implementation(libs.some.library) - version catalog reference
|
||||||
|
var catalogPattern = $@"{config}\s*\(\s*libs\.([a-zA-Z0-9_.]+)\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, catalogPattern))
|
||||||
|
{
|
||||||
|
var alias = match.Groups[1].Value;
|
||||||
|
// Mark as unresolved until version catalog is parsed
|
||||||
|
unresolved.Add($"libs.{alias}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 4: implementation("group:artifact:version") - with parentheses
|
||||||
|
var parenPattern = $@"{config}\s*\(\s*['""]([^'""]+)['""]\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, parenPattern))
|
||||||
|
{
|
||||||
|
var coordinate = match.Groups[1].Value;
|
||||||
|
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
|
||||||
|
if (dependency is not null)
|
||||||
|
{
|
||||||
|
dependencies.Add(dependency);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(coordinate))
|
||||||
|
{
|
||||||
|
unresolved.Add(coordinate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParsePlatformDependencies(
|
||||||
|
string content,
|
||||||
|
string sourcePath,
|
||||||
|
List<JavaDependencyDeclaration> dependencies)
|
||||||
|
{
|
||||||
|
var dependenciesBlock = ExtractDependenciesBlock(content);
|
||||||
|
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match: implementation platform('group:artifact:version')
|
||||||
|
var platformPattern = @"(?:implementation|api)\s+platform\s*\(\s*['""]([^'""]+)['""]\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, platformPattern))
|
||||||
|
{
|
||||||
|
var coordinate = match.Groups[1].Value;
|
||||||
|
var parts = coordinate.Split(':');
|
||||||
|
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
dependencies.Add(new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = parts[0],
|
||||||
|
ArtifactId = parts[1],
|
||||||
|
Version = parts.Length > 2 ? parts[2] : null,
|
||||||
|
Type = "pom",
|
||||||
|
Scope = "import",
|
||||||
|
Source = "build.gradle",
|
||||||
|
Locator = sourcePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractDependenciesBlock(string content)
|
||||||
|
{
|
||||||
|
// Simple extraction - find matching braces after 'dependencies'
|
||||||
|
var match = DependenciesBlockPattern().Match(content);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startIndex = match.Index + match.Length;
|
||||||
|
var braceCount = 1;
|
||||||
|
var endIndex = startIndex;
|
||||||
|
|
||||||
|
while (endIndex < content.Length && braceCount > 0)
|
||||||
|
{
|
||||||
|
if (content[endIndex] == '{') braceCount++;
|
||||||
|
else if (content[endIndex] == '}') braceCount--;
|
||||||
|
endIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceCount == 0)
|
||||||
|
{
|
||||||
|
return content[startIndex..(endIndex - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JavaDependencyDeclaration? ParseCoordinate(
|
||||||
|
string coordinate,
|
||||||
|
string configuration,
|
||||||
|
string sourcePath,
|
||||||
|
GradleProperties? properties)
|
||||||
|
{
|
||||||
|
var parts = coordinate.Split(':');
|
||||||
|
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupId = parts[0];
|
||||||
|
var artifactId = parts[1];
|
||||||
|
var version = parts.Length > 2 ? parts[2] : null;
|
||||||
|
string? classifier = null;
|
||||||
|
|
||||||
|
// Handle classifier: group:artifact:version:classifier
|
||||||
|
if (parts.Length > 3)
|
||||||
|
{
|
||||||
|
classifier = parts[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle version ranges or dynamic versions
|
||||||
|
if (version is not null && (version.Contains('[') || version.Contains('+') || version == "latest.release"))
|
||||||
|
{
|
||||||
|
// Keep dynamic versions as-is but mark them
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
ArtifactId = artifactId,
|
||||||
|
Version = ResolveVersionProperty(version, properties),
|
||||||
|
Classifier = classifier,
|
||||||
|
Scope = MapConfigurationToScope(configuration),
|
||||||
|
Source = "build.gradle",
|
||||||
|
Locator = sourcePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveVersionProperty(string? version, GradleProperties? properties)
|
||||||
|
{
|
||||||
|
if (version is null || properties is null)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle $property or ${property} syntax
|
||||||
|
if (version.StartsWith('$'))
|
||||||
|
{
|
||||||
|
var propertyName = version.TrimStart('$').Trim('{', '}');
|
||||||
|
return properties.GetProperty(propertyName) ?? version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapConfigurationToScope(string configuration)
|
||||||
|
{
|
||||||
|
return configuration.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"implementation" or "api" or "compile" => "compile",
|
||||||
|
"compileonly" or "providedcompile" => "provided",
|
||||||
|
"runtimeonly" or "runtime" or "providedruntime" => "runtime",
|
||||||
|
"testimplementation" or "testcompile" => "test",
|
||||||
|
"testcompileonly" => "test",
|
||||||
|
"testruntimeonly" or "testruntime" => "test",
|
||||||
|
"annotationprocessor" or "kapt" or "ksp" => "compile",
|
||||||
|
_ => "compile"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"plugins\s*\{([^}]+)\}", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex PluginsBlockPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"id\s*['""]([^'""]+)['""]\s*(?:version\s*['""]([^'""]+)['""])?", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex PluginPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"dependencies\s*\{", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex DependenciesBlockPattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parsed Gradle build file.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record GradleBuildFile(
|
||||||
|
string SourcePath,
|
||||||
|
JavaBuildSystem BuildSystem,
|
||||||
|
string? Group,
|
||||||
|
string? Version,
|
||||||
|
ImmutableArray<JavaDependencyDeclaration> Dependencies,
|
||||||
|
ImmutableArray<GradlePlugin> Plugins,
|
||||||
|
ImmutableArray<string> UnresolvedDependencies)
|
||||||
|
{
|
||||||
|
public static readonly GradleBuildFile Empty = new(
|
||||||
|
string.Empty,
|
||||||
|
JavaBuildSystem.GradleGroovy,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if parsing found any dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasDependencies => Dependencies.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if there are unresolved dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasUnresolvedDependencies => UnresolvedDependencies.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a Gradle plugin declaration.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record GradlePlugin(string Id, string? Version);
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Gradle Kotlin DSL build files (build.gradle.kts).
|
||||||
|
/// Uses regex-based parsing to extract dependency declarations.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class GradleKotlinParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gradle Kotlin DSL configuration functions.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] DependencyConfigurations =
|
||||||
|
[
|
||||||
|
"implementation", "api", "compileOnly", "runtimeOnly",
|
||||||
|
"testImplementation", "testCompileOnly", "testRuntimeOnly",
|
||||||
|
"annotationProcessor", "kapt", "ksp"
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a build.gradle.kts file asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<GradleBuildFile> ParseAsync(
|
||||||
|
string path,
|
||||||
|
GradleProperties? properties = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return GradleBuildFile.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
return Parse(content, path, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses build.gradle.kts content.
|
||||||
|
/// </summary>
|
||||||
|
public static GradleBuildFile Parse(string content, string sourcePath, GradleProperties? properties = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return GradleBuildFile.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dependencies = new List<JavaDependencyDeclaration>();
|
||||||
|
var plugins = new List<GradlePlugin>();
|
||||||
|
var unresolvedDependencies = new List<string>();
|
||||||
|
|
||||||
|
// Extract group and version
|
||||||
|
var group = ExtractProperty(content, "group");
|
||||||
|
var version = ExtractProperty(content, "version");
|
||||||
|
|
||||||
|
// Parse plugins block
|
||||||
|
ParsePlugins(content, plugins);
|
||||||
|
|
||||||
|
// Parse dependencies block
|
||||||
|
ParseDependencies(content, sourcePath, properties, dependencies, unresolvedDependencies);
|
||||||
|
|
||||||
|
// Parse platform/BOM declarations
|
||||||
|
ParsePlatformDependencies(content, sourcePath, dependencies);
|
||||||
|
|
||||||
|
return new GradleBuildFile(
|
||||||
|
sourcePath,
|
||||||
|
JavaBuildSystem.GradleKotlin,
|
||||||
|
group,
|
||||||
|
version,
|
||||||
|
[.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)],
|
||||||
|
[.. plugins.OrderBy(p => p.Id, StringComparer.Ordinal)],
|
||||||
|
[.. unresolvedDependencies.Distinct().OrderBy(u => u, StringComparer.Ordinal)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractProperty(string content, string propertyName)
|
||||||
|
{
|
||||||
|
// Match: group = "com.example" or group.set("com.example")
|
||||||
|
var assignPattern = $@"{propertyName}\s*=\s*""([^""]+)""";
|
||||||
|
var match = Regex.Match(content, assignPattern);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
return match.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var setPattern = $@"{propertyName}\.set\s*\(\s*""([^""]+)""\s*\)";
|
||||||
|
match = Regex.Match(content, setPattern);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParsePlugins(string content, List<GradlePlugin> plugins)
|
||||||
|
{
|
||||||
|
// Match plugins { ... } block
|
||||||
|
var pluginsBlock = ExtractBlock(content, "plugins");
|
||||||
|
if (string.IsNullOrWhiteSpace(pluginsBlock))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match id("plugin-id") version "x.y.z"
|
||||||
|
foreach (Match match in PluginIdPattern().Matches(pluginsBlock))
|
||||||
|
{
|
||||||
|
var id = match.Groups[1].Value;
|
||||||
|
var version = match.Groups.Count > 2 && match.Groups[2].Success
|
||||||
|
? match.Groups[2].Value
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
plugins.Add(new GradlePlugin(id, version));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match kotlin("jvm") style
|
||||||
|
foreach (Match match in KotlinPluginPattern().Matches(pluginsBlock))
|
||||||
|
{
|
||||||
|
var type = match.Groups[1].Value;
|
||||||
|
var version = match.Groups.Count > 2 && match.Groups[2].Success
|
||||||
|
? match.Groups[2].Value
|
||||||
|
: null;
|
||||||
|
|
||||||
|
plugins.Add(new GradlePlugin($"org.jetbrains.kotlin.{type}", version));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match `java` or similar bare plugins
|
||||||
|
foreach (Match match in BarePluginPattern().Matches(pluginsBlock))
|
||||||
|
{
|
||||||
|
var id = match.Groups[1].Value;
|
||||||
|
if (!id.Contains('"') && !id.Contains('('))
|
||||||
|
{
|
||||||
|
plugins.Add(new GradlePlugin(id, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseDependencies(
|
||||||
|
string content,
|
||||||
|
string sourcePath,
|
||||||
|
GradleProperties? properties,
|
||||||
|
List<JavaDependencyDeclaration> dependencies,
|
||||||
|
List<string> unresolved)
|
||||||
|
{
|
||||||
|
var dependenciesBlock = ExtractBlock(content, "dependencies");
|
||||||
|
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var config in DependencyConfigurations)
|
||||||
|
{
|
||||||
|
// Pattern 1: implementation("group:artifact:version")
|
||||||
|
var stringPattern = $@"{config}\s*\(\s*""([^""]+)""\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, stringPattern))
|
||||||
|
{
|
||||||
|
var coordinate = match.Groups[1].Value;
|
||||||
|
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
|
||||||
|
if (dependency is not null)
|
||||||
|
{
|
||||||
|
dependencies.Add(dependency);
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(coordinate))
|
||||||
|
{
|
||||||
|
unresolved.Add(coordinate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: implementation(group = "com.example", name = "artifact", version = "1.0")
|
||||||
|
var namedArgsPattern = $@"{config}\s*\(\s*group\s*=\s*""([^""]+)""\s*,\s*name\s*=\s*""([^""]+)""(?:\s*,\s*version\s*=\s*""([^""]+)"")?\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, namedArgsPattern))
|
||||||
|
{
|
||||||
|
var groupId = match.Groups[1].Value;
|
||||||
|
var artifactId = match.Groups[2].Value;
|
||||||
|
var version = match.Groups.Count > 3 && match.Groups[3].Success
|
||||||
|
? match.Groups[3].Value
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
|
||||||
|
{
|
||||||
|
dependencies.Add(new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
ArtifactId = artifactId,
|
||||||
|
Version = ResolveVersionProperty(version, properties),
|
||||||
|
Scope = MapConfigurationToScope(config),
|
||||||
|
Source = "build.gradle.kts",
|
||||||
|
Locator = sourcePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: implementation(libs.some.library) - version catalog reference
|
||||||
|
var catalogPattern = $@"{config}\s*\(\s*libs\.([a-zA-Z0-9_.]+)\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, catalogPattern))
|
||||||
|
{
|
||||||
|
var alias = match.Groups[1].Value;
|
||||||
|
unresolved.Add($"libs.{alias}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 4: implementation(project(":module"))
|
||||||
|
var projectPattern = $@"{config}\s*\(\s*project\s*\(\s*"":([^""]+)""\s*\)\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, projectPattern))
|
||||||
|
{
|
||||||
|
// Skip project dependencies - they're internal modules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParsePlatformDependencies(
|
||||||
|
string content,
|
||||||
|
string sourcePath,
|
||||||
|
List<JavaDependencyDeclaration> dependencies)
|
||||||
|
{
|
||||||
|
var dependenciesBlock = ExtractBlock(content, "dependencies");
|
||||||
|
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match: implementation(platform("group:artifact:version"))
|
||||||
|
var platformPattern = @"(?:implementation|api)\s*\(\s*platform\s*\(\s*""([^""]+)""\s*\)\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, platformPattern))
|
||||||
|
{
|
||||||
|
var coordinate = match.Groups[1].Value;
|
||||||
|
var parts = coordinate.Split(':');
|
||||||
|
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
dependencies.Add(new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = parts[0],
|
||||||
|
ArtifactId = parts[1],
|
||||||
|
Version = parts.Length > 2 ? parts[2] : null,
|
||||||
|
Type = "pom",
|
||||||
|
Scope = "import",
|
||||||
|
Source = "build.gradle.kts",
|
||||||
|
Locator = sourcePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match: implementation(enforcedPlatform("group:artifact:version"))
|
||||||
|
var enforcedPattern = @"(?:implementation|api)\s*\(\s*enforcedPlatform\s*\(\s*""([^""]+)""\s*\)\s*\)";
|
||||||
|
foreach (Match match in Regex.Matches(dependenciesBlock, enforcedPattern))
|
||||||
|
{
|
||||||
|
var coordinate = match.Groups[1].Value;
|
||||||
|
var parts = coordinate.Split(':');
|
||||||
|
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
dependencies.Add(new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = parts[0],
|
||||||
|
ArtifactId = parts[1],
|
||||||
|
Version = parts.Length > 2 ? parts[2] : null,
|
||||||
|
Type = "pom",
|
||||||
|
Scope = "import",
|
||||||
|
Source = "build.gradle.kts",
|
||||||
|
Locator = sourcePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractBlock(string content, string blockName)
|
||||||
|
{
|
||||||
|
var pattern = $@"{blockName}\s*\{{";
|
||||||
|
var match = Regex.Match(content, pattern);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startIndex = match.Index + match.Length;
|
||||||
|
var braceCount = 1;
|
||||||
|
var endIndex = startIndex;
|
||||||
|
|
||||||
|
while (endIndex < content.Length && braceCount > 0)
|
||||||
|
{
|
||||||
|
if (content[endIndex] == '{') braceCount++;
|
||||||
|
else if (content[endIndex] == '}') braceCount--;
|
||||||
|
endIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceCount == 0)
|
||||||
|
{
|
||||||
|
return content[startIndex..(endIndex - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JavaDependencyDeclaration? ParseCoordinate(
|
||||||
|
string coordinate,
|
||||||
|
string configuration,
|
||||||
|
string sourcePath,
|
||||||
|
GradleProperties? properties)
|
||||||
|
{
|
||||||
|
// Handle string interpolation like "$group:$artifact:$version"
|
||||||
|
if (coordinate.Contains('$'))
|
||||||
|
{
|
||||||
|
return null; // Unresolved variable reference
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = coordinate.Split(':');
|
||||||
|
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupId = parts[0];
|
||||||
|
var artifactId = parts[1];
|
||||||
|
var version = parts.Length > 2 ? parts[2] : null;
|
||||||
|
string? classifier = null;
|
||||||
|
|
||||||
|
if (parts.Length > 3)
|
||||||
|
{
|
||||||
|
classifier = parts[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
ArtifactId = artifactId,
|
||||||
|
Version = ResolveVersionProperty(version, properties),
|
||||||
|
Classifier = classifier,
|
||||||
|
Scope = MapConfigurationToScope(configuration),
|
||||||
|
Source = "build.gradle.kts",
|
||||||
|
Locator = sourcePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveVersionProperty(string? version, GradleProperties? properties)
|
||||||
|
{
|
||||||
|
if (version is null || properties is null)
|
||||||
|
{
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle $property syntax in Kotlin
|
||||||
|
if (version.StartsWith('$'))
|
||||||
|
{
|
||||||
|
var propertyName = version.TrimStart('$');
|
||||||
|
return properties.GetProperty(propertyName) ?? version;
|
||||||
|
}
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MapConfigurationToScope(string configuration)
|
||||||
|
{
|
||||||
|
return configuration.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"implementation" or "api" => "compile",
|
||||||
|
"compileonly" => "provided",
|
||||||
|
"runtimeonly" => "runtime",
|
||||||
|
"testimplementation" => "test",
|
||||||
|
"testcompileonly" or "testruntimeonly" => "test",
|
||||||
|
"annotationprocessor" or "kapt" or "ksp" => "compile",
|
||||||
|
_ => "compile"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"id\s*\(\s*""([^""]+)""\s*\)(?:\s*version\s*""([^""]+)"")?", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex PluginIdPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"kotlin\s*\(\s*""([^""]+)""\s*\)(?:\s*version\s*""([^""]+)"")?", RegexOptions.Singleline)]
|
||||||
|
private static partial Regex KotlinPluginPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\s*`?([a-zA-Z-]+)`?\s*$", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex BarePluginPattern();
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses gradle.properties files to extract key-value properties.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class GradlePropertiesParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a gradle.properties file asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<GradleProperties> ParseAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return GradleProperties.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
return Parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses gradle.properties content.
|
||||||
|
/// </summary>
|
||||||
|
public static GradleProperties Parse(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return GradleProperties.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var systemProperties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
using var reader = new StringReader(content);
|
||||||
|
string? line;
|
||||||
|
string? continuationKey = null;
|
||||||
|
var continuationValue = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
// Handle line continuation
|
||||||
|
if (continuationKey is not null)
|
||||||
|
{
|
||||||
|
if (line.EndsWith('\\'))
|
||||||
|
{
|
||||||
|
continuationValue.Append(line[..^1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
continuationValue.Append(line);
|
||||||
|
AddProperty(properties, systemProperties, continuationKey, continuationValue.ToString());
|
||||||
|
continuationKey = null;
|
||||||
|
continuationValue.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim and skip empty lines/comments
|
||||||
|
line = line.Trim();
|
||||||
|
if (string.IsNullOrEmpty(line) || line.StartsWith('#') || line.StartsWith('!'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the key-value separator (= or :)
|
||||||
|
var separatorIndex = FindSeparator(line);
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = line[..separatorIndex].Trim();
|
||||||
|
var value = line[(separatorIndex + 1)..].TrimStart();
|
||||||
|
|
||||||
|
// Handle line continuation
|
||||||
|
if (value.EndsWith('\\'))
|
||||||
|
{
|
||||||
|
continuationKey = key;
|
||||||
|
continuationValue.Append(value[..^1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddProperty(properties, systemProperties, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any remaining continuation
|
||||||
|
if (continuationKey is not null)
|
||||||
|
{
|
||||||
|
AddProperty(properties, systemProperties, continuationKey, continuationValue.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GradleProperties(
|
||||||
|
properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||||
|
systemProperties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindSeparator(string line)
|
||||||
|
{
|
||||||
|
var equalsIndex = line.IndexOf('=');
|
||||||
|
var colonIndex = line.IndexOf(':');
|
||||||
|
|
||||||
|
if (equalsIndex < 0) return colonIndex;
|
||||||
|
if (colonIndex < 0) return equalsIndex;
|
||||||
|
return Math.Min(equalsIndex, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddProperty(
|
||||||
|
Dictionary<string, string> properties,
|
||||||
|
Dictionary<string, string> systemProperties,
|
||||||
|
string key,
|
||||||
|
string value)
|
||||||
|
{
|
||||||
|
// Unescape common escape sequences
|
||||||
|
value = UnescapeValue(value);
|
||||||
|
|
||||||
|
// Check if it's a system property
|
||||||
|
if (key.StartsWith("systemProp.", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var systemKey = key["systemProp.".Length..];
|
||||||
|
systemProperties[systemKey] = value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
properties[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UnescapeValue(string value)
|
||||||
|
{
|
||||||
|
if (!value.Contains('\\'))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.Replace("\\n", "\n")
|
||||||
|
.Replace("\\r", "\r")
|
||||||
|
.Replace("\\t", "\t")
|
||||||
|
.Replace("\\\\", "\\");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents parsed gradle.properties content.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record GradleProperties(
|
||||||
|
ImmutableDictionary<string, string> Properties,
|
||||||
|
ImmutableDictionary<string, string> SystemProperties)
|
||||||
|
{
|
||||||
|
public static readonly GradleProperties Empty = new(
|
||||||
|
ImmutableDictionary<string, string>.Empty,
|
||||||
|
ImmutableDictionary<string, string>.Empty);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a property value, returning null if not found.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetProperty(string key)
|
||||||
|
=> Properties.TryGetValue(key, out var value) ? value : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the project group if defined.
|
||||||
|
/// </summary>
|
||||||
|
public string? Group => GetProperty("group");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the project version if defined.
|
||||||
|
/// </summary>
|
||||||
|
public string? Version => GetProperty("version");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets commonly used version properties.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<KeyValuePair<string, string>> GetVersionProperties()
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in Properties)
|
||||||
|
{
|
||||||
|
if (key.EndsWith("Version", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
key.EndsWith(".version", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return new KeyValuePair<string, string>(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Gradle Version Catalog files (libs.versions.toml).
|
||||||
|
/// </summary>
|
||||||
|
internal static class GradleVersionCatalogParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a version catalog file asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<GradleVersionCatalog> ParseAsync(
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return GradleVersionCatalog.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
return Parse(content, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses version catalog content.
|
||||||
|
/// </summary>
|
||||||
|
public static GradleVersionCatalog Parse(string content, string sourcePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return GradleVersionCatalog.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var document = TomlParser.Parse(content);
|
||||||
|
|
||||||
|
var versions = ParseVersions(document);
|
||||||
|
var libraries = ParseLibraries(document, versions, sourcePath);
|
||||||
|
var plugins = ParsePlugins(document, versions);
|
||||||
|
var bundles = ParseBundles(document);
|
||||||
|
|
||||||
|
return new GradleVersionCatalog(
|
||||||
|
sourcePath,
|
||||||
|
versions.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||||
|
libraries.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||||
|
plugins.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||||
|
bundles.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseVersions(TomlDocument document)
|
||||||
|
{
|
||||||
|
var versions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var versionsTable = document.GetTable("versions");
|
||||||
|
if (versionsTable is null)
|
||||||
|
{
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (key, value) in versionsTable.Entries)
|
||||||
|
{
|
||||||
|
if (value.Kind == TomlValueKind.String)
|
||||||
|
{
|
||||||
|
versions[key] = value.StringValue;
|
||||||
|
}
|
||||||
|
else if (value.Kind == TomlValueKind.InlineTable)
|
||||||
|
{
|
||||||
|
// Handle { strictly = "x.y.z" } or { prefer = "x.y.z" }
|
||||||
|
var strictly = value.GetNestedString("strictly");
|
||||||
|
var prefer = value.GetNestedString("prefer");
|
||||||
|
var require = value.GetNestedString("require");
|
||||||
|
|
||||||
|
versions[key] = strictly ?? prefer ?? require ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, CatalogLibrary> ParseLibraries(
|
||||||
|
TomlDocument document,
|
||||||
|
Dictionary<string, string> versions,
|
||||||
|
string sourcePath)
|
||||||
|
{
|
||||||
|
var libraries = new Dictionary<string, CatalogLibrary>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var librariesTable = document.GetTable("libraries");
|
||||||
|
if (librariesTable is null)
|
||||||
|
{
|
||||||
|
return libraries;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (alias, value) in librariesTable.Entries)
|
||||||
|
{
|
||||||
|
CatalogLibrary? library = null;
|
||||||
|
|
||||||
|
if (value.Kind == TomlValueKind.String)
|
||||||
|
{
|
||||||
|
// Short notation: "group:artifact:version"
|
||||||
|
library = ParseLibraryString(alias, value.StringValue, sourcePath);
|
||||||
|
}
|
||||||
|
else if (value.Kind == TomlValueKind.InlineTable)
|
||||||
|
{
|
||||||
|
// Full notation with module or group/name
|
||||||
|
library = ParseLibraryTable(alias, value, versions, sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library is not null)
|
||||||
|
{
|
||||||
|
libraries[alias] = library;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return libraries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CatalogLibrary? ParseLibraryString(string alias, string value, string sourcePath)
|
||||||
|
{
|
||||||
|
var parts = value.Split(':');
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CatalogLibrary(
|
||||||
|
alias,
|
||||||
|
parts[0],
|
||||||
|
parts[1],
|
||||||
|
parts.Length > 2 ? parts[2] : null,
|
||||||
|
null,
|
||||||
|
sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CatalogLibrary? ParseLibraryTable(
|
||||||
|
string alias,
|
||||||
|
TomlValue value,
|
||||||
|
Dictionary<string, string> versions,
|
||||||
|
string sourcePath)
|
||||||
|
{
|
||||||
|
var module = value.GetNestedString("module");
|
||||||
|
string? groupId = null;
|
||||||
|
string? artifactId = null;
|
||||||
|
string? version = null;
|
||||||
|
string? versionRef = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(module))
|
||||||
|
{
|
||||||
|
// module = "group:artifact"
|
||||||
|
var parts = module.Split(':');
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
groupId = parts[0];
|
||||||
|
artifactId = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// group = "...", name = "..."
|
||||||
|
groupId = value.GetNestedString("group");
|
||||||
|
artifactId = value.GetNestedString("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(groupId) || string.IsNullOrEmpty(artifactId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle version - can be direct or reference
|
||||||
|
version = value.GetNestedString("version");
|
||||||
|
if (string.IsNullOrEmpty(version))
|
||||||
|
{
|
||||||
|
// Check for version.ref
|
||||||
|
var versionValue = value.TableValue?.GetValueOrDefault("version");
|
||||||
|
if (versionValue?.Kind == TomlValueKind.InlineTable)
|
||||||
|
{
|
||||||
|
versionRef = versionValue.GetNestedString("ref");
|
||||||
|
if (!string.IsNullOrEmpty(versionRef) && versions.TryGetValue(versionRef, out var resolvedVersion))
|
||||||
|
{
|
||||||
|
version = resolvedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (versionValue?.Kind == TomlValueKind.String)
|
||||||
|
{
|
||||||
|
version = versionValue.StringValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CatalogLibrary(
|
||||||
|
alias,
|
||||||
|
groupId,
|
||||||
|
artifactId,
|
||||||
|
version,
|
||||||
|
versionRef,
|
||||||
|
sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, CatalogPlugin> ParsePlugins(
|
||||||
|
TomlDocument document,
|
||||||
|
Dictionary<string, string> versions)
|
||||||
|
{
|
||||||
|
var plugins = new Dictionary<string, CatalogPlugin>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var pluginsTable = document.GetTable("plugins");
|
||||||
|
if (pluginsTable is null)
|
||||||
|
{
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (alias, value) in pluginsTable.Entries)
|
||||||
|
{
|
||||||
|
if (value.Kind == TomlValueKind.String)
|
||||||
|
{
|
||||||
|
// Short notation: "plugin.id:version"
|
||||||
|
var parts = value.StringValue.Split(':');
|
||||||
|
plugins[alias] = new CatalogPlugin(
|
||||||
|
alias,
|
||||||
|
parts[0],
|
||||||
|
parts.Length > 1 ? parts[1] : null,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
else if (value.Kind == TomlValueKind.InlineTable)
|
||||||
|
{
|
||||||
|
var id = value.GetNestedString("id");
|
||||||
|
var version = value.GetNestedString("version");
|
||||||
|
string? versionRef = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(version))
|
||||||
|
{
|
||||||
|
var versionValue = value.TableValue?.GetValueOrDefault("version");
|
||||||
|
if (versionValue?.Kind == TomlValueKind.InlineTable)
|
||||||
|
{
|
||||||
|
versionRef = versionValue.GetNestedString("ref");
|
||||||
|
if (!string.IsNullOrEmpty(versionRef) && versions.TryGetValue(versionRef, out var resolved))
|
||||||
|
{
|
||||||
|
version = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(id))
|
||||||
|
{
|
||||||
|
plugins[alias] = new CatalogPlugin(alias, id, version, versionRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, CatalogBundle> ParseBundles(TomlDocument document)
|
||||||
|
{
|
||||||
|
var bundles = new Dictionary<string, CatalogBundle>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var bundlesTable = document.GetTable("bundles");
|
||||||
|
if (bundlesTable is null)
|
||||||
|
{
|
||||||
|
return bundles;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (alias, value) in bundlesTable.Entries)
|
||||||
|
{
|
||||||
|
if (value.Kind == TomlValueKind.Array)
|
||||||
|
{
|
||||||
|
var libraryRefs = value.GetArrayItems()
|
||||||
|
.Where(v => v.Kind == TomlValueKind.String)
|
||||||
|
.Select(v => v.StringValue)
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
bundles[alias] = new CatalogBundle(alias, libraryRefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parsed Gradle Version Catalog.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record GradleVersionCatalog(
|
||||||
|
string SourcePath,
|
||||||
|
FrozenDictionary<string, string> Versions,
|
||||||
|
FrozenDictionary<string, CatalogLibrary> Libraries,
|
||||||
|
FrozenDictionary<string, CatalogPlugin> Plugins,
|
||||||
|
FrozenDictionary<string, CatalogBundle> Bundles)
|
||||||
|
{
|
||||||
|
public static readonly GradleVersionCatalog Empty = new(
|
||||||
|
string.Empty,
|
||||||
|
FrozenDictionary<string, string>.Empty,
|
||||||
|
FrozenDictionary<string, CatalogLibrary>.Empty,
|
||||||
|
FrozenDictionary<string, CatalogPlugin>.Empty,
|
||||||
|
FrozenDictionary<string, CatalogBundle>.Empty);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the catalog has any libraries.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasLibraries => Libraries.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a library by its alias.
|
||||||
|
/// </summary>
|
||||||
|
public CatalogLibrary? GetLibrary(string alias)
|
||||||
|
{
|
||||||
|
// Handle dotted notation: libs.some.library -> some-library or some.library
|
||||||
|
var normalizedAlias = alias
|
||||||
|
.Replace("libs.", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace('.', '-');
|
||||||
|
|
||||||
|
if (Libraries.TryGetValue(normalizedAlias, out var library))
|
||||||
|
{
|
||||||
|
return library;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with dots
|
||||||
|
normalizedAlias = alias.Replace("libs.", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return Libraries.TryGetValue(normalizedAlias, out library) ? library : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts all libraries to dependency declarations.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<JavaDependencyDeclaration> ToDependencies()
|
||||||
|
{
|
||||||
|
foreach (var library in Libraries.Values)
|
||||||
|
{
|
||||||
|
yield return new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = library.GroupId,
|
||||||
|
ArtifactId = library.ArtifactId,
|
||||||
|
Version = library.Version,
|
||||||
|
VersionSource = library.VersionRef is not null
|
||||||
|
? JavaVersionSource.VersionCatalog
|
||||||
|
: JavaVersionSource.Direct,
|
||||||
|
VersionProperty = library.VersionRef,
|
||||||
|
Source = "libs.versions.toml",
|
||||||
|
Locator = SourcePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a library entry in the version catalog.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record CatalogLibrary(
|
||||||
|
string Alias,
|
||||||
|
string GroupId,
|
||||||
|
string ArtifactId,
|
||||||
|
string? Version,
|
||||||
|
string? VersionRef,
|
||||||
|
string SourcePath)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate.
|
||||||
|
/// </summary>
|
||||||
|
public string Gav => Version is not null
|
||||||
|
? $"{GroupId}:{ArtifactId}:{Version}"
|
||||||
|
: $"{GroupId}:{ArtifactId}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to a dependency declaration.
|
||||||
|
/// </summary>
|
||||||
|
public JavaDependencyDeclaration ToDependency(string? scope = null) => new()
|
||||||
|
{
|
||||||
|
GroupId = GroupId,
|
||||||
|
ArtifactId = ArtifactId,
|
||||||
|
Version = Version,
|
||||||
|
Scope = scope,
|
||||||
|
VersionSource = VersionRef is not null
|
||||||
|
? JavaVersionSource.VersionCatalog
|
||||||
|
: JavaVersionSource.Direct,
|
||||||
|
VersionProperty = VersionRef,
|
||||||
|
Source = "libs.versions.toml",
|
||||||
|
Locator = SourcePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a plugin entry in the version catalog.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record CatalogPlugin(
|
||||||
|
string Alias,
|
||||||
|
string Id,
|
||||||
|
string? Version,
|
||||||
|
string? VersionRef);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a bundle (group of libraries) in the version catalog.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record CatalogBundle(
|
||||||
|
string Alias,
|
||||||
|
ImmutableArray<string> LibraryRefs);
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal TOML parser for parsing Gradle version catalog files.
|
||||||
|
/// Supports the subset of TOML needed for libs.versions.toml parsing.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class TomlParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a TOML file.
|
||||||
|
/// </summary>
|
||||||
|
public static TomlDocument Parse(string content)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return TomlDocument.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables = new Dictionary<string, TomlTable>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var rootTable = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var currentTable = rootTable;
|
||||||
|
var currentTableName = string.Empty;
|
||||||
|
|
||||||
|
using var reader = new StringReader(content);
|
||||||
|
string? line;
|
||||||
|
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
line = line.Trim();
|
||||||
|
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table header: [tableName]
|
||||||
|
var tableMatch = TableHeaderPattern().Match(line);
|
||||||
|
if (tableMatch.Success)
|
||||||
|
{
|
||||||
|
// Save previous table
|
||||||
|
if (!string.IsNullOrEmpty(currentTableName))
|
||||||
|
{
|
||||||
|
tables[currentTableName] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
else if (currentTable.Count > 0)
|
||||||
|
{
|
||||||
|
tables[string.Empty] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTableName = tableMatch.Groups[1].Value;
|
||||||
|
currentTable = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key-value pair: key = value
|
||||||
|
var kvMatch = KeyValuePattern().Match(line);
|
||||||
|
if (kvMatch.Success)
|
||||||
|
{
|
||||||
|
var key = kvMatch.Groups[1].Value.Trim().Trim('"');
|
||||||
|
var valueStr = kvMatch.Groups[2].Value.Trim();
|
||||||
|
var value = ParseValue(valueStr);
|
||||||
|
currentTable[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the last table
|
||||||
|
if (!string.IsNullOrEmpty(currentTableName))
|
||||||
|
{
|
||||||
|
tables[currentTableName] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
else if (currentTable.Count > 0)
|
||||||
|
{
|
||||||
|
tables[string.Empty] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TomlDocument(tables.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TomlValue ParseValue(string valueStr)
|
||||||
|
{
|
||||||
|
// Remove trailing comment
|
||||||
|
var commentIndex = valueStr.IndexOf('#');
|
||||||
|
if (commentIndex > 0)
|
||||||
|
{
|
||||||
|
// But not inside a string
|
||||||
|
var inString = false;
|
||||||
|
for (int i = 0; i < commentIndex; i++)
|
||||||
|
{
|
||||||
|
if (valueStr[i] == '"' && (i == 0 || valueStr[i - 1] != '\\'))
|
||||||
|
{
|
||||||
|
inString = !inString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!inString)
|
||||||
|
{
|
||||||
|
valueStr = valueStr[..commentIndex].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String value: "value" or 'value'
|
||||||
|
if ((valueStr.StartsWith('"') && valueStr.EndsWith('"')) ||
|
||||||
|
(valueStr.StartsWith('\'') && valueStr.EndsWith('\'')))
|
||||||
|
{
|
||||||
|
return new TomlValue(TomlValueKind.String, valueStr[1..^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline table: { key = "value", ... }
|
||||||
|
if (valueStr.StartsWith('{') && valueStr.EndsWith('}'))
|
||||||
|
{
|
||||||
|
var tableContent = valueStr[1..^1];
|
||||||
|
var inlineTable = ParseInlineTable(tableContent);
|
||||||
|
return new TomlValue(TomlValueKind.InlineTable, valueStr, inlineTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array: [ ... ]
|
||||||
|
if (valueStr.StartsWith('[') && valueStr.EndsWith(']'))
|
||||||
|
{
|
||||||
|
var arrayContent = valueStr[1..^1];
|
||||||
|
var items = ParseArray(arrayContent);
|
||||||
|
return new TomlValue(TomlValueKind.Array, valueStr, ArrayItems: items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
if (valueStr.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new TomlValue(TomlValueKind.Boolean, "true");
|
||||||
|
}
|
||||||
|
if (valueStr.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new TomlValue(TomlValueKind.Boolean, "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number (integer or float)
|
||||||
|
if (double.TryParse(valueStr, out _))
|
||||||
|
{
|
||||||
|
return new TomlValue(TomlValueKind.Number, valueStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare string (unquoted - technically not valid TOML but seen in some files)
|
||||||
|
return new TomlValue(TomlValueKind.String, valueStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FrozenDictionary<string, TomlValue> ParseInlineTable(string content)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Split by comma, handling nested structures
|
||||||
|
var pairs = SplitByComma(content);
|
||||||
|
|
||||||
|
foreach (var pair in pairs)
|
||||||
|
{
|
||||||
|
var eqIndex = pair.IndexOf('=');
|
||||||
|
if (eqIndex > 0)
|
||||||
|
{
|
||||||
|
var key = pair[..eqIndex].Trim().Trim('"');
|
||||||
|
var valueStr = pair[(eqIndex + 1)..].Trim();
|
||||||
|
result[key] = ParseValue(valueStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<TomlValue> ParseArray(string content)
|
||||||
|
{
|
||||||
|
var items = new List<TomlValue>();
|
||||||
|
var elements = SplitByComma(content);
|
||||||
|
|
||||||
|
foreach (var element in elements)
|
||||||
|
{
|
||||||
|
var trimmed = element.Trim();
|
||||||
|
if (!string.IsNullOrEmpty(trimmed))
|
||||||
|
{
|
||||||
|
items.Add(ParseValue(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. items];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SplitByComma(string content)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var current = new System.Text.StringBuilder();
|
||||||
|
var depth = 0;
|
||||||
|
var inString = false;
|
||||||
|
|
||||||
|
foreach (var c in content)
|
||||||
|
{
|
||||||
|
if (c == '"' && (current.Length == 0 || current[^1] != '\\'))
|
||||||
|
{
|
||||||
|
inString = !inString;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inString)
|
||||||
|
{
|
||||||
|
if (c == '{' || c == '[') depth++;
|
||||||
|
else if (c == '}' || c == ']') depth--;
|
||||||
|
else if (c == ',' && depth == 0)
|
||||||
|
{
|
||||||
|
result.Add(current.ToString());
|
||||||
|
current.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Length > 0)
|
||||||
|
{
|
||||||
|
result.Add(current.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\[([^\]]+)\]$")]
|
||||||
|
private static partial Regex TableHeaderPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^([^=]+)=(.+)$")]
|
||||||
|
private static partial Regex KeyValuePattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parsed TOML document.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record TomlDocument(FrozenDictionary<string, TomlTable> Tables)
|
||||||
|
{
|
||||||
|
public static readonly TomlDocument Empty = new(FrozenDictionary<string, TomlTable>.Empty);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a table by name.
|
||||||
|
/// </summary>
|
||||||
|
public TomlTable? GetTable(string name)
|
||||||
|
=> Tables.TryGetValue(name, out var table) ? table : null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a table exists.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasTable(string name)
|
||||||
|
=> Tables.ContainsKey(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a TOML table (section).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record TomlTable(FrozenDictionary<string, TomlValue> Values)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a string value from the table.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetString(string key)
|
||||||
|
=> Values.TryGetValue(key, out var value) && value.Kind == TomlValueKind.String
|
||||||
|
? value.StringValue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an inline table value.
|
||||||
|
/// </summary>
|
||||||
|
public FrozenDictionary<string, TomlValue>? GetInlineTable(string key)
|
||||||
|
=> Values.TryGetValue(key, out var value) && value.Kind == TomlValueKind.InlineTable
|
||||||
|
? value.TableValue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all entries in this table.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<KeyValuePair<string, TomlValue>> Entries => Values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a TOML value.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record TomlValue(
|
||||||
|
TomlValueKind Kind,
|
||||||
|
string StringValue,
|
||||||
|
FrozenDictionary<string, TomlValue>? TableValue = null,
|
||||||
|
ImmutableArray<TomlValue>? ArrayItems = null)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a nested value from an inline table.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetNestedString(string key)
|
||||||
|
{
|
||||||
|
if (Kind != TomlValueKind.InlineTable || TableValue is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TableValue.TryGetValue(key, out var value) ? value.StringValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the array items if this is an array value.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<TomlValue> GetArrayItems()
|
||||||
|
=> ArrayItems ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kind of TOML value.
|
||||||
|
/// </summary>
|
||||||
|
internal enum TomlValueKind
|
||||||
|
{
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Boolean,
|
||||||
|
Array,
|
||||||
|
InlineTable
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
using System.Collections.Frozen;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.License;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes license names and URLs to SPDX identifiers.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SpdxLicenseNormalizer
|
||||||
|
{
|
||||||
|
private static readonly Lazy<SpdxLicenseNormalizer> LazyInstance = new(() => new SpdxLicenseNormalizer());
|
||||||
|
|
||||||
|
private readonly FrozenDictionary<string, SpdxLicenseMapping> _nameIndex;
|
||||||
|
private readonly FrozenDictionary<string, SpdxLicenseMapping> _urlIndex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the singleton instance.
|
||||||
|
/// </summary>
|
||||||
|
public static SpdxLicenseNormalizer Instance => LazyInstance.Value;
|
||||||
|
|
||||||
|
private SpdxLicenseNormalizer()
|
||||||
|
{
|
||||||
|
var mappings = LoadMappings();
|
||||||
|
|
||||||
|
var nameDict = new Dictionary<string, SpdxLicenseMapping>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var urlDict = new Dictionary<string, SpdxLicenseMapping>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var mapping in mappings)
|
||||||
|
{
|
||||||
|
// Index by normalized name
|
||||||
|
foreach (var name in mapping.Names)
|
||||||
|
{
|
||||||
|
var normalizedName = NormalizeName(name);
|
||||||
|
nameDict.TryAdd(normalizedName, mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index by URL
|
||||||
|
foreach (var url in mapping.Urls)
|
||||||
|
{
|
||||||
|
var normalizedUrl = NormalizeUrl(url);
|
||||||
|
urlDict.TryAdd(normalizedUrl, mapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_nameIndex = nameDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
|
_urlIndex = urlDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a license name and/or URL to an SPDX identifier.
|
||||||
|
/// </summary>
|
||||||
|
public JavaLicenseInfo Normalize(string? name, string? url)
|
||||||
|
{
|
||||||
|
var result = new JavaLicenseInfo
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Url = url
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try URL first (higher confidence)
|
||||||
|
if (!string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
var normalizedUrl = NormalizeUrl(url);
|
||||||
|
if (_urlIndex.TryGetValue(normalizedUrl, out var urlMapping))
|
||||||
|
{
|
||||||
|
return result with
|
||||||
|
{
|
||||||
|
SpdxId = urlMapping.SpdxId,
|
||||||
|
SpdxConfidence = SpdxConfidence.High
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try name
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
var normalizedName = NormalizeName(name);
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (_nameIndex.TryGetValue(normalizedName, out var nameMapping))
|
||||||
|
{
|
||||||
|
return result with
|
||||||
|
{
|
||||||
|
SpdxId = nameMapping.SpdxId,
|
||||||
|
SpdxConfidence = SpdxConfidence.High
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match
|
||||||
|
var fuzzyMatch = TryFuzzyMatch(normalizedName);
|
||||||
|
if (fuzzyMatch is not null)
|
||||||
|
{
|
||||||
|
return result with
|
||||||
|
{
|
||||||
|
SpdxId = fuzzyMatch.SpdxId,
|
||||||
|
SpdxConfidence = SpdxConfidence.Medium
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeName(string name)
|
||||||
|
{
|
||||||
|
// Remove common noise words and normalize whitespace
|
||||||
|
var normalized = name.ToLowerInvariant()
|
||||||
|
.Replace("the", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("license", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("licence", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("version", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace(",", "")
|
||||||
|
.Replace("(", "")
|
||||||
|
.Replace(")", "");
|
||||||
|
|
||||||
|
return WhitespacePattern().Replace(normalized, " ").Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeUrl(string url)
|
||||||
|
{
|
||||||
|
// Normalize URL for comparison
|
||||||
|
var normalized = url.ToLowerInvariant()
|
||||||
|
.Replace("https://", "")
|
||||||
|
.Replace("http://", "")
|
||||||
|
.Replace("www.", "")
|
||||||
|
.TrimEnd('/');
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpdxLicenseMapping? TryFuzzyMatch(string normalizedName)
|
||||||
|
{
|
||||||
|
// Check for common patterns
|
||||||
|
if (normalizedName.Contains("apache") && normalizedName.Contains("2"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("apache 2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("mit"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("mit");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("bsd") && normalizedName.Contains("3"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("bsd 3 clause");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("bsd") && normalizedName.Contains("2"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("bsd 2 clause");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("gpl") && normalizedName.Contains("3"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("gpl 3.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("gpl") && normalizedName.Contains("2"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("gpl 2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("lgpl") && normalizedName.Contains("2.1"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("lgpl 2.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("lgpl") && normalizedName.Contains("3"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("lgpl 3.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("mpl") && normalizedName.Contains("2"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("mpl 2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("cddl"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("cddl 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("epl") && normalizedName.Contains("2"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("epl 2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedName.Contains("epl") && normalizedName.Contains("1"))
|
||||||
|
{
|
||||||
|
return _nameIndex.GetValueOrDefault("epl 1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<SpdxLicenseMapping> LoadMappings()
|
||||||
|
{
|
||||||
|
// High-confidence SPDX mappings for common licenses
|
||||||
|
// This list focuses on licenses commonly found in Java/Maven projects
|
||||||
|
return
|
||||||
|
[
|
||||||
|
// Apache
|
||||||
|
new SpdxLicenseMapping("Apache-2.0",
|
||||||
|
["Apache License 2.0", "Apache License, Version 2.0", "Apache 2.0", "Apache-2.0", "ASL 2.0", "AL 2.0"],
|
||||||
|
["apache.org/licenses/LICENSE-2.0", "opensource.org/licenses/Apache-2.0"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("Apache-1.1",
|
||||||
|
["Apache License 1.1", "Apache Software License 1.1"],
|
||||||
|
["apache.org/licenses/LICENSE-1.1"]),
|
||||||
|
|
||||||
|
// MIT
|
||||||
|
new SpdxLicenseMapping("MIT",
|
||||||
|
["MIT License", "MIT", "The MIT License", "Expat License"],
|
||||||
|
["opensource.org/licenses/MIT", "mit-license.org"]),
|
||||||
|
|
||||||
|
// BSD
|
||||||
|
new SpdxLicenseMapping("BSD-2-Clause",
|
||||||
|
["BSD 2-Clause License", "BSD-2-Clause", "Simplified BSD License", "FreeBSD License"],
|
||||||
|
["opensource.org/licenses/BSD-2-Clause"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("BSD-3-Clause",
|
||||||
|
["BSD 3-Clause License", "BSD-3-Clause", "New BSD License", "Modified BSD License"],
|
||||||
|
["opensource.org/licenses/BSD-3-Clause"]),
|
||||||
|
|
||||||
|
// GPL
|
||||||
|
new SpdxLicenseMapping("GPL-2.0-only",
|
||||||
|
["GNU General Public License v2.0", "GPL 2.0", "GPL-2.0", "GPLv2"],
|
||||||
|
["gnu.org/licenses/old-licenses/gpl-2.0", "opensource.org/licenses/GPL-2.0"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("GPL-2.0-or-later",
|
||||||
|
["GNU General Public License v2.0 or later", "GPL 2.0+", "GPL-2.0+", "GPLv2+"],
|
||||||
|
[]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("GPL-3.0-only",
|
||||||
|
["GNU General Public License v3.0", "GPL 3.0", "GPL-3.0", "GPLv3"],
|
||||||
|
["gnu.org/licenses/gpl-3.0", "opensource.org/licenses/GPL-3.0"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("GPL-3.0-or-later",
|
||||||
|
["GNU General Public License v3.0 or later", "GPL 3.0+", "GPL-3.0+", "GPLv3+"],
|
||||||
|
[]),
|
||||||
|
|
||||||
|
// LGPL
|
||||||
|
new SpdxLicenseMapping("LGPL-2.1-only",
|
||||||
|
["GNU Lesser General Public License v2.1", "LGPL 2.1", "LGPL-2.1", "LGPLv2.1"],
|
||||||
|
["gnu.org/licenses/old-licenses/lgpl-2.1", "opensource.org/licenses/LGPL-2.1"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("LGPL-3.0-only",
|
||||||
|
["GNU Lesser General Public License v3.0", "LGPL 3.0", "LGPL-3.0", "LGPLv3"],
|
||||||
|
["gnu.org/licenses/lgpl-3.0", "opensource.org/licenses/LGPL-3.0"]),
|
||||||
|
|
||||||
|
// MPL
|
||||||
|
new SpdxLicenseMapping("MPL-2.0",
|
||||||
|
["Mozilla Public License 2.0", "MPL 2.0", "MPL-2.0"],
|
||||||
|
["mozilla.org/MPL/2.0", "opensource.org/licenses/MPL-2.0"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("MPL-1.1",
|
||||||
|
["Mozilla Public License 1.1", "MPL 1.1", "MPL-1.1"],
|
||||||
|
["mozilla.org/MPL/1.1"]),
|
||||||
|
|
||||||
|
// Eclipse
|
||||||
|
new SpdxLicenseMapping("EPL-1.0",
|
||||||
|
["Eclipse Public License 1.0", "EPL 1.0", "EPL-1.0"],
|
||||||
|
["eclipse.org/legal/epl-v10", "opensource.org/licenses/EPL-1.0"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("EPL-2.0",
|
||||||
|
["Eclipse Public License 2.0", "EPL 2.0", "EPL-2.0"],
|
||||||
|
["eclipse.org/legal/epl-2.0", "opensource.org/licenses/EPL-2.0"]),
|
||||||
|
|
||||||
|
// CDDL
|
||||||
|
new SpdxLicenseMapping("CDDL-1.0",
|
||||||
|
["Common Development and Distribution License 1.0", "CDDL 1.0", "CDDL-1.0"],
|
||||||
|
["opensource.org/licenses/CDDL-1.0"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("CDDL-1.1",
|
||||||
|
["Common Development and Distribution License 1.1", "CDDL 1.1", "CDDL-1.1"],
|
||||||
|
["glassfish.dev.java.net/public/CDDL+GPL_1_1"]),
|
||||||
|
|
||||||
|
// Creative Commons
|
||||||
|
new SpdxLicenseMapping("CC0-1.0",
|
||||||
|
["CC0 1.0 Universal", "CC0", "Public Domain"],
|
||||||
|
["creativecommons.org/publicdomain/zero/1.0"]),
|
||||||
|
|
||||||
|
new SpdxLicenseMapping("CC-BY-4.0",
|
||||||
|
["Creative Commons Attribution 4.0", "CC BY 4.0"],
|
||||||
|
["creativecommons.org/licenses/by/4.0"]),
|
||||||
|
|
||||||
|
// Unlicense
|
||||||
|
new SpdxLicenseMapping("Unlicense",
|
||||||
|
["The Unlicense", "Unlicense"],
|
||||||
|
["unlicense.org"]),
|
||||||
|
|
||||||
|
// ISC
|
||||||
|
new SpdxLicenseMapping("ISC",
|
||||||
|
["ISC License", "ISC"],
|
||||||
|
["opensource.org/licenses/ISC"]),
|
||||||
|
|
||||||
|
// Zlib
|
||||||
|
new SpdxLicenseMapping("Zlib",
|
||||||
|
["zlib License", "zlib/libpng License"],
|
||||||
|
["opensource.org/licenses/Zlib"]),
|
||||||
|
|
||||||
|
// WTFPL
|
||||||
|
new SpdxLicenseMapping("WTFPL",
|
||||||
|
["Do What The F*ck You Want To Public License", "WTFPL"],
|
||||||
|
["wtfpl.net"]),
|
||||||
|
|
||||||
|
// BSL (Business Source License)
|
||||||
|
new SpdxLicenseMapping("BSL-1.0",
|
||||||
|
["Boost Software License 1.0", "BSL-1.0", "Boost License"],
|
||||||
|
["boost.org/LICENSE_1_0.txt", "opensource.org/licenses/BSL-1.0"]),
|
||||||
|
|
||||||
|
// JSON License
|
||||||
|
new SpdxLicenseMapping("JSON",
|
||||||
|
["The JSON License", "JSON License"],
|
||||||
|
["json.org/license"]),
|
||||||
|
|
||||||
|
// AGPL
|
||||||
|
new SpdxLicenseMapping("AGPL-3.0-only",
|
||||||
|
["GNU Affero General Public License v3.0", "AGPL 3.0", "AGPL-3.0", "AGPLv3"],
|
||||||
|
["gnu.org/licenses/agpl-3.0", "opensource.org/licenses/AGPL-3.0"]),
|
||||||
|
|
||||||
|
// PostgreSQL
|
||||||
|
new SpdxLicenseMapping("PostgreSQL",
|
||||||
|
["PostgreSQL License", "The PostgreSQL License"],
|
||||||
|
["opensource.org/licenses/PostgreSQL"]),
|
||||||
|
|
||||||
|
// Unicode
|
||||||
|
new SpdxLicenseMapping("Unicode-DFS-2016",
|
||||||
|
["Unicode License Agreement", "Unicode DFS 2016"],
|
||||||
|
["unicode.org/copyright"]),
|
||||||
|
|
||||||
|
// W3C
|
||||||
|
new SpdxLicenseMapping("W3C",
|
||||||
|
["W3C Software Notice and License", "W3C License"],
|
||||||
|
["w3.org/Consortium/Legal/2015/copyright-software-and-document"])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\s+")]
|
||||||
|
private static partial Regex WhitespacePattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a mapping from license names/URLs to an SPDX identifier.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record SpdxLicenseMapping(
|
||||||
|
string SpdxId,
|
||||||
|
IReadOnlyList<string> Names,
|
||||||
|
IReadOnlyList<string> Urls);
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports Maven BOM (Bill of Materials) POMs to extract managed dependency versions.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MavenBomImporter
|
||||||
|
{
|
||||||
|
private const int MaxImportDepth = 5;
|
||||||
|
|
||||||
|
private readonly string _rootPath;
|
||||||
|
private readonly MavenLocalRepository _localRepository;
|
||||||
|
private readonly Dictionary<string, ImportedBom?> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly HashSet<string> _importing = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public MavenBomImporter(string rootPath)
|
||||||
|
{
|
||||||
|
_rootPath = rootPath;
|
||||||
|
_localRepository = new MavenLocalRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports a BOM and returns its managed dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ImportedBom?> ImportAsync(
|
||||||
|
string groupId,
|
||||||
|
string artifactId,
|
||||||
|
string version,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await ImportInternalAsync(groupId, artifactId, version, 0, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ImportedBom?> ImportInternalAsync(
|
||||||
|
string groupId,
|
||||||
|
string artifactId,
|
||||||
|
string version,
|
||||||
|
int depth,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (depth >= MaxImportDepth)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = $"{groupId}:{artifactId}:{version}".ToLowerInvariant();
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (_cache.TryGetValue(key, out var cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cycle
|
||||||
|
if (!_importing.Add(key))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bomPom = await TryLoadBomAsync(groupId, artifactId, version, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (bomPom is null)
|
||||||
|
{
|
||||||
|
_cache[key] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var managedDependencies = new List<JavaDependencyDeclaration>();
|
||||||
|
var nestedBoms = new List<ImportedBom>();
|
||||||
|
|
||||||
|
// Process dependency management
|
||||||
|
foreach (var dep in bomPom.DependencyManagement)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// Check if this is a nested BOM import
|
||||||
|
if (dep.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
|
||||||
|
dep.Type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
var nestedBom = await ImportInternalAsync(
|
||||||
|
dep.GroupId,
|
||||||
|
dep.ArtifactId,
|
||||||
|
dep.Version ?? string.Empty,
|
||||||
|
depth + 1,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (nestedBom is not null)
|
||||||
|
{
|
||||||
|
nestedBoms.Add(nestedBom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
managedDependencies.Add(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge nested BOM dependencies (earlier BOMs have lower priority)
|
||||||
|
var allManaged = new Dictionary<string, JavaDependencyDeclaration>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var nestedBom in nestedBoms)
|
||||||
|
{
|
||||||
|
foreach (var dep in nestedBom.ManagedDependencies)
|
||||||
|
{
|
||||||
|
var depKey = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||||
|
allManaged.TryAdd(depKey, dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current BOM's declarations override nested
|
||||||
|
foreach (var dep in managedDependencies)
|
||||||
|
{
|
||||||
|
var depKey = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||||
|
allManaged[depKey] = dep;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new ImportedBom(
|
||||||
|
groupId,
|
||||||
|
artifactId,
|
||||||
|
version,
|
||||||
|
bomPom.SourcePath,
|
||||||
|
bomPom.Properties,
|
||||||
|
[.. allManaged.Values.OrderBy(d => d.Gav, StringComparer.Ordinal)],
|
||||||
|
[.. nestedBoms]);
|
||||||
|
|
||||||
|
_cache[key] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_importing.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MavenPom?> TryLoadBomAsync(
|
||||||
|
string groupId,
|
||||||
|
string artifactId,
|
||||||
|
string version,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Try local Maven repository first
|
||||||
|
var localPath = _localRepository.GetPomPath(groupId, artifactId, version);
|
||||||
|
if (localPath is not null && File.Exists(localPath))
|
||||||
|
{
|
||||||
|
return await MavenPomParser.ParseAsync(localPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find in workspace
|
||||||
|
var workspacePath = FindInWorkspace(groupId, artifactId);
|
||||||
|
if (workspacePath is not null)
|
||||||
|
{
|
||||||
|
return await MavenPomParser.ParseAsync(workspacePath, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? FindInWorkspace(string groupId, string artifactId)
|
||||||
|
{
|
||||||
|
// Search for pom.xml files that match the GAV
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var pomPath in Directory.EnumerateFiles(_rootPath, "pom.xml", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
// Quick check by reading first few KB
|
||||||
|
var content = File.ReadAllText(pomPath);
|
||||||
|
if (content.Contains($"<groupId>{groupId}</groupId>", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
content.Contains($"<artifactId>{artifactId}</artifactId>", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return pomPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore file system errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an imported BOM with its managed dependencies.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ImportedBom(
|
||||||
|
string GroupId,
|
||||||
|
string ArtifactId,
|
||||||
|
string Version,
|
||||||
|
string SourcePath,
|
||||||
|
ImmutableDictionary<string, string> Properties,
|
||||||
|
ImmutableArray<JavaDependencyDeclaration> ManagedDependencies,
|
||||||
|
ImmutableArray<ImportedBom> NestedBoms)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate.
|
||||||
|
/// </summary>
|
||||||
|
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a managed version for an artifact.
|
||||||
|
/// </summary>
|
||||||
|
public string? GetManagedVersion(string groupId, string artifactId)
|
||||||
|
{
|
||||||
|
var key = $"{groupId}:{artifactId}".ToLowerInvariant();
|
||||||
|
return ManagedDependencies
|
||||||
|
.FirstOrDefault(d => $"{d.GroupId}:{d.ArtifactId}".Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?.Version;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an effective POM by merging the parent chain and resolving all properties.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MavenEffectivePomBuilder
|
||||||
|
{
|
||||||
|
private readonly MavenParentResolver _parentResolver;
|
||||||
|
private readonly MavenBomImporter _bomImporter;
|
||||||
|
|
||||||
|
public MavenEffectivePomBuilder(string rootPath)
|
||||||
|
{
|
||||||
|
_parentResolver = new MavenParentResolver(rootPath);
|
||||||
|
_bomImporter = new MavenBomImporter(rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the effective POM with fully resolved dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MavenEffectivePomResult> BuildAsync(
|
||||||
|
MavenPom pom,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Step 1: Resolve parent chain
|
||||||
|
var effectivePom = await _parentResolver.ResolveAsync(pom, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Step 2: Import BOMs from dependency management
|
||||||
|
var bomImports = await ImportBomsAsync(pom, effectivePom.EffectiveProperties, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Step 3: Build merged dependency management index
|
||||||
|
var managedVersions = BuildManagedVersionsIndex(effectivePom, bomImports);
|
||||||
|
|
||||||
|
// Step 4: Create property resolver with all properties
|
||||||
|
var allProperties = MergeProperties(effectivePom.EffectiveProperties, bomImports);
|
||||||
|
var resolver = new JavaPropertyResolver(allProperties);
|
||||||
|
|
||||||
|
// Step 5: Resolve all dependencies
|
||||||
|
var resolvedDependencies = ResolveDependencies(
|
||||||
|
pom.Dependencies,
|
||||||
|
managedVersions,
|
||||||
|
resolver);
|
||||||
|
|
||||||
|
return new MavenEffectivePomResult(
|
||||||
|
pom,
|
||||||
|
effectivePom.ParentChain,
|
||||||
|
allProperties,
|
||||||
|
managedVersions.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||||
|
resolvedDependencies,
|
||||||
|
effectivePom.AllLicenses,
|
||||||
|
bomImports,
|
||||||
|
effectivePom.UnresolvedParents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ImmutableArray<ImportedBom>> ImportBomsAsync(
|
||||||
|
MavenPom pom,
|
||||||
|
ImmutableDictionary<string, string> properties,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var bomImports = pom.GetBomImports().ToList();
|
||||||
|
if (bomImports.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var imported = new List<ImportedBom>();
|
||||||
|
|
||||||
|
foreach (var bomDep in bomImports)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
// Resolve version if it contains properties
|
||||||
|
var version = bomDep.Version;
|
||||||
|
if (version?.Contains("${", StringComparison.Ordinal) == true)
|
||||||
|
{
|
||||||
|
var result = resolver.Resolve(version);
|
||||||
|
version = result.ResolvedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bom = await _bomImporter.ImportAsync(
|
||||||
|
bomDep.GroupId,
|
||||||
|
bomDep.ArtifactId,
|
||||||
|
version,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (bom is not null)
|
||||||
|
{
|
||||||
|
imported.Add(bom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. imported];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, ManagedDependency> BuildManagedVersionsIndex(
|
||||||
|
MavenEffectivePom effectivePom,
|
||||||
|
ImmutableArray<ImportedBom> bomImports)
|
||||||
|
{
|
||||||
|
var index = new Dictionary<string, ManagedDependency>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Start with BOM imports (lower priority)
|
||||||
|
foreach (var bom in bomImports)
|
||||||
|
{
|
||||||
|
foreach (var managed in bom.ManagedDependencies)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||||
|
{
|
||||||
|
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||||
|
index.TryAdd(key, new ManagedDependency(
|
||||||
|
managed.Version,
|
||||||
|
$"bom:{bom.GroupId}:{bom.ArtifactId}:{bom.Version}",
|
||||||
|
managed.Scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then parent chain (higher priority, child overrides parent)
|
||||||
|
for (int i = effectivePom.ParentChain.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var parentPom = effectivePom.ParentChain[i];
|
||||||
|
foreach (var managed in parentPom.DependencyManagement)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||||
|
{
|
||||||
|
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||||
|
index[key] = new ManagedDependency(
|
||||||
|
managed.Version,
|
||||||
|
i == 0 ? "dependencyManagement" : $"parent:{parentPom.Gav}",
|
||||||
|
managed.Scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally current POM's dependency management (highest priority)
|
||||||
|
foreach (var managed in effectivePom.OriginalPom.DependencyManagement)
|
||||||
|
{
|
||||||
|
// Skip BOM imports themselves
|
||||||
|
if (managed.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||||
|
{
|
||||||
|
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||||
|
index[key] = new ManagedDependency(
|
||||||
|
managed.Version,
|
||||||
|
"dependencyManagement",
|
||||||
|
managed.Scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableDictionary<string, string> MergeProperties(
|
||||||
|
ImmutableDictionary<string, string> effectiveProperties,
|
||||||
|
ImmutableArray<ImportedBom> bomImports)
|
||||||
|
{
|
||||||
|
var merged = effectiveProperties.ToBuilder();
|
||||||
|
|
||||||
|
// Add properties from BOMs (don't override existing)
|
||||||
|
foreach (var bom in bomImports)
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in bom.Properties)
|
||||||
|
{
|
||||||
|
merged.TryAdd(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.ToImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<JavaDependencyDeclaration> ResolveDependencies(
|
||||||
|
ImmutableArray<JavaDependencyDeclaration> dependencies,
|
||||||
|
Dictionary<string, ManagedDependency> managedVersions,
|
||||||
|
JavaPropertyResolver resolver)
|
||||||
|
{
|
||||||
|
var resolved = new List<JavaDependencyDeclaration>();
|
||||||
|
|
||||||
|
foreach (var dep in dependencies)
|
||||||
|
{
|
||||||
|
var resolvedDep = dep;
|
||||||
|
var versionSource = dep.VersionSource;
|
||||||
|
string? versionProperty = dep.VersionProperty;
|
||||||
|
|
||||||
|
// Resolve property placeholders in version
|
||||||
|
if (dep.Version?.Contains("${", StringComparison.Ordinal) == true)
|
||||||
|
{
|
||||||
|
var result = resolver.Resolve(dep.Version);
|
||||||
|
resolvedDep = dep with { Version = result.ResolvedValue };
|
||||||
|
versionSource = result.IsFullyResolved
|
||||||
|
? JavaVersionSource.Property
|
||||||
|
: JavaVersionSource.Unresolved;
|
||||||
|
versionProperty = ExtractPropertyName(dep.Version);
|
||||||
|
}
|
||||||
|
// Look up version from managed dependencies
|
||||||
|
else if (string.IsNullOrWhiteSpace(dep.Version))
|
||||||
|
{
|
||||||
|
var key = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||||
|
if (managedVersions.TryGetValue(key, out var managed))
|
||||||
|
{
|
||||||
|
// Resolve any properties in the managed version
|
||||||
|
var managedVersion = managed.Version;
|
||||||
|
if (managedVersion.Contains("${", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var result = resolver.Resolve(managedVersion);
|
||||||
|
managedVersion = result.ResolvedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedDep = dep with
|
||||||
|
{
|
||||||
|
Version = managedVersion,
|
||||||
|
Scope = dep.Scope ?? managed.Scope
|
||||||
|
};
|
||||||
|
|
||||||
|
versionSource = managed.Source.StartsWith("bom:", StringComparison.Ordinal)
|
||||||
|
? JavaVersionSource.Bom
|
||||||
|
: managed.Source == "dependencyManagement"
|
||||||
|
? JavaVersionSource.DependencyManagement
|
||||||
|
: JavaVersionSource.Parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.Add(resolvedDep with
|
||||||
|
{
|
||||||
|
VersionSource = versionSource,
|
||||||
|
VersionProperty = versionProperty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. resolved.OrderBy(d => d.Gav, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractPropertyName(string value)
|
||||||
|
{
|
||||||
|
var start = value.IndexOf("${", StringComparison.Ordinal);
|
||||||
|
var end = value.IndexOf('}', start + 2);
|
||||||
|
|
||||||
|
if (start >= 0 && end > start)
|
||||||
|
{
|
||||||
|
return value[(start + 2)..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of building an effective POM.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record MavenEffectivePomResult(
|
||||||
|
MavenPom OriginalPom,
|
||||||
|
ImmutableArray<MavenPom> ParentChain,
|
||||||
|
ImmutableDictionary<string, string> EffectiveProperties,
|
||||||
|
ImmutableDictionary<string, ManagedDependency> ManagedVersions,
|
||||||
|
ImmutableArray<JavaDependencyDeclaration> ResolvedDependencies,
|
||||||
|
ImmutableArray<JavaLicenseInfo> Licenses,
|
||||||
|
ImmutableArray<ImportedBom> ImportedBoms,
|
||||||
|
ImmutableArray<string> UnresolvedParents)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if all parents and BOMs were resolved.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFullyResolved => UnresolvedParents.Length == 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets dependencies that still have unresolved versions.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<JavaDependencyDeclaration> GetUnresolvedDependencies()
|
||||||
|
=> ResolvedDependencies.Where(d => !d.IsVersionResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a managed dependency version.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ManagedDependency(
|
||||||
|
string Version,
|
||||||
|
string Source,
|
||||||
|
string? Scope);
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves Maven parent POM chain and builds effective POM properties.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MavenParentResolver
|
||||||
|
{
|
||||||
|
private const int MaxDepth = 10;
|
||||||
|
|
||||||
|
private readonly string _rootPath;
|
||||||
|
private readonly Dictionary<string, MavenPom> _pomCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public MavenParentResolver(string rootPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||||
|
_rootPath = rootPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the parent chain for a POM and returns the effective properties.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MavenEffectivePom> ResolveAsync(
|
||||||
|
MavenPom pom,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var chain = new List<MavenPom> { pom };
|
||||||
|
var unresolved = new List<string>();
|
||||||
|
|
||||||
|
// Build the parent chain
|
||||||
|
await BuildParentChainAsync(pom, chain, unresolved, 0, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Merge properties from all POMs in the chain (parent to child)
|
||||||
|
var effectiveProperties = BuildEffectiveProperties(chain);
|
||||||
|
|
||||||
|
// Build the property resolver
|
||||||
|
var resolver = new JavaPropertyResolver(effectiveProperties);
|
||||||
|
|
||||||
|
// Resolve dependencies with merged properties
|
||||||
|
var resolvedDependencies = ResolveDependencies(pom, chain, resolver);
|
||||||
|
|
||||||
|
// Collect all licenses from the chain
|
||||||
|
var licenses = chain
|
||||||
|
.SelectMany(p => p.Licenses)
|
||||||
|
.Distinct()
|
||||||
|
.ToImmutableArray();
|
||||||
|
|
||||||
|
return new MavenEffectivePom(
|
||||||
|
pom,
|
||||||
|
[.. chain],
|
||||||
|
effectiveProperties,
|
||||||
|
resolvedDependencies,
|
||||||
|
licenses,
|
||||||
|
[.. unresolved]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BuildParentChainAsync(
|
||||||
|
MavenPom pom,
|
||||||
|
List<MavenPom> chain,
|
||||||
|
List<string> unresolved,
|
||||||
|
int depth,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (depth >= MaxDepth || pom.Parent is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = pom.Parent;
|
||||||
|
var parentPom = await TryResolveParentAsync(pom, parent, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (parentPom is null)
|
||||||
|
{
|
||||||
|
unresolved.Add(parent.GroupId + ":" + parent.ArtifactId + ":" + parent.Version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.Add(parentPom);
|
||||||
|
|
||||||
|
// Recurse for grandparent
|
||||||
|
await BuildParentChainAsync(parentPom, chain, unresolved, depth + 1, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MavenPom?> TryResolveParentAsync(
|
||||||
|
MavenPom childPom,
|
||||||
|
MavenParentRef parent,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Try relativePath first
|
||||||
|
if (!string.IsNullOrWhiteSpace(parent.RelativePath))
|
||||||
|
{
|
||||||
|
var childDir = Path.GetDirectoryName(childPom.SourcePath) ?? _rootPath;
|
||||||
|
var relativePomPath = Path.GetFullPath(Path.Combine(childDir, parent.RelativePath));
|
||||||
|
|
||||||
|
// If relativePath points to a directory, append pom.xml
|
||||||
|
if (Directory.Exists(relativePomPath))
|
||||||
|
{
|
||||||
|
relativePomPath = Path.Combine(relativePomPath, "pom.xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentPom = await TryLoadPomAsync(relativePomPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (parentPom is not null && MatchesParent(parentPom, parent))
|
||||||
|
{
|
||||||
|
return parentPom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: look in parent directory
|
||||||
|
var defaultPath = Path.GetFullPath(Path.Combine(
|
||||||
|
Path.GetDirectoryName(childPom.SourcePath) ?? _rootPath,
|
||||||
|
"..",
|
||||||
|
"pom.xml"));
|
||||||
|
|
||||||
|
var defaultParent = await TryLoadPomAsync(defaultPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (defaultParent is not null && MatchesParent(defaultParent, parent))
|
||||||
|
{
|
||||||
|
return defaultParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find in workspace by GAV
|
||||||
|
var workspaceParent = await TryFindInWorkspaceAsync(parent, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (workspaceParent is not null)
|
||||||
|
{
|
||||||
|
return workspaceParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try local Maven repository
|
||||||
|
var localRepoParent = await TryFindInLocalRepositoryAsync(parent, cancellationToken).ConfigureAwait(false);
|
||||||
|
return localRepoParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MavenPom?> TryLoadPomAsync(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedPath = Path.GetFullPath(path);
|
||||||
|
|
||||||
|
if (_pomCache.TryGetValue(normalizedPath, out var cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pom = await MavenPomParser.ParseAsync(normalizedPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
_pomCache[normalizedPath] = pom;
|
||||||
|
return pom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MavenPom?> TryFindInWorkspaceAsync(
|
||||||
|
MavenParentRef parent,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Search for pom.xml files in the workspace
|
||||||
|
foreach (var pomPath in Directory.EnumerateFiles(_rootPath, "pom.xml", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var pom = await TryLoadPomAsync(pomPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (pom is not null && MatchesParent(pom, parent))
|
||||||
|
{
|
||||||
|
return pom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<MavenPom?> TryFindInLocalRepositoryAsync(
|
||||||
|
MavenParentRef parent,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var localRepoPath = GetLocalRepositoryPath();
|
||||||
|
if (string.IsNullOrEmpty(localRepoPath) || !Directory.Exists(localRepoPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert GAV to path: com.example:parent:1.0.0 -> com/example/parent/1.0.0/parent-1.0.0.pom
|
||||||
|
var groupPath = parent.GroupId.Replace('.', Path.DirectorySeparatorChar);
|
||||||
|
var pomFileName = $"{parent.ArtifactId}-{parent.Version}.pom";
|
||||||
|
var pomPath = Path.Combine(localRepoPath, groupPath, parent.ArtifactId, parent.Version, pomFileName);
|
||||||
|
|
||||||
|
return await TryLoadPomAsync(pomPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetLocalRepositoryPath()
|
||||||
|
{
|
||||||
|
// Check M2_REPO environment variable
|
||||||
|
var m2Repo = Environment.GetEnvironmentVariable("M2_REPO");
|
||||||
|
if (!string.IsNullOrEmpty(m2Repo) && Directory.Exists(m2Repo))
|
||||||
|
{
|
||||||
|
return m2Repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: ~/.m2/repository
|
||||||
|
var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
var defaultPath = Path.Combine(userHome, ".m2", "repository");
|
||||||
|
|
||||||
|
return Directory.Exists(defaultPath) ? defaultPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesParent(MavenPom pom, MavenParentRef parent)
|
||||||
|
{
|
||||||
|
return string.Equals(pom.GroupId, parent.GroupId, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(pom.ArtifactId, parent.ArtifactId, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableDictionary<string, string> BuildEffectiveProperties(List<MavenPom> chain)
|
||||||
|
{
|
||||||
|
var builder = new JavaPropertyBuilder();
|
||||||
|
|
||||||
|
// Start from root parent and work down to child (child properties override parent)
|
||||||
|
for (int i = chain.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var pom = chain[i];
|
||||||
|
|
||||||
|
// Add project coordinates
|
||||||
|
builder.AddProjectCoordinates(pom.GroupId, pom.ArtifactId, pom.Version);
|
||||||
|
|
||||||
|
// Add parent coordinates
|
||||||
|
if (pom.Parent is not null)
|
||||||
|
{
|
||||||
|
builder.Add("project.parent.groupId", pom.Parent.GroupId);
|
||||||
|
builder.Add("project.parent.artifactId", pom.Parent.ArtifactId);
|
||||||
|
builder.Add("project.parent.version", pom.Parent.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add declared properties
|
||||||
|
builder.AddRange(pom.Properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<JavaDependencyDeclaration> ResolveDependencies(
|
||||||
|
MavenPom pom,
|
||||||
|
List<MavenPom> chain,
|
||||||
|
JavaPropertyResolver resolver)
|
||||||
|
{
|
||||||
|
// Build dependency management index from all POMs in chain
|
||||||
|
var managedVersions = BuildManagedVersionsIndex(chain);
|
||||||
|
|
||||||
|
var resolved = new List<JavaDependencyDeclaration>();
|
||||||
|
|
||||||
|
foreach (var dep in pom.Dependencies)
|
||||||
|
{
|
||||||
|
var resolvedDep = dep;
|
||||||
|
|
||||||
|
// Resolve property placeholders in version
|
||||||
|
if (dep.Version?.Contains("${", StringComparison.Ordinal) == true)
|
||||||
|
{
|
||||||
|
var result = resolver.Resolve(dep.Version);
|
||||||
|
resolvedDep = dep with
|
||||||
|
{
|
||||||
|
Version = result.ResolvedValue,
|
||||||
|
VersionSource = result.IsFullyResolved
|
||||||
|
? JavaVersionSource.Property
|
||||||
|
: JavaVersionSource.Unresolved
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Look up version from dependency management
|
||||||
|
else if (string.IsNullOrWhiteSpace(dep.Version))
|
||||||
|
{
|
||||||
|
var key = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||||
|
if (managedVersions.TryGetValue(key, out var managedVersion))
|
||||||
|
{
|
||||||
|
// Resolve any properties in the managed version
|
||||||
|
var result = resolver.Resolve(managedVersion);
|
||||||
|
resolvedDep = dep with
|
||||||
|
{
|
||||||
|
Version = result.ResolvedValue,
|
||||||
|
VersionSource = JavaVersionSource.DependencyManagement
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved.Add(resolvedDep);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. resolved.OrderBy(d => d.Gav, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildManagedVersionsIndex(List<MavenPom> chain)
|
||||||
|
{
|
||||||
|
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Start from root parent (last in chain) so child definitions override
|
||||||
|
for (int i = chain.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
foreach (var managed in chain[i].DependencyManagement)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||||
|
{
|
||||||
|
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||||
|
index[key] = managed.Version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a fully resolved effective POM with merged parent chain.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record MavenEffectivePom(
|
||||||
|
MavenPom OriginalPom,
|
||||||
|
ImmutableArray<MavenPom> ParentChain,
|
||||||
|
ImmutableDictionary<string, string> EffectiveProperties,
|
||||||
|
ImmutableArray<JavaDependencyDeclaration> ResolvedDependencies,
|
||||||
|
ImmutableArray<JavaLicenseInfo> AllLicenses,
|
||||||
|
ImmutableArray<string> UnresolvedParents)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if all parents were successfully resolved.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFullyResolved => UnresolvedParents.Length == 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the effective group ID.
|
||||||
|
/// </summary>
|
||||||
|
public string? EffectiveGroupId => OriginalPom.GroupId ?? ParentChain.FirstOrDefault()?.GroupId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the effective version.
|
||||||
|
/// </summary>
|
||||||
|
public string? EffectiveVersion => OriginalPom.Version ?? ParentChain.FirstOrDefault()?.Version;
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.License;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses Maven POM files (pom.xml) to extract project metadata and dependencies.
|
||||||
|
/// </summary>
|
||||||
|
internal static class MavenPomParser
|
||||||
|
{
|
||||||
|
private static readonly XNamespace PomNamespace = "http://maven.apache.org/POM/4.0.0";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a pom.xml file asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<MavenPom> ParseAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return MavenPom.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
var document = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Parse(document, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses pom.xml content from a string.
|
||||||
|
/// </summary>
|
||||||
|
public static MavenPom ParseFromString(string content, string sourcePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
{
|
||||||
|
return MavenPom.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var document = XDocument.Parse(content);
|
||||||
|
return Parse(document, sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a pom.xml XDocument.
|
||||||
|
/// </summary>
|
||||||
|
public static MavenPom Parse(XDocument document, string sourcePath)
|
||||||
|
{
|
||||||
|
var root = document.Root;
|
||||||
|
if (root is null)
|
||||||
|
{
|
||||||
|
return MavenPom.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine namespace (might be default or prefixed)
|
||||||
|
var ns = root.Name.Namespace;
|
||||||
|
if (ns == XNamespace.None)
|
||||||
|
{
|
||||||
|
ns = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupId = GetElementValue(root, ns, "groupId");
|
||||||
|
var artifactId = GetElementValue(root, ns, "artifactId");
|
||||||
|
var version = GetElementValue(root, ns, "version");
|
||||||
|
var packaging = GetElementValue(root, ns, "packaging") ?? "jar";
|
||||||
|
var name = GetElementValue(root, ns, "name");
|
||||||
|
var description = GetElementValue(root, ns, "description");
|
||||||
|
|
||||||
|
// Parse parent
|
||||||
|
var parent = ParseParent(root, ns);
|
||||||
|
|
||||||
|
// Inherit from parent if not set
|
||||||
|
groupId ??= parent?.GroupId;
|
||||||
|
version ??= parent?.Version;
|
||||||
|
|
||||||
|
// Parse properties
|
||||||
|
var properties = ParseProperties(root, ns);
|
||||||
|
|
||||||
|
// Parse licenses
|
||||||
|
var licenses = ParseLicenses(root, ns);
|
||||||
|
|
||||||
|
// Parse dependencies
|
||||||
|
var dependencies = ParseDependencies(root, ns, sourcePath);
|
||||||
|
|
||||||
|
// Parse dependency management
|
||||||
|
var dependencyManagement = ParseDependencyManagement(root, ns, sourcePath);
|
||||||
|
|
||||||
|
// Parse modules (for multi-module projects)
|
||||||
|
var modules = ParseModules(root, ns);
|
||||||
|
|
||||||
|
// Parse repositories
|
||||||
|
var repositories = ParseRepositories(root, ns);
|
||||||
|
|
||||||
|
return new MavenPom(
|
||||||
|
sourcePath,
|
||||||
|
groupId,
|
||||||
|
artifactId,
|
||||||
|
version,
|
||||||
|
packaging,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parent,
|
||||||
|
properties,
|
||||||
|
licenses,
|
||||||
|
dependencies,
|
||||||
|
dependencyManagement,
|
||||||
|
modules,
|
||||||
|
repositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetElementValue(XElement parent, XNamespace ns, string name)
|
||||||
|
{
|
||||||
|
var element = parent.Element(ns + name);
|
||||||
|
return element?.Value?.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MavenParentRef? ParseParent(XElement root, XNamespace ns)
|
||||||
|
{
|
||||||
|
var parentElement = root.Element(ns + "parent");
|
||||||
|
if (parentElement is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupId = GetElementValue(parentElement, ns, "groupId");
|
||||||
|
var artifactId = GetElementValue(parentElement, ns, "artifactId");
|
||||||
|
var version = GetElementValue(parentElement, ns, "version");
|
||||||
|
var relativePath = GetElementValue(parentElement, ns, "relativePath");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(artifactId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MavenParentRef(groupId, artifactId, version ?? string.Empty, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableDictionary<string, string> ParseProperties(XElement root, XNamespace ns)
|
||||||
|
{
|
||||||
|
var propertiesElement = root.Element(ns + "properties");
|
||||||
|
if (propertiesElement is null)
|
||||||
|
{
|
||||||
|
return ImmutableDictionary<string, string>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var prop in propertiesElement.Elements())
|
||||||
|
{
|
||||||
|
var key = prop.Name.LocalName;
|
||||||
|
var value = prop.Value?.Trim();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
properties[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<JavaLicenseInfo> ParseLicenses(XElement root, XNamespace ns)
|
||||||
|
{
|
||||||
|
var licensesElement = root.Element(ns + "licenses");
|
||||||
|
if (licensesElement is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var licenses = new List<JavaLicenseInfo>();
|
||||||
|
|
||||||
|
foreach (var licenseElement in licensesElement.Elements(ns + "license"))
|
||||||
|
{
|
||||||
|
var name = GetElementValue(licenseElement, ns, "name");
|
||||||
|
var url = GetElementValue(licenseElement, ns, "url");
|
||||||
|
var distribution = GetElementValue(licenseElement, ns, "distribution");
|
||||||
|
var comments = GetElementValue(licenseElement, ns, "comments");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(name) || !string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
// Normalize to SPDX
|
||||||
|
var normalizedLicense = SpdxLicenseNormalizer.Instance.Normalize(name, url);
|
||||||
|
|
||||||
|
licenses.Add(normalizedLicense with
|
||||||
|
{
|
||||||
|
Distribution = distribution,
|
||||||
|
Comments = comments
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. licenses];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencies(
|
||||||
|
XElement root,
|
||||||
|
XNamespace ns,
|
||||||
|
string sourcePath)
|
||||||
|
{
|
||||||
|
var dependenciesElement = root.Element(ns + "dependencies");
|
||||||
|
if (dependenciesElement is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseDependencyElements(dependenciesElement, ns, sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencyManagement(
|
||||||
|
XElement root,
|
||||||
|
XNamespace ns,
|
||||||
|
string sourcePath)
|
||||||
|
{
|
||||||
|
var dmElement = root.Element(ns + "dependencyManagement");
|
||||||
|
if (dmElement is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var dependenciesElement = dmElement.Element(ns + "dependencies");
|
||||||
|
if (dependenciesElement is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseDependencyElements(dependenciesElement, ns, sourcePath, isDependencyManagement: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencyElements(
|
||||||
|
XElement dependenciesElement,
|
||||||
|
XNamespace ns,
|
||||||
|
string sourcePath,
|
||||||
|
bool isDependencyManagement = false)
|
||||||
|
{
|
||||||
|
var dependencies = new List<JavaDependencyDeclaration>();
|
||||||
|
|
||||||
|
foreach (var depElement in dependenciesElement.Elements(ns + "dependency"))
|
||||||
|
{
|
||||||
|
var groupId = GetElementValue(depElement, ns, "groupId");
|
||||||
|
var artifactId = GetElementValue(depElement, ns, "artifactId");
|
||||||
|
var version = GetElementValue(depElement, ns, "version");
|
||||||
|
var scope = GetElementValue(depElement, ns, "scope");
|
||||||
|
var type = GetElementValue(depElement, ns, "type");
|
||||||
|
var classifier = GetElementValue(depElement, ns, "classifier");
|
||||||
|
var optional = GetElementValue(depElement, ns, "optional");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(artifactId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse exclusions
|
||||||
|
var exclusions = ParseExclusions(depElement, ns);
|
||||||
|
|
||||||
|
// Determine version source
|
||||||
|
var versionSource = JavaVersionSource.Direct;
|
||||||
|
string? versionProperty = null;
|
||||||
|
|
||||||
|
if (version?.Contains("${", StringComparison.Ordinal) == true)
|
||||||
|
{
|
||||||
|
versionSource = JavaVersionSource.Property;
|
||||||
|
versionProperty = ExtractPropertyName(version);
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(version) && !isDependencyManagement)
|
||||||
|
{
|
||||||
|
versionSource = JavaVersionSource.DependencyManagement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a BOM import
|
||||||
|
var isBomImport = scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
|
||||||
|
type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true;
|
||||||
|
|
||||||
|
dependencies.Add(new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = groupId,
|
||||||
|
ArtifactId = artifactId,
|
||||||
|
Version = version,
|
||||||
|
Scope = isBomImport ? "import" : scope,
|
||||||
|
Type = type,
|
||||||
|
Classifier = classifier,
|
||||||
|
Optional = optional?.Equals("true", StringComparison.OrdinalIgnoreCase) == true,
|
||||||
|
Exclusions = exclusions,
|
||||||
|
Source = "pom.xml",
|
||||||
|
Locator = sourcePath,
|
||||||
|
VersionSource = versionSource,
|
||||||
|
VersionProperty = versionProperty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<JavaExclusion> ParseExclusions(XElement depElement, XNamespace ns)
|
||||||
|
{
|
||||||
|
var exclusionsElement = depElement.Element(ns + "exclusions");
|
||||||
|
if (exclusionsElement is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var exclusions = new List<JavaExclusion>();
|
||||||
|
|
||||||
|
foreach (var excElement in exclusionsElement.Elements(ns + "exclusion"))
|
||||||
|
{
|
||||||
|
var groupId = GetElementValue(excElement, ns, "groupId");
|
||||||
|
var artifactId = GetElementValue(excElement, ns, "artifactId");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
|
||||||
|
{
|
||||||
|
exclusions.Add(new JavaExclusion(groupId, artifactId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. exclusions];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<string> ParseModules(XElement root, XNamespace ns)
|
||||||
|
{
|
||||||
|
var modulesElement = root.Element(ns + "modules");
|
||||||
|
if (modulesElement is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. modulesElement.Elements(ns + "module")
|
||||||
|
.Select(e => e.Value?.Trim())
|
||||||
|
.Where(m => !string.IsNullOrWhiteSpace(m))
|
||||||
|
.Cast<string>()
|
||||||
|
.OrderBy(m => m, StringComparer.Ordinal)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<MavenRepository> ParseRepositories(XElement root, XNamespace ns)
|
||||||
|
{
|
||||||
|
var repositoriesElement = root.Element(ns + "repositories");
|
||||||
|
if (repositoriesElement is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var repositories = new List<MavenRepository>();
|
||||||
|
|
||||||
|
foreach (var repoElement in repositoriesElement.Elements(ns + "repository"))
|
||||||
|
{
|
||||||
|
var id = GetElementValue(repoElement, ns, "id");
|
||||||
|
var name = GetElementValue(repoElement, ns, "name");
|
||||||
|
var url = GetElementValue(repoElement, ns, "url");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
repositories.Add(new MavenRepository(id ?? string.Empty, name, url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. repositories.OrderBy(r => r.Id, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractPropertyName(string value)
|
||||||
|
{
|
||||||
|
var start = value.IndexOf("${", StringComparison.Ordinal);
|
||||||
|
var end = value.IndexOf('}', start + 2);
|
||||||
|
|
||||||
|
if (start >= 0 && end > start)
|
||||||
|
{
|
||||||
|
return value[(start + 2)..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parsed Maven POM file.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record MavenPom(
|
||||||
|
string SourcePath,
|
||||||
|
string? GroupId,
|
||||||
|
string? ArtifactId,
|
||||||
|
string? Version,
|
||||||
|
string Packaging,
|
||||||
|
string? Name,
|
||||||
|
string? Description,
|
||||||
|
MavenParentRef? Parent,
|
||||||
|
ImmutableDictionary<string, string> Properties,
|
||||||
|
ImmutableArray<JavaLicenseInfo> Licenses,
|
||||||
|
ImmutableArray<JavaDependencyDeclaration> Dependencies,
|
||||||
|
ImmutableArray<JavaDependencyDeclaration> DependencyManagement,
|
||||||
|
ImmutableArray<string> Modules,
|
||||||
|
ImmutableArray<MavenRepository> Repositories)
|
||||||
|
{
|
||||||
|
public static readonly MavenPom Empty = new(
|
||||||
|
string.Empty,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"jar",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
ImmutableDictionary<string, string>.Empty,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if this is a parent/aggregator POM.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsParentPom => Packaging.Equals("pom", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if this POM has a parent.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasParent => Parent is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate.
|
||||||
|
/// </summary>
|
||||||
|
public string? Gav => GroupId is not null && ArtifactId is not null
|
||||||
|
? Version is not null
|
||||||
|
? $"{GroupId}:{ArtifactId}:{Version}"
|
||||||
|
: $"{GroupId}:{ArtifactId}"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets BOM imports from dependency management.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<JavaDependencyDeclaration> GetBomImports()
|
||||||
|
=> DependencyManagement.Where(d =>
|
||||||
|
d.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
|
||||||
|
d.Type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to unified project metadata.
|
||||||
|
/// </summary>
|
||||||
|
public JavaProjectMetadata ToProjectMetadata() => new()
|
||||||
|
{
|
||||||
|
GroupId = GroupId,
|
||||||
|
ArtifactId = ArtifactId,
|
||||||
|
Version = Version,
|
||||||
|
Packaging = Packaging,
|
||||||
|
Parent = Parent is not null
|
||||||
|
? new JavaParentReference
|
||||||
|
{
|
||||||
|
GroupId = Parent.GroupId,
|
||||||
|
ArtifactId = Parent.ArtifactId,
|
||||||
|
Version = Parent.Version,
|
||||||
|
RelativePath = Parent.RelativePath
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
Properties = Properties,
|
||||||
|
Licenses = Licenses,
|
||||||
|
Dependencies = Dependencies,
|
||||||
|
DependencyManagement = DependencyManagement,
|
||||||
|
SourcePath = SourcePath,
|
||||||
|
BuildSystem = JavaBuildSystem.Maven
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a parent POM reference.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record MavenParentRef(
|
||||||
|
string GroupId,
|
||||||
|
string ArtifactId,
|
||||||
|
string Version,
|
||||||
|
string? RelativePath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a Maven repository.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record MavenRepository(string Id, string? Name, string Url);
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Osgi;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses OSGi bundle metadata from JAR manifest files.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class OsgiBundleParser
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses OSGi bundle information from a manifest dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public static OsgiBundleInfo? Parse(IReadOnlyDictionary<string, string> manifest)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
|
||||||
|
// Check if this is an OSGi bundle
|
||||||
|
if (!manifest.TryGetValue("Bundle-SymbolicName", out var symbolicName) ||
|
||||||
|
string.IsNullOrWhiteSpace(symbolicName))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse symbolic name (may include directives like ;singleton:=true)
|
||||||
|
var parsedSymbolicName = ParseSymbolicName(symbolicName);
|
||||||
|
|
||||||
|
var bundleVersion = manifest.GetValueOrDefault("Bundle-Version", "0.0.0");
|
||||||
|
var bundleName = manifest.GetValueOrDefault("Bundle-Name");
|
||||||
|
var bundleVendor = manifest.GetValueOrDefault("Bundle-Vendor");
|
||||||
|
var bundleDescription = manifest.GetValueOrDefault("Bundle-Description");
|
||||||
|
var bundleActivator = manifest.GetValueOrDefault("Bundle-Activator");
|
||||||
|
var bundleCategory = manifest.GetValueOrDefault("Bundle-Category");
|
||||||
|
var bundleLicense = manifest.GetValueOrDefault("Bundle-License");
|
||||||
|
var fragmentHost = manifest.GetValueOrDefault("Fragment-Host");
|
||||||
|
|
||||||
|
// Parse imports and exports
|
||||||
|
var importPackage = ParsePackageList(manifest.GetValueOrDefault("Import-Package"));
|
||||||
|
var exportPackage = ParsePackageList(manifest.GetValueOrDefault("Export-Package"));
|
||||||
|
var requireBundle = ParseRequireBundle(manifest.GetValueOrDefault("Require-Bundle"));
|
||||||
|
var dynamicImport = ParsePackageList(manifest.GetValueOrDefault("DynamicImport-Package"));
|
||||||
|
|
||||||
|
// Parse capabilities and requirements (OSGi R5+)
|
||||||
|
var provideCapability = manifest.GetValueOrDefault("Provide-Capability");
|
||||||
|
var requireCapability = manifest.GetValueOrDefault("Require-Capability");
|
||||||
|
|
||||||
|
return new OsgiBundleInfo(
|
||||||
|
parsedSymbolicName.Name,
|
||||||
|
bundleVersion,
|
||||||
|
bundleName,
|
||||||
|
bundleVendor,
|
||||||
|
bundleDescription,
|
||||||
|
bundleActivator,
|
||||||
|
bundleCategory,
|
||||||
|
bundleLicense,
|
||||||
|
fragmentHost,
|
||||||
|
parsedSymbolicName.IsSingleton,
|
||||||
|
importPackage,
|
||||||
|
exportPackage,
|
||||||
|
requireBundle,
|
||||||
|
dynamicImport,
|
||||||
|
provideCapability,
|
||||||
|
requireCapability);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses manifest content from a string.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyDictionary<string, string> ParseManifest(string manifestContent)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(manifestContent))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest format uses continuation lines starting with space
|
||||||
|
var lines = manifestContent.Split('\n');
|
||||||
|
string? currentKey = null;
|
||||||
|
var currentValue = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
foreach (var rawLine in lines)
|
||||||
|
{
|
||||||
|
var line = rawLine.TrimEnd('\r');
|
||||||
|
|
||||||
|
if (line.StartsWith(' ') || line.StartsWith('\t'))
|
||||||
|
{
|
||||||
|
// Continuation line
|
||||||
|
if (currentKey is not null)
|
||||||
|
{
|
||||||
|
currentValue.Append(line.TrimStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Save previous entry
|
||||||
|
if (currentKey is not null)
|
||||||
|
{
|
||||||
|
result[currentKey] = currentValue.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse new entry
|
||||||
|
var colonIndex = line.IndexOf(':');
|
||||||
|
if (colonIndex > 0)
|
||||||
|
{
|
||||||
|
currentKey = line[..colonIndex].Trim();
|
||||||
|
currentValue.Clear();
|
||||||
|
currentValue.Append(line[(colonIndex + 1)..].Trim());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
currentKey = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save last entry
|
||||||
|
if (currentKey is not null)
|
||||||
|
{
|
||||||
|
result[currentKey] = currentValue.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Name, bool IsSingleton) ParseSymbolicName(string symbolicName)
|
||||||
|
{
|
||||||
|
var semicolonIndex = symbolicName.IndexOf(';');
|
||||||
|
if (semicolonIndex < 0)
|
||||||
|
{
|
||||||
|
return (symbolicName.Trim(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = symbolicName[..semicolonIndex].Trim();
|
||||||
|
var directives = symbolicName[semicolonIndex..];
|
||||||
|
|
||||||
|
var isSingleton = directives.Contains("singleton:=true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return (name, isSingleton);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<OsgiPackageSpec> ParsePackageList(string? packageList)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(packageList))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages = new List<OsgiPackageSpec>();
|
||||||
|
|
||||||
|
// Split by comma, but handle nested quotes and parentheses
|
||||||
|
var entries = SplitPackageEntries(packageList);
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var spec = ParsePackageSpec(entry.Trim());
|
||||||
|
if (spec is not null)
|
||||||
|
{
|
||||||
|
packages.Add(spec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. packages.OrderBy(p => p.PackageName, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OsgiPackageSpec? ParsePackageSpec(string entry)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(entry))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package may have attributes: com.example.package;version="[1.0,2.0)"
|
||||||
|
var semicolonIndex = entry.IndexOf(';');
|
||||||
|
if (semicolonIndex < 0)
|
||||||
|
{
|
||||||
|
return new OsgiPackageSpec(entry.Trim(), null, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var packageName = entry[..semicolonIndex].Trim();
|
||||||
|
var attributes = entry[semicolonIndex..];
|
||||||
|
|
||||||
|
// Extract version
|
||||||
|
string? version = null;
|
||||||
|
var versionMatch = VersionPattern().Match(attributes);
|
||||||
|
if (versionMatch.Success)
|
||||||
|
{
|
||||||
|
version = versionMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for resolution:=optional
|
||||||
|
var isOptional = attributes.Contains("resolution:=optional", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Extract uses directive
|
||||||
|
string? uses = null;
|
||||||
|
var usesMatch = UsesPattern().Match(attributes);
|
||||||
|
if (usesMatch.Success)
|
||||||
|
{
|
||||||
|
uses = usesMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OsgiPackageSpec(packageName, version, uses, isOptional);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableArray<OsgiBundleRef> ParseRequireBundle(string? requireBundle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(requireBundle))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var bundles = new List<OsgiBundleRef>();
|
||||||
|
var entries = SplitPackageEntries(requireBundle);
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var semicolonIndex = entry.IndexOf(';');
|
||||||
|
string bundleName;
|
||||||
|
string? bundleVersion = null;
|
||||||
|
bool isOptional = false;
|
||||||
|
|
||||||
|
if (semicolonIndex < 0)
|
||||||
|
{
|
||||||
|
bundleName = entry.Trim();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bundleName = entry[..semicolonIndex].Trim();
|
||||||
|
var attributes = entry[semicolonIndex..];
|
||||||
|
|
||||||
|
var versionMatch = BundleVersionPattern().Match(attributes);
|
||||||
|
if (versionMatch.Success)
|
||||||
|
{
|
||||||
|
bundleVersion = versionMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
isOptional = attributes.Contains("resolution:=optional", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(bundleName))
|
||||||
|
{
|
||||||
|
bundles.Add(new OsgiBundleRef(bundleName, bundleVersion, isOptional));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. bundles.OrderBy(b => b.SymbolicName, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SplitPackageEntries(string value)
|
||||||
|
{
|
||||||
|
var result = new List<string>();
|
||||||
|
var current = new System.Text.StringBuilder();
|
||||||
|
var depth = 0;
|
||||||
|
var inQuote = false;
|
||||||
|
|
||||||
|
foreach (var c in value)
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
inQuote = !inQuote;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inQuote)
|
||||||
|
{
|
||||||
|
if (c == '(' || c == '[') depth++;
|
||||||
|
else if (c == ')' || c == ']') depth--;
|
||||||
|
else if (c == ',' && depth == 0)
|
||||||
|
{
|
||||||
|
result.Add(current.ToString());
|
||||||
|
current.Clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Length > 0)
|
||||||
|
{
|
||||||
|
result.Add(current.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"version\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex VersionPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"uses\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex UsesPattern();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"bundle-version\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex BundleVersionPattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents OSGi bundle metadata.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record OsgiBundleInfo(
|
||||||
|
string SymbolicName,
|
||||||
|
string Version,
|
||||||
|
string? Name,
|
||||||
|
string? Vendor,
|
||||||
|
string? Description,
|
||||||
|
string? Activator,
|
||||||
|
string? Category,
|
||||||
|
string? License,
|
||||||
|
string? FragmentHost,
|
||||||
|
bool IsSingleton,
|
||||||
|
ImmutableArray<OsgiPackageSpec> ImportPackage,
|
||||||
|
ImmutableArray<OsgiPackageSpec> ExportPackage,
|
||||||
|
ImmutableArray<OsgiBundleRef> RequireBundle,
|
||||||
|
ImmutableArray<OsgiPackageSpec> DynamicImport,
|
||||||
|
string? ProvideCapability,
|
||||||
|
string? RequireCapability)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if this is a fragment bundle.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsFragment => !string.IsNullOrWhiteSpace(FragmentHost);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Import-Package header as a formatted string.
|
||||||
|
/// </summary>
|
||||||
|
public string GetImportPackageHeader()
|
||||||
|
=> string.Join(",", ImportPackage.Select(p => p.ToHeaderString()));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Export-Package header as a formatted string.
|
||||||
|
/// </summary>
|
||||||
|
public string GetExportPackageHeader()
|
||||||
|
=> string.Join(",", ExportPackage.Select(p => p.ToHeaderString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a package specification in Import-Package or Export-Package.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record OsgiPackageSpec(
|
||||||
|
string PackageName,
|
||||||
|
string? Version,
|
||||||
|
string? Uses,
|
||||||
|
bool IsOptional)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to OSGi header format.
|
||||||
|
/// </summary>
|
||||||
|
public string ToHeaderString()
|
||||||
|
{
|
||||||
|
var result = PackageName;
|
||||||
|
if (Version is not null)
|
||||||
|
{
|
||||||
|
result += $";version=\"{Version}\"";
|
||||||
|
}
|
||||||
|
if (IsOptional)
|
||||||
|
{
|
||||||
|
result += ";resolution:=optional";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a Require-Bundle entry.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record OsgiBundleRef(
|
||||||
|
string SymbolicName,
|
||||||
|
string? BundleVersion,
|
||||||
|
bool IsOptional);
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves property placeholders (${property.name}) in Java project metadata.
|
||||||
|
/// Supports Maven-style properties with parent chain resolution.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class JavaPropertyResolver
|
||||||
|
{
|
||||||
|
private const int MaxRecursionDepth = 10;
|
||||||
|
private static readonly Regex PropertyPattern = GetPropertyPattern();
|
||||||
|
|
||||||
|
private readonly ImmutableDictionary<string, string> _baseProperties;
|
||||||
|
private readonly ImmutableArray<ImmutableDictionary<string, string>> _propertyChain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a property resolver with the given property sources.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="baseProperties">Properties from the current project.</param>
|
||||||
|
/// <param name="parentProperties">Properties from parent projects, ordered from nearest to root.</param>
|
||||||
|
public JavaPropertyResolver(
|
||||||
|
ImmutableDictionary<string, string>? baseProperties = null,
|
||||||
|
IEnumerable<ImmutableDictionary<string, string>>? parentProperties = null)
|
||||||
|
{
|
||||||
|
_baseProperties = baseProperties ?? ImmutableDictionary<string, string>.Empty;
|
||||||
|
_propertyChain = parentProperties?.ToImmutableArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a resolver from a project metadata and its parent chain.
|
||||||
|
/// </summary>
|
||||||
|
public static JavaPropertyResolver FromProject(JavaProjectMetadata project)
|
||||||
|
{
|
||||||
|
var parentProps = new List<ImmutableDictionary<string, string>>();
|
||||||
|
var current = project.Parent?.ResolvedParent;
|
||||||
|
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
parentProps.Add(current.Properties);
|
||||||
|
current = current.Parent?.ResolvedParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JavaPropertyResolver(project.Properties, parentProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves all property placeholders in the given string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">String containing ${property} placeholders.</param>
|
||||||
|
/// <returns>Resolved string with all placeholders replaced.</returns>
|
||||||
|
public PropertyResolutionResult Resolve(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return PropertyResolutionResult.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value.Contains("${", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new PropertyResolutionResult(value, true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolvedProperties = new List<string>();
|
||||||
|
var resolved = ResolveInternal(value, 0, unresolvedProperties);
|
||||||
|
|
||||||
|
return new PropertyResolutionResult(
|
||||||
|
resolved,
|
||||||
|
unresolvedProperties.Count == 0,
|
||||||
|
unresolvedProperties.ToImmutableArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveInternal(string value, int depth, List<string> unresolved)
|
||||||
|
{
|
||||||
|
if (depth >= MaxRecursionDepth)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PropertyPattern.Replace(value, match =>
|
||||||
|
{
|
||||||
|
var propertyName = match.Groups[1].Value;
|
||||||
|
|
||||||
|
if (TryGetProperty(propertyName, out var propertyValue))
|
||||||
|
{
|
||||||
|
// Recursively resolve nested properties
|
||||||
|
if (propertyValue.Contains("${", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return ResolveInternal(propertyValue, depth + 1, unresolved);
|
||||||
|
}
|
||||||
|
return propertyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle built-in Maven properties
|
||||||
|
if (TryGetBuiltInProperty(propertyName, out var builtInValue))
|
||||||
|
{
|
||||||
|
return builtInValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unresolved.Add(propertyName);
|
||||||
|
return match.Value; // Keep original placeholder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetProperty(string name, out string value)
|
||||||
|
{
|
||||||
|
// First check base properties
|
||||||
|
if (_baseProperties.TryGetValue(name, out value!))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check parent chain in order
|
||||||
|
foreach (var parentProps in _propertyChain)
|
||||||
|
{
|
||||||
|
if (parentProps.TryGetValue(name, out value!))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetBuiltInProperty(string name, out string value)
|
||||||
|
{
|
||||||
|
// Handle common Maven built-in properties
|
||||||
|
value = name switch
|
||||||
|
{
|
||||||
|
"project.basedir" => ".",
|
||||||
|
"basedir" => ".",
|
||||||
|
"project.build.directory" => "target",
|
||||||
|
"project.build.outputDirectory" => "target/classes",
|
||||||
|
"project.build.testOutputDirectory" => "target/test-classes",
|
||||||
|
"project.build.sourceDirectory" => "src/main/java",
|
||||||
|
"project.build.testSourceDirectory" => "src/test/java",
|
||||||
|
"project.build.resourcesDirectory" => "src/main/resources",
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
return !string.IsNullOrEmpty(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a dependency declaration, resolving version and other placeholders.
|
||||||
|
/// </summary>
|
||||||
|
public JavaDependencyDeclaration ResolveDependency(JavaDependencyDeclaration dependency)
|
||||||
|
{
|
||||||
|
var versionResult = Resolve(dependency.Version);
|
||||||
|
|
||||||
|
return dependency with
|
||||||
|
{
|
||||||
|
Version = versionResult.ResolvedValue,
|
||||||
|
VersionSource = versionResult.IsFullyResolved
|
||||||
|
? JavaVersionSource.Property
|
||||||
|
: JavaVersionSource.Unresolved,
|
||||||
|
VersionProperty = dependency.Version?.Contains("${", StringComparison.Ordinal) == true
|
||||||
|
? ExtractPropertyName(dependency.Version)
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractPropertyName(string value)
|
||||||
|
{
|
||||||
|
var match = PropertyPattern.Match(value);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\$\{([^}]+)\}", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex GetPropertyPattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a property resolution operation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record PropertyResolutionResult(
|
||||||
|
string ResolvedValue,
|
||||||
|
bool IsFullyResolved,
|
||||||
|
ImmutableArray<string> UnresolvedProperties)
|
||||||
|
{
|
||||||
|
public static readonly PropertyResolutionResult Empty = new(string.Empty, true, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builder for constructing property dictionaries from various sources.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class JavaPropertyBuilder
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _properties = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a property if it doesn't already exist.
|
||||||
|
/// </summary>
|
||||||
|
public JavaPropertyBuilder Add(string name, string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(value) && !_properties.ContainsKey(name))
|
||||||
|
{
|
||||||
|
_properties[name] = value;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds project coordinates as properties.
|
||||||
|
/// </summary>
|
||||||
|
public JavaPropertyBuilder AddProjectCoordinates(string? groupId, string? artifactId, string? version)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(groupId))
|
||||||
|
{
|
||||||
|
Add("project.groupId", groupId);
|
||||||
|
Add("groupId", groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(artifactId))
|
||||||
|
{
|
||||||
|
Add("project.artifactId", artifactId);
|
||||||
|
Add("artifactId", artifactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(version))
|
||||||
|
{
|
||||||
|
Add("project.version", version);
|
||||||
|
Add("version", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds parent coordinates as properties.
|
||||||
|
/// </summary>
|
||||||
|
public JavaPropertyBuilder AddParentCoordinates(JavaParentReference? parent)
|
||||||
|
{
|
||||||
|
if (parent is null) return this;
|
||||||
|
|
||||||
|
Add("project.parent.groupId", parent.GroupId);
|
||||||
|
Add("project.parent.artifactId", parent.ArtifactId);
|
||||||
|
Add("project.parent.version", parent.Version);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds all properties from an existing dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public JavaPropertyBuilder AddRange(IReadOnlyDictionary<string, string>? properties)
|
||||||
|
{
|
||||||
|
if (properties is null) return this;
|
||||||
|
|
||||||
|
foreach (var (key, value) in properties)
|
||||||
|
{
|
||||||
|
Add(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an immutable property dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableDictionary<string, string> Build()
|
||||||
|
=> _properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Shading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detects shaded/shadow JARs that bundle dependencies inside a single artifact.
|
||||||
|
/// </summary>
|
||||||
|
internal static partial class ShadedJarDetector
|
||||||
|
{
|
||||||
|
private static readonly string[] ShadingMarkerFiles =
|
||||||
|
[
|
||||||
|
"META-INF/maven/*/dependency-reduced-pom.xml", // Maven Shade plugin marker
|
||||||
|
"META-INF/maven/*/*/dependency-reduced-pom.xml"
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a JAR archive to detect shading.
|
||||||
|
/// </summary>
|
||||||
|
public static ShadingAnalysis Analyze(ZipArchive archive, string jarPath)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(archive);
|
||||||
|
|
||||||
|
var markers = new List<string>();
|
||||||
|
var embeddedArtifacts = new List<EmbeddedArtifact>();
|
||||||
|
var relocatedPrefixes = new List<string>();
|
||||||
|
|
||||||
|
// Check for multiple pom.properties files (indicates bundled dependencies)
|
||||||
|
var pomPropertiesFiles = archive.Entries
|
||||||
|
.Where(e => e.FullName.EndsWith("pom.properties", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
e.FullName.Contains("META-INF/maven/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (pomPropertiesFiles.Count > 1)
|
||||||
|
{
|
||||||
|
markers.Add("multiple-pom-properties");
|
||||||
|
|
||||||
|
// Parse each pom.properties to extract GAV
|
||||||
|
foreach (var entry in pomPropertiesFiles)
|
||||||
|
{
|
||||||
|
var artifact = ParsePomProperties(entry);
|
||||||
|
if (artifact is not null)
|
||||||
|
{
|
||||||
|
embeddedArtifacts.Add(artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dependency-reduced-pom.xml (Maven Shade plugin marker)
|
||||||
|
var hasReducedPom = archive.Entries.Any(e =>
|
||||||
|
e.FullName.Contains("dependency-reduced-pom.xml", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (hasReducedPom)
|
||||||
|
{
|
||||||
|
markers.Add("dependency-reduced-pom.xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect relocated packages (common patterns)
|
||||||
|
var relocations = DetectRelocatedPackages(archive);
|
||||||
|
relocatedPrefixes.AddRange(relocations);
|
||||||
|
|
||||||
|
if (relocations.Count > 0)
|
||||||
|
{
|
||||||
|
markers.Add("relocated-packages");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for shadow plugin markers
|
||||||
|
var hasShadowMarker = archive.Entries.Any(e =>
|
||||||
|
e.FullName.Contains("shadow/", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
e.FullName.EndsWith(".class", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (hasShadowMarker)
|
||||||
|
{
|
||||||
|
markers.Add("gradle-shadow-plugin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate confidence
|
||||||
|
var confidence = CalculateConfidence(markers, embeddedArtifacts.Count);
|
||||||
|
|
||||||
|
return new ShadingAnalysis(
|
||||||
|
jarPath,
|
||||||
|
confidence >= ShadingConfidence.Medium,
|
||||||
|
confidence,
|
||||||
|
[.. markers],
|
||||||
|
[.. embeddedArtifacts.OrderBy(a => a.Gav, StringComparer.Ordinal)],
|
||||||
|
[.. relocatedPrefixes.Distinct().OrderBy(p => p, StringComparer.Ordinal)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a JAR file from disk.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<ShadingAnalysis> AnalyzeAsync(
|
||||||
|
string jarPath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(jarPath);
|
||||||
|
|
||||||
|
if (!File.Exists(jarPath))
|
||||||
|
{
|
||||||
|
return ShadingAnalysis.NotShaded(jarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||||
|
|
||||||
|
return Analyze(archive, jarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EmbeddedArtifact? ParsePomProperties(ZipArchiveEntry entry)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = entry.Open();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
string? groupId = null;
|
||||||
|
string? artifactId = null;
|
||||||
|
string? version = null;
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
if (line.StartsWith("groupId=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
groupId = line[8..].Trim();
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("artifactId=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
artifactId = line[11..].Trim();
|
||||||
|
}
|
||||||
|
else if (line.StartsWith("version=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
version = line[8..].Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(groupId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(artifactId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
return new EmbeddedArtifact(groupId, artifactId, version, entry.FullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore parsing errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> DetectRelocatedPackages(ZipArchive archive)
|
||||||
|
{
|
||||||
|
var relocations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Common relocation prefixes used by shade/shadow plugins
|
||||||
|
var commonRelocatedPrefixes = new[]
|
||||||
|
{
|
||||||
|
"shaded/",
|
||||||
|
"relocated/",
|
||||||
|
"hidden/",
|
||||||
|
"internal/shaded/",
|
||||||
|
"lib/"
|
||||||
|
};
|
||||||
|
|
||||||
|
var classEntries = archive.Entries
|
||||||
|
.Where(e => e.FullName.EndsWith(".class", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(e => e.FullName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var prefix in commonRelocatedPrefixes)
|
||||||
|
{
|
||||||
|
if (classEntries.Any(c => c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
// Extract the full relocation path
|
||||||
|
var relocated = classEntries
|
||||||
|
.Where(c => c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(c => ExtractPackagePrefix(c))
|
||||||
|
.Where(p => !string.IsNullOrEmpty(p))
|
||||||
|
.Cast<string>()
|
||||||
|
.Distinct()
|
||||||
|
.Take(5); // Limit to avoid noise
|
||||||
|
|
||||||
|
foreach (var r in relocated)
|
||||||
|
{
|
||||||
|
relocations.Add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect common library packages that are often shaded
|
||||||
|
var shadedLibraryPatterns = new[]
|
||||||
|
{
|
||||||
|
@"^([a-z]+)/com/google/",
|
||||||
|
@"^([a-z]+)/org/apache/",
|
||||||
|
@"^([a-z]+)/io/netty/",
|
||||||
|
@"^([a-z]+)/com/fasterxml/",
|
||||||
|
@"^([a-z]+)/org/slf4j/"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var pattern in shadedLibraryPatterns)
|
||||||
|
{
|
||||||
|
var regex = new Regex(pattern, RegexOptions.IgnoreCase);
|
||||||
|
foreach (var classEntry in classEntries)
|
||||||
|
{
|
||||||
|
var match = regex.Match(classEntry);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
relocations.Add(match.Groups[1].Value + "/");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. relocations];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractPackagePrefix(string classPath)
|
||||||
|
{
|
||||||
|
var parts = classPath.Split('/');
|
||||||
|
if (parts.Length >= 3)
|
||||||
|
{
|
||||||
|
// Return first two path segments as the relocation prefix
|
||||||
|
return $"{parts[0]}/{parts[1]}/";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShadingConfidence CalculateConfidence(List<string> markers, int embeddedCount)
|
||||||
|
{
|
||||||
|
var score = 0;
|
||||||
|
|
||||||
|
// Strong indicators
|
||||||
|
if (markers.Contains("dependency-reduced-pom.xml")) score += 3;
|
||||||
|
if (markers.Contains("multiple-pom-properties")) score += 2;
|
||||||
|
if (markers.Contains("gradle-shadow-plugin")) score += 3;
|
||||||
|
|
||||||
|
// Moderate indicators
|
||||||
|
if (markers.Contains("relocated-packages")) score += 1;
|
||||||
|
|
||||||
|
// Embedded artifact count
|
||||||
|
if (embeddedCount > 5) score += 2;
|
||||||
|
else if (embeddedCount > 1) score += 1;
|
||||||
|
|
||||||
|
return score switch
|
||||||
|
{
|
||||||
|
>= 4 => ShadingConfidence.High,
|
||||||
|
>= 2 => ShadingConfidence.Medium,
|
||||||
|
>= 1 => ShadingConfidence.Low,
|
||||||
|
_ => ShadingConfidence.None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of shaded JAR analysis.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record ShadingAnalysis(
|
||||||
|
string JarPath,
|
||||||
|
bool IsShaded,
|
||||||
|
ShadingConfidence Confidence,
|
||||||
|
ImmutableArray<string> Markers,
|
||||||
|
ImmutableArray<EmbeddedArtifact> EmbeddedArtifacts,
|
||||||
|
ImmutableArray<string> RelocatedPrefixes)
|
||||||
|
{
|
||||||
|
public static ShadingAnalysis NotShaded(string jarPath) => new(
|
||||||
|
jarPath,
|
||||||
|
false,
|
||||||
|
ShadingConfidence.None,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[]);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of embedded artifacts.
|
||||||
|
/// </summary>
|
||||||
|
public int EmbeddedCount => EmbeddedArtifacts.Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the embedded artifacts as a comma-separated GAV list.
|
||||||
|
/// </summary>
|
||||||
|
public string GetEmbeddedGavList()
|
||||||
|
=> string.Join(",", EmbeddedArtifacts.Select(a => a.Gav));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an artifact embedded inside a shaded JAR.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record EmbeddedArtifact(
|
||||||
|
string GroupId,
|
||||||
|
string ArtifactId,
|
||||||
|
string Version,
|
||||||
|
string PomPropertiesPath)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the GAV coordinate.
|
||||||
|
/// </summary>
|
||||||
|
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the PURL for this artifact.
|
||||||
|
/// </summary>
|
||||||
|
public string Purl => $"pkg:maven/{GroupId}/{ArtifactId}@{Version}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Confidence level for shading detection.
|
||||||
|
/// </summary>
|
||||||
|
internal enum ShadingConfidence
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Low = 1,
|
||||||
|
Medium = 2,
|
||||||
|
High = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Core.Contracts;
|
||||||
|
|
||||||
|
public sealed record BunPackageInventory(
|
||||||
|
string ScanId,
|
||||||
|
string ImageDigest,
|
||||||
|
DateTimeOffset GeneratedAtUtc,
|
||||||
|
IReadOnlyList<BunPackageArtifact> Packages);
|
||||||
|
|
||||||
|
public sealed record BunPackageArtifact(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("version")] string? Version,
|
||||||
|
[property: JsonPropertyName("source")] string? Source,
|
||||||
|
[property: JsonPropertyName("resolved")] string? Resolved,
|
||||||
|
[property: JsonPropertyName("integrity")] string? Integrity,
|
||||||
|
[property: JsonPropertyName("isDev")] bool? IsDev,
|
||||||
|
[property: JsonPropertyName("isDirect")] bool? IsDirect,
|
||||||
|
[property: JsonPropertyName("isPatched")] bool? IsPatched,
|
||||||
|
[property: JsonPropertyName("provenance")] BunPackageProvenance? Provenance,
|
||||||
|
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string?>? Metadata);
|
||||||
|
|
||||||
|
public sealed record BunPackageProvenance(
|
||||||
|
[property: JsonPropertyName("source")] string? Source,
|
||||||
|
[property: JsonPropertyName("lockfile")] string? Lockfile,
|
||||||
|
[property: JsonPropertyName("locator")] string? Locator);
|
||||||
|
|
||||||
|
public interface IBunPackageInventoryStore
|
||||||
|
{
|
||||||
|
Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NullBunPackageInventoryStore : IBunPackageInventoryStore
|
||||||
|
{
|
||||||
|
public Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(inventory);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||||
|
return Task.FromResult<BunPackageInventory?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
using StellaOps.Scanner.Core.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Storage.Catalog;
|
||||||
|
|
||||||
|
[BsonIgnoreExtraElements]
|
||||||
|
public sealed class BunPackageInventoryDocument
|
||||||
|
{
|
||||||
|
[BsonId]
|
||||||
|
public string ScanId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("imageDigest")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public string? ImageDigest { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("generatedAtUtc")]
|
||||||
|
public DateTime GeneratedAtUtc { get; set; }
|
||||||
|
= DateTime.UtcNow;
|
||||||
|
|
||||||
|
[BsonElement("packages")]
|
||||||
|
public List<BunPackageDocument> Packages { get; set; }
|
||||||
|
= new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[BsonIgnoreExtraElements]
|
||||||
|
public sealed class BunPackageDocument
|
||||||
|
{
|
||||||
|
[BsonElement("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[BsonElement("version")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public string? Version { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("source")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public string? Source { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("resolved")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public string? Resolved { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("integrity")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public string? Integrity { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("isDev")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public bool? IsDev { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("isDirect")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public bool? IsDirect { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("isPatched")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public bool? IsPatched { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("provenance")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public BunPackageProvenance? Provenance { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
[BsonElement("metadata")]
|
||||||
|
[BsonIgnoreIfNull]
|
||||||
|
public Dictionary<string, string?>? Metadata { get; set; }
|
||||||
|
= null;
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using Amazon;
|
using Amazon;
|
||||||
using Amazon.S3;
|
using Amazon.S3;
|
||||||
using Amazon.Runtime;
|
using Amazon.Runtime;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using StellaOps.Scanner.Core.Contracts;
|
using StellaOps.Scanner.Core.Contracts;
|
||||||
using StellaOps.Scanner.EntryTrace;
|
using StellaOps.Scanner.EntryTrace;
|
||||||
using StellaOps.Scanner.Storage.Migrations;
|
using StellaOps.Scanner.Storage.Migrations;
|
||||||
using StellaOps.Scanner.Storage.Mongo;
|
using StellaOps.Scanner.Storage.Mongo;
|
||||||
using StellaOps.Scanner.Storage.ObjectStore;
|
using StellaOps.Scanner.Storage.ObjectStore;
|
||||||
@@ -62,65 +62,67 @@ public static class ServiceCollectionExtensions
|
|||||||
services.TryAddSingleton<MongoBootstrapper>();
|
services.TryAddSingleton<MongoBootstrapper>();
|
||||||
services.TryAddSingleton<ArtifactRepository>();
|
services.TryAddSingleton<ArtifactRepository>();
|
||||||
services.TryAddSingleton<ImageRepository>();
|
services.TryAddSingleton<ImageRepository>();
|
||||||
services.TryAddSingleton<LayerRepository>();
|
services.TryAddSingleton<LayerRepository>();
|
||||||
services.TryAddSingleton<LinkRepository>();
|
services.TryAddSingleton<LinkRepository>();
|
||||||
services.TryAddSingleton<JobRepository>();
|
services.TryAddSingleton<JobRepository>();
|
||||||
services.TryAddSingleton<LifecycleRuleRepository>();
|
services.TryAddSingleton<LifecycleRuleRepository>();
|
||||||
services.TryAddSingleton<RuntimeEventRepository>();
|
services.TryAddSingleton<RuntimeEventRepository>();
|
||||||
services.TryAddSingleton<EntryTraceRepository>();
|
services.TryAddSingleton<EntryTraceRepository>();
|
||||||
services.TryAddSingleton<RubyPackageInventoryRepository>();
|
services.TryAddSingleton<RubyPackageInventoryRepository>();
|
||||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
services.TryAddSingleton<BunPackageInventoryRepository>();
|
||||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||||
|
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||||
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
|
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
||||||
.ConfigureHttpClient((sp, client) =>
|
|
||||||
{
|
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
|
||||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
.ConfigureHttpClient((sp, client) =>
|
||||||
if (!options.IsRustFsDriver())
|
{
|
||||||
{
|
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||||
return;
|
if (!options.IsRustFsDriver())
|
||||||
}
|
{
|
||||||
|
return;
|
||||||
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
|
}
|
||||||
{
|
|
||||||
throw new InvalidOperationException("RustFS baseUrl must be a valid absolute URI.");
|
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
|
||||||
}
|
{
|
||||||
|
throw new InvalidOperationException("RustFS baseUrl must be a valid absolute URI.");
|
||||||
client.BaseAddress = baseUri;
|
}
|
||||||
client.Timeout = options.RustFs.Timeout;
|
|
||||||
|
client.BaseAddress = baseUri;
|
||||||
foreach (var header in options.Headers)
|
client.Timeout = options.RustFs.Timeout;
|
||||||
{
|
|
||||||
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
|
foreach (var header in options.Headers)
|
||||||
}
|
{
|
||||||
|
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
|
}
|
||||||
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
|
|
||||||
{
|
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
|
||||||
client.DefaultRequestHeaders.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
|
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
|
||||||
}
|
{
|
||||||
})
|
client.DefaultRequestHeaders.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
|
||||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
}
|
||||||
{
|
})
|
||||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||||
if (!options.IsRustFsDriver())
|
{
|
||||||
{
|
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||||
return new HttpClientHandler();
|
if (!options.IsRustFsDriver())
|
||||||
}
|
{
|
||||||
|
return new HttpClientHandler();
|
||||||
var handler = new HttpClientHandler();
|
}
|
||||||
if (options.RustFs.AllowInsecureTls)
|
|
||||||
{
|
var handler = new HttpClientHandler();
|
||||||
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
if (options.RustFs.AllowInsecureTls)
|
||||||
}
|
{
|
||||||
|
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||||
return handler;
|
}
|
||||||
});
|
|
||||||
|
return handler;
|
||||||
services.TryAddSingleton(CreateAmazonS3Client);
|
});
|
||||||
services.TryAddSingleton<IArtifactObjectStore>(CreateArtifactObjectStore);
|
|
||||||
services.TryAddSingleton<ArtifactStorageService>();
|
services.TryAddSingleton(CreateAmazonS3Client);
|
||||||
}
|
services.TryAddSingleton<IArtifactObjectStore>(CreateArtifactObjectStore);
|
||||||
|
services.TryAddSingleton<ArtifactStorageService>();
|
||||||
|
}
|
||||||
|
|
||||||
private static IMongoClient CreateMongoClient(IServiceProvider provider)
|
private static IMongoClient CreateMongoClient(IServiceProvider provider)
|
||||||
{
|
{
|
||||||
@@ -149,47 +151,47 @@ public static class ServiceCollectionExtensions
|
|||||||
return client.GetDatabase(databaseName);
|
return client.GetDatabase(databaseName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
|
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
|
||||||
{
|
{
|
||||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||||
var config = new AmazonS3Config
|
var config = new AmazonS3Config
|
||||||
{
|
{
|
||||||
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
|
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
|
||||||
ForcePathStyle = options.ForcePathStyle,
|
ForcePathStyle = options.ForcePathStyle,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
|
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
|
||||||
{
|
{
|
||||||
config.ServiceURL = options.ServiceUrl;
|
config.ServiceURL = options.ServiceUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey))
|
if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey))
|
||||||
{
|
{
|
||||||
AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken)
|
AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken)
|
||||||
? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey)
|
? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey)
|
||||||
: new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken);
|
: new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken);
|
||||||
return new AmazonS3Client(credentials, config);
|
return new AmazonS3Client(credentials, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AmazonS3Client(config);
|
return new AmazonS3Client(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IArtifactObjectStore CreateArtifactObjectStore(IServiceProvider provider)
|
private static IArtifactObjectStore CreateArtifactObjectStore(IServiceProvider provider)
|
||||||
{
|
{
|
||||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>();
|
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>();
|
||||||
var objectStore = options.Value.ObjectStore;
|
var objectStore = options.Value.ObjectStore;
|
||||||
|
|
||||||
if (objectStore.IsRustFsDriver())
|
if (objectStore.IsRustFsDriver())
|
||||||
{
|
{
|
||||||
return new RustFsArtifactObjectStore(
|
return new RustFsArtifactObjectStore(
|
||||||
provider.GetRequiredService<IHttpClientFactory>(),
|
provider.GetRequiredService<IHttpClientFactory>(),
|
||||||
options,
|
options,
|
||||||
provider.GetRequiredService<ILogger<RustFsArtifactObjectStore>>());
|
provider.GetRequiredService<ILogger<RustFsArtifactObjectStore>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new S3ArtifactObjectStore(
|
return new S3ArtifactObjectStore(
|
||||||
provider.GetRequiredService<IAmazonS3>(),
|
provider.GetRequiredService<IAmazonS3>(),
|
||||||
options,
|
options,
|
||||||
provider.GetRequiredService<ILogger<S3ArtifactObjectStore>>());
|
provider.GetRequiredService<ILogger<S3ArtifactObjectStore>>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,15 @@ public sealed class MongoCollectionProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
public IMongoCollection<ArtifactDocument> Artifacts => GetCollection<ArtifactDocument>(ScannerStorageDefaults.Collections.Artifacts);
|
public IMongoCollection<ArtifactDocument> Artifacts => GetCollection<ArtifactDocument>(ScannerStorageDefaults.Collections.Artifacts);
|
||||||
public IMongoCollection<ImageDocument> Images => GetCollection<ImageDocument>(ScannerStorageDefaults.Collections.Images);
|
public IMongoCollection<ImageDocument> Images => GetCollection<ImageDocument>(ScannerStorageDefaults.Collections.Images);
|
||||||
public IMongoCollection<LayerDocument> Layers => GetCollection<LayerDocument>(ScannerStorageDefaults.Collections.Layers);
|
public IMongoCollection<LayerDocument> Layers => GetCollection<LayerDocument>(ScannerStorageDefaults.Collections.Layers);
|
||||||
public IMongoCollection<LinkDocument> Links => GetCollection<LinkDocument>(ScannerStorageDefaults.Collections.Links);
|
public IMongoCollection<LinkDocument> Links => GetCollection<LinkDocument>(ScannerStorageDefaults.Collections.Links);
|
||||||
public IMongoCollection<JobDocument> Jobs => GetCollection<JobDocument>(ScannerStorageDefaults.Collections.Jobs);
|
public IMongoCollection<JobDocument> Jobs => GetCollection<JobDocument>(ScannerStorageDefaults.Collections.Jobs);
|
||||||
public IMongoCollection<LifecycleRuleDocument> LifecycleRules => GetCollection<LifecycleRuleDocument>(ScannerStorageDefaults.Collections.LifecycleRules);
|
public IMongoCollection<LifecycleRuleDocument> LifecycleRules => GetCollection<LifecycleRuleDocument>(ScannerStorageDefaults.Collections.LifecycleRules);
|
||||||
public IMongoCollection<RuntimeEventDocument> RuntimeEvents => GetCollection<RuntimeEventDocument>(ScannerStorageDefaults.Collections.RuntimeEvents);
|
public IMongoCollection<RuntimeEventDocument> RuntimeEvents => GetCollection<RuntimeEventDocument>(ScannerStorageDefaults.Collections.RuntimeEvents);
|
||||||
public IMongoCollection<EntryTraceDocument> EntryTrace => GetCollection<EntryTraceDocument>(ScannerStorageDefaults.Collections.EntryTrace);
|
public IMongoCollection<EntryTraceDocument> EntryTrace => GetCollection<EntryTraceDocument>(ScannerStorageDefaults.Collections.EntryTrace);
|
||||||
public IMongoCollection<RubyPackageInventoryDocument> RubyPackages => GetCollection<RubyPackageInventoryDocument>(ScannerStorageDefaults.Collections.RubyPackages);
|
public IMongoCollection<RubyPackageInventoryDocument> RubyPackages => GetCollection<RubyPackageInventoryDocument>(ScannerStorageDefaults.Collections.RubyPackages);
|
||||||
|
public IMongoCollection<BunPackageInventoryDocument> BunPackages => GetCollection<BunPackageInventoryDocument>(ScannerStorageDefaults.Collections.BunPackages);
|
||||||
|
|
||||||
private IMongoCollection<TDocument> GetCollection<TDocument>(string name)
|
private IMongoCollection<TDocument> GetCollection<TDocument>(string name)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using StellaOps.Scanner.Storage.Catalog;
|
||||||
|
using StellaOps.Scanner.Storage.Mongo;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Storage.Repositories;
|
||||||
|
|
||||||
|
public sealed class BunPackageInventoryRepository
|
||||||
|
{
|
||||||
|
private readonly MongoCollectionProvider _collections;
|
||||||
|
|
||||||
|
public BunPackageInventoryRepository(MongoCollectionProvider collections)
|
||||||
|
{
|
||||||
|
_collections = collections ?? throw new ArgumentNullException(nameof(collections));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BunPackageInventoryDocument?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||||
|
return await _collections.BunPackages
|
||||||
|
.Find(x => x.ScanId == scanId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertAsync(BunPackageInventoryDocument document, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(document);
|
||||||
|
var options = new ReplaceOptions { IsUpsert = true };
|
||||||
|
await _collections.BunPackages
|
||||||
|
.ReplaceOneAsync(x => x.ScanId == document.ScanId, document, options, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
namespace StellaOps.Scanner.Storage;
|
namespace StellaOps.Scanner.Storage;
|
||||||
|
|
||||||
public static class ScannerStorageDefaults
|
public static class ScannerStorageDefaults
|
||||||
{
|
{
|
||||||
public const string DefaultDatabaseName = "scanner";
|
public const string DefaultDatabaseName = "scanner";
|
||||||
public const string DefaultBucketName = "stellaops";
|
public const string DefaultBucketName = "stellaops";
|
||||||
public const string DefaultRootPrefix = "scanner";
|
public const string DefaultRootPrefix = "scanner";
|
||||||
|
|
||||||
@@ -24,9 +24,10 @@ public static class ScannerStorageDefaults
|
|||||||
public const string RuntimeEvents = "runtime.events";
|
public const string RuntimeEvents = "runtime.events";
|
||||||
public const string EntryTrace = "entrytrace";
|
public const string EntryTrace = "entrytrace";
|
||||||
public const string RubyPackages = "ruby.packages";
|
public const string RubyPackages = "ruby.packages";
|
||||||
|
public const string BunPackages = "bun.packages";
|
||||||
public const string Migrations = "schema_migrations";
|
public const string Migrations = "schema_migrations";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ObjectPrefixes
|
public static class ObjectPrefixes
|
||||||
{
|
{
|
||||||
public const string Layers = "layers";
|
public const string Layers = "layers";
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Scanner.Core.Contracts;
|
||||||
|
using StellaOps.Scanner.Storage.Catalog;
|
||||||
|
using StellaOps.Scanner.Storage.Repositories;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Storage.Services;
|
||||||
|
|
||||||
|
public sealed class BunPackageInventoryStore : IBunPackageInventoryStore
|
||||||
|
{
|
||||||
|
private readonly BunPackageInventoryRepository _repository;
|
||||||
|
|
||||||
|
public BunPackageInventoryStore(BunPackageInventoryRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(inventory);
|
||||||
|
|
||||||
|
var document = new BunPackageInventoryDocument
|
||||||
|
{
|
||||||
|
ScanId = inventory.ScanId,
|
||||||
|
ImageDigest = inventory.ImageDigest,
|
||||||
|
GeneratedAtUtc = inventory.GeneratedAtUtc.UtcDateTime,
|
||||||
|
Packages = inventory.Packages.Select(ToDocument).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await _repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||||
|
|
||||||
|
var document = await _repository.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (document is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var generatedAt = DateTime.SpecifyKind(document.GeneratedAtUtc, DateTimeKind.Utc);
|
||||||
|
var packages = document.Packages?.Select(FromDocument).ToImmutableArray()
|
||||||
|
?? ImmutableArray<BunPackageArtifact>.Empty;
|
||||||
|
|
||||||
|
return new BunPackageInventory(
|
||||||
|
document.ScanId,
|
||||||
|
document.ImageDigest ?? string.Empty,
|
||||||
|
new DateTimeOffset(generatedAt),
|
||||||
|
packages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BunPackageDocument ToDocument(BunPackageArtifact artifact)
|
||||||
|
{
|
||||||
|
var doc = new BunPackageDocument
|
||||||
|
{
|
||||||
|
Id = artifact.Id,
|
||||||
|
Name = artifact.Name,
|
||||||
|
Version = artifact.Version,
|
||||||
|
Source = artifact.Source,
|
||||||
|
Resolved = artifact.Resolved,
|
||||||
|
Integrity = artifact.Integrity,
|
||||||
|
IsDev = artifact.IsDev,
|
||||||
|
IsDirect = artifact.IsDirect,
|
||||||
|
IsPatched = artifact.IsPatched,
|
||||||
|
Provenance = artifact.Provenance,
|
||||||
|
Metadata = artifact.Metadata is null ? null : new Dictionary<string, string?>(artifact.Metadata, StringComparer.OrdinalIgnoreCase)
|
||||||
|
};
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BunPackageArtifact FromDocument(BunPackageDocument document)
|
||||||
|
{
|
||||||
|
IReadOnlyDictionary<string, string?>? metadata = document.Metadata;
|
||||||
|
|
||||||
|
return new BunPackageArtifact(
|
||||||
|
document.Id,
|
||||||
|
document.Name,
|
||||||
|
document.Version,
|
||||||
|
document.Source,
|
||||||
|
document.Resolved,
|
||||||
|
document.Integrity,
|
||||||
|
document.IsDev,
|
||||||
|
document.IsDirect,
|
||||||
|
document.IsPatched,
|
||||||
|
document.Provenance,
|
||||||
|
metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Client.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service collection extensions for registering the TaskRunner client.
|
||||||
|
/// </summary>
|
||||||
|
public static class TaskRunnerClientServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the TaskRunner client to the service collection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">Service collection.</param>
|
||||||
|
/// <param name="configuration">Configuration.</param>
|
||||||
|
/// <returns>HTTP client builder for further configuration.</returns>
|
||||||
|
public static IHttpClientBuilder AddTaskRunnerClient(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
|
||||||
|
services.Configure<TaskRunnerClientOptions>(
|
||||||
|
configuration.GetSection(TaskRunnerClientOptions.SectionName));
|
||||||
|
|
||||||
|
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = configuration
|
||||||
|
.GetSection(TaskRunnerClientOptions.SectionName)
|
||||||
|
.Get<TaskRunnerClientOptions>();
|
||||||
|
|
||||||
|
if (options is not null && !string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options?.UserAgent))
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the TaskRunner client to the service collection with custom options.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">Service collection.</param>
|
||||||
|
/// <param name="configureOptions">Options configuration action.</param>
|
||||||
|
/// <returns>HTTP client builder for further configuration.</returns>
|
||||||
|
public static IHttpClientBuilder AddTaskRunnerClient(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<TaskRunnerClientOptions> configureOptions)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||||
|
|
||||||
|
services.Configure(configureOptions);
|
||||||
|
|
||||||
|
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = new TaskRunnerClientOptions();
|
||||||
|
configureOptions(options);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.UserAgent))
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using StellaOps.TaskRunner.Client.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client interface for the TaskRunner WebService API.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITaskRunnerClient
|
||||||
|
{
|
||||||
|
#region Pack Runs
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new pack run.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Run creation request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Created run response.</returns>
|
||||||
|
Task<CreatePackRunResponse> CreateRunAsync(
|
||||||
|
CreatePackRunRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current state of a pack run.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Pack run state or null if not found.</returns>
|
||||||
|
Task<PackRunState?> GetRunAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels a running pack run.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Cancel response.</returns>
|
||||||
|
Task<CancelRunResponse> CancelRunAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Approvals
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies an approval decision to a pending approval gate.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="approvalId">Approval gate identifier.</param>
|
||||||
|
/// <param name="request">Decision request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Approval decision response.</returns>
|
||||||
|
Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
|
||||||
|
string runId,
|
||||||
|
string approvalId,
|
||||||
|
ApprovalDecisionRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Logs
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams log entries for a pack run as NDJSON.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Async enumerable of log entries.</returns>
|
||||||
|
IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Artifacts
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists artifacts produced by a pack run.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Artifact list response.</returns>
|
||||||
|
Task<ArtifactListResponse> ListArtifactsAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Simulation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simulates a task pack execution without running it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Simulation request.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Simulation result.</returns>
|
||||||
|
Task<SimulatePackResponse> SimulateAsync(
|
||||||
|
SimulatePackRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Metadata
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets OpenAPI metadata including spec URL, version, and signature.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>OpenAPI metadata.</returns>
|
||||||
|
Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenAPI metadata from /.well-known/openapi endpoint.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OpenApiMetadata(
|
||||||
|
[property: System.Text.Json.Serialization.JsonPropertyName("specUrl")] string SpecUrl,
|
||||||
|
[property: System.Text.Json.Serialization.JsonPropertyName("version")] string Version,
|
||||||
|
[property: System.Text.Json.Serialization.JsonPropertyName("buildVersion")] string BuildVersion,
|
||||||
|
[property: System.Text.Json.Serialization.JsonPropertyName("eTag")] string ETag,
|
||||||
|
[property: System.Text.Json.Serialization.JsonPropertyName("signature")] string Signature);
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
using StellaOps.TaskRunner.Client.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Client.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for pack run lifecycle operations.
|
||||||
|
/// </summary>
|
||||||
|
public static class PackRunLifecycleHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Terminal statuses for pack runs.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly IReadOnlySet<string> TerminalStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"rejected"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a run and waits for it to reach a terminal state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">TaskRunner client.</param>
|
||||||
|
/// <param name="request">Run creation request.</param>
|
||||||
|
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||||
|
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Final pack run state.</returns>
|
||||||
|
public static async Task<PackRunState> CreateAndWaitAsync(
|
||||||
|
ITaskRunnerClient client,
|
||||||
|
CreatePackRunRequest request,
|
||||||
|
TimeSpan? pollInterval = null,
|
||||||
|
TimeSpan? timeout = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||||
|
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
return await WaitForCompletionAsync(client, createResponse.RunId, interval, maxWait, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits for a pack run to reach a terminal state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">TaskRunner client.</param>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||||
|
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Final pack run state.</returns>
|
||||||
|
public static async Task<PackRunState> WaitForCompletionAsync(
|
||||||
|
ITaskRunnerClient client,
|
||||||
|
string runId,
|
||||||
|
TimeSpan? pollInterval = null,
|
||||||
|
TimeSpan? timeout = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
|
||||||
|
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||||
|
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(maxWait);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
|
||||||
|
if (state is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Run '{runId}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TerminalStatuses.Contains(state.Status))
|
||||||
|
{
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits for a pack run to reach a pending approval state.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">TaskRunner client.</param>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||||
|
/// <param name="timeout">Maximum time to wait (default: 10 minutes).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Pack run state with pending approvals, or null if run completed without approvals.</returns>
|
||||||
|
public static async Task<PackRunState?> WaitForApprovalAsync(
|
||||||
|
ITaskRunnerClient client,
|
||||||
|
string runId,
|
||||||
|
TimeSpan? pollInterval = null,
|
||||||
|
TimeSpan? timeout = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
|
||||||
|
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||||
|
var maxWait = timeout ?? TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(maxWait);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
|
||||||
|
if (state is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Run '{runId}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TerminalStatuses.Contains(state.Status))
|
||||||
|
{
|
||||||
|
return null; // Completed without needing approval
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.PendingApprovals is { Count: > 0 })
|
||||||
|
{
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Approves all pending approvals for a run.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">TaskRunner client.</param>
|
||||||
|
/// <param name="runId">Run identifier.</param>
|
||||||
|
/// <param name="planHash">Expected plan hash.</param>
|
||||||
|
/// <param name="actorId">Actor applying the approval.</param>
|
||||||
|
/// <param name="summary">Approval summary.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Number of approvals applied.</returns>
|
||||||
|
public static async Task<int> ApproveAllAsync(
|
||||||
|
ITaskRunnerClient client,
|
||||||
|
string runId,
|
||||||
|
string planHash,
|
||||||
|
string? actorId = null,
|
||||||
|
string? summary = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(planHash);
|
||||||
|
|
||||||
|
var state = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (state?.PendingApprovals is null or { Count: 0 })
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
foreach (var approval in state.PendingApprovals)
|
||||||
|
{
|
||||||
|
var request = new ApprovalDecisionRequest("approved", planHash, actorId, summary);
|
||||||
|
await client.ApplyApprovalDecisionAsync(runId, approval.ApprovalId, request, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a run, auto-approves when needed, and waits for completion.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">TaskRunner client.</param>
|
||||||
|
/// <param name="request">Run creation request.</param>
|
||||||
|
/// <param name="actorId">Actor for auto-approval.</param>
|
||||||
|
/// <param name="pollInterval">Interval between status checks.</param>
|
||||||
|
/// <param name="timeout">Maximum time to wait.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Final pack run state.</returns>
|
||||||
|
public static async Task<PackRunState> CreateRunAndAutoApproveAsync(
|
||||||
|
ITaskRunnerClient client,
|
||||||
|
CreatePackRunRequest request,
|
||||||
|
string? actorId = null,
|
||||||
|
TimeSpan? pollInterval = null,
|
||||||
|
TimeSpan? timeout = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||||
|
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
var runId = createResponse.RunId;
|
||||||
|
var planHash = createResponse.PlanHash;
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(maxWait);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
|
||||||
|
if (state is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Run '{runId}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TerminalStatuses.Contains(state.Status))
|
||||||
|
{
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.PendingApprovals is { Count: > 0 })
|
||||||
|
{
|
||||||
|
await ApproveAllAsync(client, runId, planHash, actorId, "Auto-approved by SDK", cts.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to create a new pack run.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreatePackRunRequest(
|
||||||
|
[property: JsonPropertyName("packId")] string PackId,
|
||||||
|
[property: JsonPropertyName("packVersion")] string? PackVersion = null,
|
||||||
|
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null,
|
||||||
|
[property: JsonPropertyName("tenantId")] string? TenantId = null,
|
||||||
|
[property: JsonPropertyName("correlationId")] string? CorrelationId = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response from creating a pack run.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CreatePackRunResponse(
|
||||||
|
[property: JsonPropertyName("runId")] string RunId,
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("planHash")] string PlanHash,
|
||||||
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pack run state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PackRunState(
|
||||||
|
[property: JsonPropertyName("runId")] string RunId,
|
||||||
|
[property: JsonPropertyName("packId")] string PackId,
|
||||||
|
[property: JsonPropertyName("packVersion")] string PackVersion,
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("planHash")] string PlanHash,
|
||||||
|
[property: JsonPropertyName("currentStepId")] string? CurrentStepId,
|
||||||
|
[property: JsonPropertyName("steps")] IReadOnlyList<PackRunStepState> Steps,
|
||||||
|
[property: JsonPropertyName("pendingApprovals")] IReadOnlyList<PendingApproval>? PendingApprovals,
|
||||||
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||||
|
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||||
|
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
|
||||||
|
[property: JsonPropertyName("error")] PackRunError? Error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// State of a single step in a pack run.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PackRunStepState(
|
||||||
|
[property: JsonPropertyName("stepId")] string StepId,
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||||
|
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
|
||||||
|
[property: JsonPropertyName("retryCount")] int RetryCount,
|
||||||
|
[property: JsonPropertyName("outputs")] IReadOnlyDictionary<string, object>? Outputs);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pending approval gate.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PendingApproval(
|
||||||
|
[property: JsonPropertyName("approvalId")] string ApprovalId,
|
||||||
|
[property: JsonPropertyName("stepId")] string StepId,
|
||||||
|
[property: JsonPropertyName("message")] string? Message,
|
||||||
|
[property: JsonPropertyName("requiredGrants")] IReadOnlyList<string> RequiredGrants,
|
||||||
|
[property: JsonPropertyName("requestedAt")] DateTimeOffset RequestedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pack run error information.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PackRunError(
|
||||||
|
[property: JsonPropertyName("code")] string Code,
|
||||||
|
[property: JsonPropertyName("message")] string Message,
|
||||||
|
[property: JsonPropertyName("stepId")] string? StepId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to apply an approval decision.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ApprovalDecisionRequest(
|
||||||
|
[property: JsonPropertyName("decision")] string Decision,
|
||||||
|
[property: JsonPropertyName("planHash")] string PlanHash,
|
||||||
|
[property: JsonPropertyName("actorId")] string? ActorId = null,
|
||||||
|
[property: JsonPropertyName("summary")] string? Summary = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response from applying an approval decision.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ApprovalDecisionResponse(
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("resumed")] bool Resumed);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to simulate a task pack.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SimulatePackRequest(
|
||||||
|
[property: JsonPropertyName("manifest")] string Manifest,
|
||||||
|
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simulation result for a task pack.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SimulatePackResponse(
|
||||||
|
[property: JsonPropertyName("valid")] bool Valid,
|
||||||
|
[property: JsonPropertyName("planHash")] string? PlanHash,
|
||||||
|
[property: JsonPropertyName("steps")] IReadOnlyList<SimulatedStep> Steps,
|
||||||
|
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simulated step in a pack run.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SimulatedStep(
|
||||||
|
[property: JsonPropertyName("stepId")] string StepId,
|
||||||
|
[property: JsonPropertyName("kind")] string Kind,
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("loopInfo")] LoopInfo? LoopInfo,
|
||||||
|
[property: JsonPropertyName("conditionalInfo")] ConditionalInfo? ConditionalInfo,
|
||||||
|
[property: JsonPropertyName("policyInfo")] PolicyInfo? PolicyInfo);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loop step simulation info.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LoopInfo(
|
||||||
|
[property: JsonPropertyName("itemsExpression")] string? ItemsExpression,
|
||||||
|
[property: JsonPropertyName("iterator")] string Iterator,
|
||||||
|
[property: JsonPropertyName("maxIterations")] int MaxIterations);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conditional step simulation info.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ConditionalInfo(
|
||||||
|
[property: JsonPropertyName("branches")] IReadOnlyList<BranchInfo> Branches,
|
||||||
|
[property: JsonPropertyName("hasElse")] bool HasElse);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conditional branch info.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BranchInfo(
|
||||||
|
[property: JsonPropertyName("condition")] string Condition,
|
||||||
|
[property: JsonPropertyName("stepCount")] int StepCount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Policy gate simulation info.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PolicyInfo(
|
||||||
|
[property: JsonPropertyName("policyId")] string PolicyId,
|
||||||
|
[property: JsonPropertyName("failureAction")] string FailureAction);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Artifact metadata.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ArtifactInfo(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("path")] string Path,
|
||||||
|
[property: JsonPropertyName("size")] long Size,
|
||||||
|
[property: JsonPropertyName("sha256")] string Sha256,
|
||||||
|
[property: JsonPropertyName("contentType")] string? ContentType,
|
||||||
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of artifacts.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ArtifactListResponse(
|
||||||
|
[property: JsonPropertyName("artifacts")] IReadOnlyList<ArtifactInfo> Artifacts);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Run log entry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RunLogEntry(
|
||||||
|
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||||
|
[property: JsonPropertyName("level")] string Level,
|
||||||
|
[property: JsonPropertyName("stepId")] string? StepId,
|
||||||
|
[property: JsonPropertyName("message")] string Message,
|
||||||
|
[property: JsonPropertyName("traceId")] string? TraceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancel run response.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CancelRunResponse(
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("message")] string? Message);
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Client.Pagination;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic paginator for API responses.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of items being paginated.</typeparam>
|
||||||
|
public sealed class Paginator<T>
|
||||||
|
{
|
||||||
|
private readonly Func<int, int, CancellationToken, Task<PagedResponse<T>>> _fetchPage;
|
||||||
|
private readonly int _pageSize;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new paginator.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fetchPage">Function to fetch a page (offset, limit, cancellationToken) -> page.</param>
|
||||||
|
/// <param name="pageSize">Number of items per page (default: 50).</param>
|
||||||
|
public Paginator(
|
||||||
|
Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
|
||||||
|
int pageSize = 50)
|
||||||
|
{
|
||||||
|
_fetchPage = fetchPage ?? throw new ArgumentNullException(nameof(fetchPage));
|
||||||
|
_pageSize = pageSize > 0 ? pageSize : throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Iterates through all pages asynchronously.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Async enumerable of items.</returns>
|
||||||
|
public async IAsyncEnumerable<T> GetAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var page = await _fetchPage(offset, _pageSize, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var item in page.Items)
|
||||||
|
{
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!page.HasMore || page.Items.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += page.Items.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects all items into a list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>List of all items.</returns>
|
||||||
|
public async Task<IReadOnlyList<T>> CollectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var items = new List<T>();
|
||||||
|
|
||||||
|
await foreach (var item in GetAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a single page.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pageNumber">Page number (1-based).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Single page response.</returns>
|
||||||
|
public Task<PagedResponse<T>> GetPageAsync(int pageNumber, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (pageNumber < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(pageNumber), "Page number must be >= 1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = (pageNumber - 1) * _pageSize;
|
||||||
|
return _fetchPage(offset, _pageSize, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paginated response wrapper.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of items.</typeparam>
|
||||||
|
public sealed record PagedResponse<T>(
|
||||||
|
IReadOnlyList<T> Items,
|
||||||
|
int TotalCount,
|
||||||
|
bool HasMore)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an empty page.
|
||||||
|
/// </summary>
|
||||||
|
public static PagedResponse<T> Empty { get; } = new([], 0, false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current page number (1-based) based on offset and page size.
|
||||||
|
/// </summary>
|
||||||
|
public int PageNumber(int offset, int pageSize)
|
||||||
|
=> pageSize > 0 ? (offset / pageSize) + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for creating paginators.
|
||||||
|
/// </summary>
|
||||||
|
public static class PaginatorExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a paginator from a fetch function.
|
||||||
|
/// </summary>
|
||||||
|
public static Paginator<T> Paginate<T>(
|
||||||
|
this Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
|
||||||
|
int pageSize = 50)
|
||||||
|
=> new(fetchPage, pageSize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the first N items from an async enumerable.
|
||||||
|
/// </summary>
|
||||||
|
public static async IAsyncEnumerable<T> TakeAsync<T>(
|
||||||
|
this IAsyncEnumerable<T> source,
|
||||||
|
int count,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
|
||||||
|
if (count <= 0)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var taken = 0;
|
||||||
|
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
yield return item;
|
||||||
|
taken++;
|
||||||
|
if (taken >= count)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Skips the first N items from an async enumerable.
|
||||||
|
/// </summary>
|
||||||
|
public static async IAsyncEnumerable<T> SkipAsync<T>(
|
||||||
|
this IAsyncEnumerable<T> source,
|
||||||
|
int count,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
|
||||||
|
var skipped = 0;
|
||||||
|
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (skipped < count)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<Description>SDK client for StellaOps TaskRunner WebService API</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using StellaOps.TaskRunner.Client.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Client.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper for reading NDJSON streaming logs.
|
||||||
|
/// </summary>
|
||||||
|
public static class StreamingLogReader
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads log entries from an NDJSON stream.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stream">The input stream containing NDJSON log entries.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Async enumerable of log entries.</returns>
|
||||||
|
public static async IAsyncEnumerable<RunLogEntry> ReadAsync(
|
||||||
|
Stream stream,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(stream);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLogEntry? entry;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry is not null)
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects all log entries from a stream into a list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stream">The input stream containing NDJSON log entries.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>List of all log entries.</returns>
|
||||||
|
public static async Task<IReadOnlyList<RunLogEntry>> CollectAsync(
|
||||||
|
Stream stream,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entries = new List<RunLogEntry>();
|
||||||
|
|
||||||
|
await foreach (var entry in ReadAsync(stream, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
entries.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters log entries by level.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entries">Source log entries.</param>
|
||||||
|
/// <param name="levels">Log levels to include (e.g., "error", "warning").</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Filtered log entries.</returns>
|
||||||
|
public static async IAsyncEnumerable<RunLogEntry> FilterByLevelAsync(
|
||||||
|
IAsyncEnumerable<RunLogEntry> entries,
|
||||||
|
IReadOnlySet<string> levels,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
ArgumentNullException.ThrowIfNull(levels);
|
||||||
|
|
||||||
|
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (levels.Contains(entry.Level, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters log entries by step ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entries">Source log entries.</param>
|
||||||
|
/// <param name="stepId">Step ID to filter by.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Filtered log entries.</returns>
|
||||||
|
public static async IAsyncEnumerable<RunLogEntry> FilterByStepAsync(
|
||||||
|
IAsyncEnumerable<RunLogEntry> entries,
|
||||||
|
string stepId,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(stepId);
|
||||||
|
|
||||||
|
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (string.Equals(entry.StepId, stepId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups log entries by step ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entries">Source log entries.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Dictionary of step ID to log entries.</returns>
|
||||||
|
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<RunLogEntry>>> GroupByStepAsync(
|
||||||
|
IAsyncEnumerable<RunLogEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
|
||||||
|
var groups = new Dictionary<string, List<RunLogEntry>>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var key = entry.StepId ?? "(global)";
|
||||||
|
if (!groups.TryGetValue(key, out var list))
|
||||||
|
{
|
||||||
|
list = [];
|
||||||
|
groups[key] = list;
|
||||||
|
}
|
||||||
|
list.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.ToDictionary(
|
||||||
|
kvp => kvp.Key,
|
||||||
|
kvp => (IReadOnlyList<RunLogEntry>)kvp.Value,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.TaskRunner.Client.Models;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP implementation of <see cref="ITaskRunnerClient"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TaskRunnerClient : ITaskRunnerClient
|
||||||
|
{
|
||||||
|
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IOptionsMonitor<TaskRunnerClientOptions> _options;
|
||||||
|
private readonly ILogger<TaskRunnerClient>? _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TaskRunnerClient"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public TaskRunnerClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IOptionsMonitor<TaskRunnerClientOptions> options,
|
||||||
|
ILogger<TaskRunnerClient>? logger = null)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Pack Runs
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CreatePackRunResponse> CreateRunAsync(
|
||||||
|
CreatePackRunRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var url = BuildUrl("/runs");
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<CreatePackRunResponse>(JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<PackRunState?> GetRunAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
|
||||||
|
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}");
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
|
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<PackRunState>(JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CancelRunResponse> CancelRunAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
|
||||||
|
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/cancel");
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
|
|
||||||
|
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<CancelRunResponse>(JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Approvals
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
|
||||||
|
string runId,
|
||||||
|
string approvalId,
|
||||||
|
ApprovalDecisionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/approvals/{Uri.EscapeDataString(approvalId)}");
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<ApprovalDecisionResponse>(JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Logs
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
|
||||||
|
string runId,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
|
||||||
|
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/logs");
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-ndjson"));
|
||||||
|
|
||||||
|
// Use longer timeout for streaming
|
||||||
|
var streamingTimeout = TimeSpan.FromSeconds(_options.CurrentValue.StreamingTimeoutSeconds);
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(streamingTimeout);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(
|
||||||
|
httpRequest,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false);
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync(cts.Token).ConfigureAwait(false)) is not null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLogEntry? entry;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Failed to parse log entry: {Line}", line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry is not null)
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Artifacts
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ArtifactListResponse> ListArtifactsAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||||
|
|
||||||
|
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/artifacts");
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
|
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<ArtifactListResponse>(JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result ?? new ArtifactListResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Simulation
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SimulatePackResponse> SimulateAsync(
|
||||||
|
SimulatePackRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var url = BuildUrl("/simulations");
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<SimulatePackResponse>(JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Metadata
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
var url = new Uri(new Uri(options.BaseUrl), "/.well-known/openapi");
|
||||||
|
|
||||||
|
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
|
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<OpenApiMetadata>(JsonOptions, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helpers
|
||||||
|
|
||||||
|
private Uri BuildUrl(string path)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
var baseUrl = options.BaseUrl.TrimEnd('/');
|
||||||
|
var apiPath = options.ApiPath.TrimEnd('/');
|
||||||
|
return new Uri($"{baseUrl}{apiPath}{path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.UserAgent))
|
||||||
|
{
|
||||||
|
request.Headers.UserAgent.TryParseAdd(options.UserAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
|
||||||
|
|
||||||
|
return await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace StellaOps.TaskRunner.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for the TaskRunner client.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TaskRunnerClientOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration section name.
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "TaskRunner:Client";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base URL for the TaskRunner API (e.g., "https://taskrunner.example.com").
|
||||||
|
/// </summary>
|
||||||
|
public string BaseUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API version path prefix (default: "/v1/task-runner").
|
||||||
|
/// </summary>
|
||||||
|
public string ApiPath { get; set; } = "/v1/task-runner";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timeout for HTTP requests in seconds (default: 30).
|
||||||
|
/// </summary>
|
||||||
|
public int TimeoutSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timeout for streaming log requests in seconds (default: 300).
|
||||||
|
/// </summary>
|
||||||
|
public int StreamingTimeoutSeconds { get; set; } = 300;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of retry attempts for transient failures (default: 3).
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRetries { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User-Agent header value for requests.
|
||||||
|
/// </summary>
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider for retrieving air-gap sealed mode status.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAirGapStatusProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current sealed mode status of the environment.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tenantId">Optional tenant ID for multi-tenant environments.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The sealed mode status.</returns>
|
||||||
|
Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using StellaOps.TaskRunner.Core.Events;
|
||||||
|
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit logger for sealed install enforcement decisions.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISealedInstallAuditLogger
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Logs an enforcement decision.
|
||||||
|
/// </summary>
|
||||||
|
Task LogEnforcementAsync(
|
||||||
|
TaskPackManifest manifest,
|
||||||
|
SealedInstallEnforcementResult result,
|
||||||
|
string? tenantId = null,
|
||||||
|
string? runId = null,
|
||||||
|
string? actor = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of sealed install audit logger using timeline events.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SealedInstallAuditLogger : ISealedInstallAuditLogger
|
||||||
|
{
|
||||||
|
private readonly IPackRunTimelineEventEmitter _eventEmitter;
|
||||||
|
|
||||||
|
public SealedInstallAuditLogger(IPackRunTimelineEventEmitter eventEmitter)
|
||||||
|
{
|
||||||
|
_eventEmitter = eventEmitter ?? throw new ArgumentNullException(nameof(eventEmitter));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task LogEnforcementAsync(
|
||||||
|
TaskPackManifest manifest,
|
||||||
|
SealedInstallEnforcementResult result,
|
||||||
|
string? tenantId = null,
|
||||||
|
string? runId = null,
|
||||||
|
string? actor = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
|
||||||
|
var effectiveTenantId = tenantId ?? "default";
|
||||||
|
var effectiveRunId = runId ?? Guid.NewGuid().ToString("n");
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var eventType = result.Allowed
|
||||||
|
? PackRunEventTypes.SealedInstallAllowed
|
||||||
|
: PackRunEventTypes.SealedInstallDenied;
|
||||||
|
|
||||||
|
var severity = result.Allowed
|
||||||
|
? PackRunEventSeverity.Info
|
||||||
|
: PackRunEventSeverity.Warning;
|
||||||
|
|
||||||
|
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["pack_name"] = manifest.Metadata.Name,
|
||||||
|
["pack_version"] = manifest.Metadata.Version,
|
||||||
|
["decision"] = result.Allowed ? "allowed" : "denied",
|
||||||
|
["sealed_install_required"] = manifest.Spec.SealedInstall.ToString().ToLowerInvariant()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
|
||||||
|
{
|
||||||
|
attributes["error_code"] = result.ErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
object payload;
|
||||||
|
if (result.Allowed)
|
||||||
|
{
|
||||||
|
payload = new
|
||||||
|
{
|
||||||
|
event_type = "sealed_install_enforcement",
|
||||||
|
pack_id = manifest.Metadata.Name,
|
||||||
|
pack_version = manifest.Metadata.Version,
|
||||||
|
decision = "allowed",
|
||||||
|
reason = result.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
payload = new
|
||||||
|
{
|
||||||
|
event_type = "sealed_install_enforcement",
|
||||||
|
pack_id = manifest.Metadata.Name,
|
||||||
|
pack_version = manifest.Metadata.Version,
|
||||||
|
decision = "denied",
|
||||||
|
reason = result.ErrorCode,
|
||||||
|
message = result.Message,
|
||||||
|
violation = result.Violation is not null
|
||||||
|
? new
|
||||||
|
{
|
||||||
|
required_sealed = result.Violation.RequiredSealed,
|
||||||
|
actual_sealed = result.Violation.ActualSealed,
|
||||||
|
recommendation = result.Violation.Recommendation
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
requirement_violations = result.RequirementViolations?.Select(v => new
|
||||||
|
{
|
||||||
|
requirement = v.Requirement,
|
||||||
|
expected = v.Expected,
|
||||||
|
actual = v.Actual,
|
||||||
|
message = v.Message
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var timelineEvent = PackRunTimelineEvent.Create(
|
||||||
|
tenantId: effectiveTenantId,
|
||||||
|
eventType: eventType,
|
||||||
|
source: "StellaOps.TaskRunner.SealedInstallEnforcer",
|
||||||
|
occurredAt: now,
|
||||||
|
runId: effectiveRunId,
|
||||||
|
actor: actor,
|
||||||
|
severity: severity,
|
||||||
|
attributes: attributes,
|
||||||
|
payload: payload);
|
||||||
|
|
||||||
|
await _eventEmitter.EmitAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enforces sealed install requirements for task packs.
|
||||||
|
/// Per sealed-install-enforcement.md contract.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISealedInstallEnforcer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enforces sealed install requirements for a task pack.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="manifest">The task pack manifest.</param>
|
||||||
|
/// <param name="tenantId">Optional tenant ID.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Enforcement result indicating whether execution is allowed.</returns>
|
||||||
|
Task<SealedInstallEnforcementResult> EnforceAsync(
|
||||||
|
TaskPackManifest manifest,
|
||||||
|
string? tenantId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of sealed install enforcement check.
|
||||||
|
/// Per sealed-install-enforcement.md contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SealedInstallEnforcementResult(
|
||||||
|
/// <summary>Whether execution is allowed.</summary>
|
||||||
|
bool Allowed,
|
||||||
|
|
||||||
|
/// <summary>Error code if denied.</summary>
|
||||||
|
string? ErrorCode,
|
||||||
|
|
||||||
|
/// <summary>Human-readable message.</summary>
|
||||||
|
string Message,
|
||||||
|
|
||||||
|
/// <summary>Detailed violation information.</summary>
|
||||||
|
SealedInstallViolation? Violation,
|
||||||
|
|
||||||
|
/// <summary>Requirement violations if any.</summary>
|
||||||
|
IReadOnlyList<RequirementViolation>? RequirementViolations)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an allowed result.
|
||||||
|
/// </summary>
|
||||||
|
public static SealedInstallEnforcementResult CreateAllowed(string message)
|
||||||
|
=> new(true, null, message, null, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a denied result.
|
||||||
|
/// </summary>
|
||||||
|
public static SealedInstallEnforcementResult CreateDenied(
|
||||||
|
string errorCode,
|
||||||
|
string message,
|
||||||
|
SealedInstallViolation? violation = null,
|
||||||
|
IReadOnlyList<RequirementViolation>? requirementViolations = null)
|
||||||
|
=> new(false, errorCode, message, violation, requirementViolations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Details about a sealed install violation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SealedInstallViolation(
|
||||||
|
/// <summary>Pack ID that requires sealed install.</summary>
|
||||||
|
string PackId,
|
||||||
|
|
||||||
|
/// <summary>Pack version.</summary>
|
||||||
|
string? PackVersion,
|
||||||
|
|
||||||
|
/// <summary>Whether pack requires sealed install.</summary>
|
||||||
|
bool RequiredSealed,
|
||||||
|
|
||||||
|
/// <summary>Actual sealed status of environment.</summary>
|
||||||
|
bool ActualSealed,
|
||||||
|
|
||||||
|
/// <summary>Recommendation for resolving the violation.</summary>
|
||||||
|
string Recommendation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Details about a requirement violation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RequirementViolation(
|
||||||
|
/// <summary>Name of the requirement that was violated.</summary>
|
||||||
|
string Requirement,
|
||||||
|
|
||||||
|
/// <summary>Expected value.</summary>
|
||||||
|
string Expected,
|
||||||
|
|
||||||
|
/// <summary>Actual value.</summary>
|
||||||
|
string Actual,
|
||||||
|
|
||||||
|
/// <summary>Human-readable message describing the violation.</summary>
|
||||||
|
string Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error codes for sealed install enforcement.
|
||||||
|
/// </summary>
|
||||||
|
public static class SealedInstallErrorCodes
|
||||||
|
{
|
||||||
|
/// <summary>Pack requires sealed but environment is not sealed.</summary>
|
||||||
|
public const string SealedInstallViolation = "SEALED_INSTALL_VIOLATION";
|
||||||
|
|
||||||
|
/// <summary>Sealed requirements not met.</summary>
|
||||||
|
public const string SealedRequirementsViolation = "SEALED_REQUIREMENTS_VIOLATION";
|
||||||
|
|
||||||
|
/// <summary>Bundle version below minimum required.</summary>
|
||||||
|
public const string BundleVersionViolation = "BUNDLE_VERSION_VIOLATION";
|
||||||
|
|
||||||
|
/// <summary>Advisory data too stale.</summary>
|
||||||
|
public const string AdvisoryStalenessViolation = "ADVISORY_STALENESS_VIOLATION";
|
||||||
|
|
||||||
|
/// <summary>Time anchor missing or invalid.</summary>
|
||||||
|
public const string TimeAnchorViolation = "TIME_ANCHOR_VIOLATION";
|
||||||
|
|
||||||
|
/// <summary>Bundle signature verification failed.</summary>
|
||||||
|
public const string SignatureVerificationViolation = "SIGNATURE_VERIFICATION_VIOLATION";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CLI exit codes for sealed install enforcement.
|
||||||
|
/// </summary>
|
||||||
|
public static class SealedInstallExitCodes
|
||||||
|
{
|
||||||
|
/// <summary>Pack requires sealed but environment is not.</summary>
|
||||||
|
public const int SealedInstallViolation = 40;
|
||||||
|
|
||||||
|
/// <summary>Bundle version below minimum.</summary>
|
||||||
|
public const int BundleVersionViolation = 41;
|
||||||
|
|
||||||
|
/// <summary>Advisory data too stale.</summary>
|
||||||
|
public const int AdvisoryStalenessViolation = 42;
|
||||||
|
|
||||||
|
/// <summary>Time anchor missing or invalid.</summary>
|
||||||
|
public const int TimeAnchorViolation = 43;
|
||||||
|
|
||||||
|
/// <summary>Bundle signature verification failed.</summary>
|
||||||
|
public const int SignatureVerificationViolation = 44;
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enforces sealed install requirements for task packs.
|
||||||
|
/// Per sealed-install-enforcement.md contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SealedInstallEnforcer : ISealedInstallEnforcer
|
||||||
|
{
|
||||||
|
private readonly IAirGapStatusProvider _statusProvider;
|
||||||
|
private readonly IOptions<SealedInstallEnforcementOptions> _options;
|
||||||
|
private readonly ILogger<SealedInstallEnforcer> _logger;
|
||||||
|
|
||||||
|
public SealedInstallEnforcer(
|
||||||
|
IAirGapStatusProvider statusProvider,
|
||||||
|
IOptions<SealedInstallEnforcementOptions> options,
|
||||||
|
ILogger<SealedInstallEnforcer> logger)
|
||||||
|
{
|
||||||
|
_statusProvider = statusProvider ?? throw new ArgumentNullException(nameof(statusProvider));
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SealedInstallEnforcementResult> EnforceAsync(
|
||||||
|
TaskPackManifest manifest,
|
||||||
|
string? tenantId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
|
|
||||||
|
var options = _options.Value;
|
||||||
|
|
||||||
|
// Check if enforcement is enabled
|
||||||
|
if (!options.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Sealed install enforcement is disabled.");
|
||||||
|
return SealedInstallEnforcementResult.CreateAllowed("Enforcement disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for development bypass
|
||||||
|
if (options.BypassForDevelopment && IsDevelopmentEnvironment())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Sealed install enforcement bypassed for development environment.");
|
||||||
|
return SealedInstallEnforcementResult.CreateAllowed("Development bypass active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pack doesn't require sealed install, allow
|
||||||
|
if (!manifest.Spec.SealedInstall)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Pack {PackName} v{PackVersion} does not require sealed install.",
|
||||||
|
manifest.Metadata.Name,
|
||||||
|
manifest.Metadata.Version);
|
||||||
|
|
||||||
|
return SealedInstallEnforcementResult.CreateAllowed("Pack does not require sealed install");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get environment sealed status
|
||||||
|
SealedModeStatus status;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status = await _statusProvider.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to get air-gap status. Denying sealed install pack.");
|
||||||
|
|
||||||
|
return SealedInstallEnforcementResult.CreateDenied(
|
||||||
|
SealedInstallErrorCodes.SealedInstallViolation,
|
||||||
|
"Failed to verify sealed mode status",
|
||||||
|
new SealedInstallViolation(
|
||||||
|
manifest.Metadata.Name,
|
||||||
|
manifest.Metadata.Version,
|
||||||
|
RequiredSealed: true,
|
||||||
|
ActualSealed: false,
|
||||||
|
Recommendation: "Ensure the AirGap controller is accessible: stella airgap status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core check: environment must be sealed
|
||||||
|
if (!status.Sealed)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Sealed install violation: Pack {PackName} v{PackVersion} requires sealed environment but environment is {Mode}.",
|
||||||
|
manifest.Metadata.Name,
|
||||||
|
manifest.Metadata.Version,
|
||||||
|
status.Mode);
|
||||||
|
|
||||||
|
return SealedInstallEnforcementResult.CreateDenied(
|
||||||
|
SealedInstallErrorCodes.SealedInstallViolation,
|
||||||
|
"Pack requires sealed environment but environment is not sealed",
|
||||||
|
new SealedInstallViolation(
|
||||||
|
manifest.Metadata.Name,
|
||||||
|
manifest.Metadata.Version,
|
||||||
|
RequiredSealed: true,
|
||||||
|
ActualSealed: false,
|
||||||
|
Recommendation: "Activate sealed mode with: stella airgap seal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sealed requirements if specified
|
||||||
|
var requirements = manifest.Spec.SealedRequirements ?? SealedRequirements.Default;
|
||||||
|
var violations = ValidateRequirements(requirements, status, options);
|
||||||
|
|
||||||
|
if (violations.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Sealed requirements violation for pack {PackName} v{PackVersion}: {ViolationCount} requirement(s) not met.",
|
||||||
|
manifest.Metadata.Name,
|
||||||
|
manifest.Metadata.Version,
|
||||||
|
violations.Count);
|
||||||
|
|
||||||
|
return SealedInstallEnforcementResult.CreateDenied(
|
||||||
|
SealedInstallErrorCodes.SealedRequirementsViolation,
|
||||||
|
"Sealed requirements not met",
|
||||||
|
violation: null,
|
||||||
|
requirementViolations: violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Sealed install requirements satisfied for pack {PackName} v{PackVersion}.",
|
||||||
|
manifest.Metadata.Name,
|
||||||
|
manifest.Metadata.Version);
|
||||||
|
|
||||||
|
return SealedInstallEnforcementResult.CreateAllowed("Sealed install requirements satisfied");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<RequirementViolation> ValidateRequirements(
|
||||||
|
SealedRequirements requirements,
|
||||||
|
SealedModeStatus status,
|
||||||
|
SealedInstallEnforcementOptions options)
|
||||||
|
{
|
||||||
|
var violations = new List<RequirementViolation>();
|
||||||
|
|
||||||
|
// Bundle version check
|
||||||
|
if (!string.IsNullOrWhiteSpace(requirements.MinBundleVersion) &&
|
||||||
|
!string.IsNullOrWhiteSpace(status.BundleVersion))
|
||||||
|
{
|
||||||
|
if (!IsVersionSatisfied(status.BundleVersion, requirements.MinBundleVersion))
|
||||||
|
{
|
||||||
|
violations.Add(new RequirementViolation(
|
||||||
|
Requirement: "min_bundle_version",
|
||||||
|
Expected: requirements.MinBundleVersion,
|
||||||
|
Actual: status.BundleVersion,
|
||||||
|
Message: $"Bundle version {status.BundleVersion} < required {requirements.MinBundleVersion}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advisory staleness check
|
||||||
|
var effectiveStaleness = status.AdvisoryStalenessHours;
|
||||||
|
var maxStaleness = requirements.MaxAdvisoryStalenessHours;
|
||||||
|
|
||||||
|
// Apply grace period if configured
|
||||||
|
if (options.StalenessGracePeriodHours > 0)
|
||||||
|
{
|
||||||
|
maxStaleness += options.StalenessGracePeriodHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveStaleness > maxStaleness)
|
||||||
|
{
|
||||||
|
if (options.DenyOnStaleness)
|
||||||
|
{
|
||||||
|
violations.Add(new RequirementViolation(
|
||||||
|
Requirement: "max_advisory_staleness_hours",
|
||||||
|
Expected: requirements.MaxAdvisoryStalenessHours.ToString(),
|
||||||
|
Actual: effectiveStaleness.ToString(),
|
||||||
|
Message: $"Advisory data is {effectiveStaleness}h old, max allowed is {requirements.MaxAdvisoryStalenessHours}h"));
|
||||||
|
}
|
||||||
|
else if (effectiveStaleness > options.StalenessWarningThresholdHours)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Advisory data is {Staleness}h old, approaching max allowed {MaxStaleness}h.",
|
||||||
|
effectiveStaleness,
|
||||||
|
requirements.MaxAdvisoryStalenessHours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time anchor check
|
||||||
|
if (requirements.RequireTimeAnchor)
|
||||||
|
{
|
||||||
|
if (status.TimeAnchor is null)
|
||||||
|
{
|
||||||
|
violations.Add(new RequirementViolation(
|
||||||
|
Requirement: "require_time_anchor",
|
||||||
|
Expected: "valid time anchor",
|
||||||
|
Actual: "missing",
|
||||||
|
Message: "Valid time anchor required but not present"));
|
||||||
|
}
|
||||||
|
else if (!status.TimeAnchor.Valid)
|
||||||
|
{
|
||||||
|
violations.Add(new RequirementViolation(
|
||||||
|
Requirement: "require_time_anchor",
|
||||||
|
Expected: "valid time anchor",
|
||||||
|
Actual: "invalid",
|
||||||
|
Message: "Time anchor present but invalid or expired"));
|
||||||
|
}
|
||||||
|
else if (status.TimeAnchor.ExpiresAt.HasValue &&
|
||||||
|
status.TimeAnchor.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
violations.Add(new RequirementViolation(
|
||||||
|
Requirement: "require_time_anchor",
|
||||||
|
Expected: "non-expired time anchor",
|
||||||
|
Actual: $"expired at {status.TimeAnchor.ExpiresAt.Value:O}",
|
||||||
|
Message: "Time anchor has expired"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsVersionSatisfied(string actual, string required)
|
||||||
|
{
|
||||||
|
// Try semantic version comparison
|
||||||
|
if (Version.TryParse(NormalizeVersion(actual), out var actualVersion) &&
|
||||||
|
Version.TryParse(NormalizeVersion(required), out var requiredVersion))
|
||||||
|
{
|
||||||
|
return actualVersion >= requiredVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to string comparison
|
||||||
|
return string.Compare(actual, required, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeVersion(string version)
|
||||||
|
{
|
||||||
|
// Strip common prefixes like 'v' and suffixes like '-beta'
|
||||||
|
var normalized = version.TrimStart('v', 'V');
|
||||||
|
var dashIndex = normalized.IndexOf('-', StringComparison.Ordinal);
|
||||||
|
if (dashIndex > 0)
|
||||||
|
{
|
||||||
|
normalized = normalized[..dashIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDevelopmentEnvironment()
|
||||||
|
{
|
||||||
|
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ??
|
||||||
|
Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
|
|
||||||
|
return string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for sealed install enforcement.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SealedInstallEnforcementOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether enforcement is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Grace period for advisory staleness in hours.
|
||||||
|
/// </summary>
|
||||||
|
public int StalenessGracePeriodHours { get; set; } = 24;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warning threshold for staleness in hours.
|
||||||
|
/// </summary>
|
||||||
|
public int StalenessWarningThresholdHours { get; set; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to deny on staleness violation (false = warn only).
|
||||||
|
/// </summary>
|
||||||
|
public bool DenyOnStaleness { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to use heuristic detection when AirGap controller is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseHeuristicDetection { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heuristic score threshold to consider environment sealed.
|
||||||
|
/// </summary>
|
||||||
|
public double HeuristicThreshold { get; set; } = 0.7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bypass enforcement in development environments (DANGEROUS).
|
||||||
|
/// </summary>
|
||||||
|
public bool BypassForDevelopment { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log all enforcement decisions.
|
||||||
|
/// </summary>
|
||||||
|
public bool LogAllDecisions { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit retention in days.
|
||||||
|
/// </summary>
|
||||||
|
public int AuditRetentionDays { get; set; } = 365;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the sealed mode status of the air-gap environment.
|
||||||
|
/// Per sealed-install-enforcement.md contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SealedModeStatus(
|
||||||
|
/// <summary>Whether the environment is currently sealed.</summary>
|
||||||
|
bool Sealed,
|
||||||
|
|
||||||
|
/// <summary>Current mode (sealed, unsealed, transitioning).</summary>
|
||||||
|
string Mode,
|
||||||
|
|
||||||
|
/// <summary>When the environment was sealed.</summary>
|
||||||
|
DateTimeOffset? SealedAt,
|
||||||
|
|
||||||
|
/// <summary>Identity that sealed the environment.</summary>
|
||||||
|
string? SealedBy,
|
||||||
|
|
||||||
|
/// <summary>Air-gap bundle version currently installed.</summary>
|
||||||
|
string? BundleVersion,
|
||||||
|
|
||||||
|
/// <summary>Digest of the bundle.</summary>
|
||||||
|
string? BundleDigest,
|
||||||
|
|
||||||
|
/// <summary>When advisories were last updated.</summary>
|
||||||
|
DateTimeOffset? LastAdvisoryUpdate,
|
||||||
|
|
||||||
|
/// <summary>Hours since last advisory update.</summary>
|
||||||
|
int AdvisoryStalenessHours,
|
||||||
|
|
||||||
|
/// <summary>Time anchor information.</summary>
|
||||||
|
TimeAnchorInfo? TimeAnchor,
|
||||||
|
|
||||||
|
/// <summary>Whether egress is blocked.</summary>
|
||||||
|
bool EgressBlocked,
|
||||||
|
|
||||||
|
/// <summary>Network policy in effect.</summary>
|
||||||
|
string? NetworkPolicy)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an unsealed status (environment not in air-gap mode).
|
||||||
|
/// </summary>
|
||||||
|
public static SealedModeStatus Unsealed() => new(
|
||||||
|
Sealed: false,
|
||||||
|
Mode: "unsealed",
|
||||||
|
SealedAt: null,
|
||||||
|
SealedBy: null,
|
||||||
|
BundleVersion: null,
|
||||||
|
BundleDigest: null,
|
||||||
|
LastAdvisoryUpdate: null,
|
||||||
|
AdvisoryStalenessHours: 0,
|
||||||
|
TimeAnchor: null,
|
||||||
|
EgressBlocked: false,
|
||||||
|
NetworkPolicy: null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a status indicating the provider is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
public static SealedModeStatus Unavailable() => new(
|
||||||
|
Sealed: false,
|
||||||
|
Mode: "unavailable",
|
||||||
|
SealedAt: null,
|
||||||
|
SealedBy: null,
|
||||||
|
BundleVersion: null,
|
||||||
|
BundleDigest: null,
|
||||||
|
LastAdvisoryUpdate: null,
|
||||||
|
AdvisoryStalenessHours: 0,
|
||||||
|
TimeAnchor: null,
|
||||||
|
EgressBlocked: false,
|
||||||
|
NetworkPolicy: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Time anchor information for sealed environments.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TimeAnchorInfo(
|
||||||
|
/// <summary>The anchor timestamp.</summary>
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
|
||||||
|
/// <summary>Signature of the time anchor.</summary>
|
||||||
|
string? Signature,
|
||||||
|
|
||||||
|
/// <summary>Whether the time anchor is valid.</summary>
|
||||||
|
bool Valid,
|
||||||
|
|
||||||
|
/// <summary>When the time anchor expires.</summary>
|
||||||
|
DateTimeOffset? ExpiresAt);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sealed install requirements specified in a task pack manifest.
|
||||||
|
/// Per sealed-install-enforcement.md contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SealedRequirements(
|
||||||
|
/// <summary>Minimum air-gap bundle version required.</summary>
|
||||||
|
[property: JsonPropertyName("min_bundle_version")]
|
||||||
|
string? MinBundleVersion,
|
||||||
|
|
||||||
|
/// <summary>Maximum age of advisory data in hours (default: 168).</summary>
|
||||||
|
[property: JsonPropertyName("max_advisory_staleness_hours")]
|
||||||
|
int MaxAdvisoryStalenessHours,
|
||||||
|
|
||||||
|
/// <summary>Whether a valid time anchor is required (default: true).</summary>
|
||||||
|
[property: JsonPropertyName("require_time_anchor")]
|
||||||
|
bool RequireTimeAnchor,
|
||||||
|
|
||||||
|
/// <summary>Maximum allowed offline duration in hours (default: 720).</summary>
|
||||||
|
[property: JsonPropertyName("allowed_offline_duration_hours")]
|
||||||
|
int AllowedOfflineDurationHours,
|
||||||
|
|
||||||
|
/// <summary>Whether bundle signature verification is required (default: true).</summary>
|
||||||
|
[property: JsonPropertyName("require_signature_verification")]
|
||||||
|
bool RequireSignatureVerification)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Default sealed requirements.
|
||||||
|
/// </summary>
|
||||||
|
public static SealedRequirements Default => new(
|
||||||
|
MinBundleVersion: null,
|
||||||
|
MaxAdvisoryStalenessHours: 168,
|
||||||
|
RequireTimeAnchor: true,
|
||||||
|
AllowedOfflineDurationHours: 720,
|
||||||
|
RequireSignatureVerification: true);
|
||||||
|
}
|
||||||
@@ -301,6 +301,18 @@ public static class PackRunEventTypes
|
|||||||
/// <summary>Policy gate evaluated.</summary>
|
/// <summary>Policy gate evaluated.</summary>
|
||||||
public const string PolicyEvaluated = "pack.policy.evaluated";
|
public const string PolicyEvaluated = "pack.policy.evaluated";
|
||||||
|
|
||||||
|
/// <summary>Sealed install enforcement performed.</summary>
|
||||||
|
public const string SealedInstallEnforcement = "pack.sealed_install.enforcement";
|
||||||
|
|
||||||
|
/// <summary>Sealed install enforcement denied execution.</summary>
|
||||||
|
public const string SealedInstallDenied = "pack.sealed_install.denied";
|
||||||
|
|
||||||
|
/// <summary>Sealed install enforcement allowed execution.</summary>
|
||||||
|
public const string SealedInstallAllowed = "pack.sealed_install.allowed";
|
||||||
|
|
||||||
|
/// <summary>Sealed install requirements warning.</summary>
|
||||||
|
public const string SealedInstallWarning = "pack.sealed_install.warning";
|
||||||
|
|
||||||
/// <summary>Checks if the event type is a pack run event.</summary>
|
/// <summary>Checks if the event type is a pack run event.</summary>
|
||||||
public static bool IsPackRunEvent(string eventType) =>
|
public static bool IsPackRunEvent(string eventType) =>
|
||||||
eventType.StartsWith(Prefix, StringComparison.Ordinal);
|
eventType.StartsWith(Prefix, StringComparison.Ordinal);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ using System.Diagnostics.Metrics;
|
|||||||
|
|
||||||
namespace StellaOps.TaskRunner.Core.Execution;
|
namespace StellaOps.TaskRunner.Core.Execution;
|
||||||
|
|
||||||
internal static class TaskRunnerTelemetry
|
public static class TaskRunnerTelemetry
|
||||||
{
|
{
|
||||||
internal const string MeterName = "stellaops.taskrunner";
|
public const string MeterName = "stellaops.taskrunner";
|
||||||
|
|
||||||
internal static readonly Meter Meter = new(MeterName);
|
internal static readonly Meter Meter = new(MeterName);
|
||||||
internal static readonly Histogram<double> StepDurationMs =
|
internal static readonly Histogram<double> StepDurationMs =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
namespace StellaOps.TaskRunner.Core.TaskPacks;
|
namespace StellaOps.TaskRunner.Core.TaskPacks;
|
||||||
|
|
||||||
@@ -82,6 +83,18 @@ public sealed class TaskPackSpec
|
|||||||
|
|
||||||
[JsonPropertyName("slo")]
|
[JsonPropertyName("slo")]
|
||||||
public TaskPackSlo? Slo { get; init; }
|
public TaskPackSlo? Slo { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this pack requires a sealed (air-gapped) environment.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("sealedInstall")]
|
||||||
|
public bool SealedInstall { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specific requirements for sealed install mode.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("sealedRequirements")]
|
||||||
|
public SealedRequirements? SealedRequirements { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TaskPackInput
|
public sealed class TaskPackInput
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.TaskRunner.Core.AirGap;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Infrastructure.AirGap;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client implementation for retrieving air-gap status from the AirGap controller.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HttpAirGapStatusProvider : IAirGapStatusProvider
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IOptions<AirGapStatusProviderOptions> _options;
|
||||||
|
private readonly ILogger<HttpAirGapStatusProvider> _logger;
|
||||||
|
|
||||||
|
public HttpAirGapStatusProvider(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IOptions<AirGapStatusProviderOptions> options,
|
||||||
|
ILogger<HttpAirGapStatusProvider> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SealedModeStatus> GetStatusAsync(
|
||||||
|
string? tenantId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
var url = string.IsNullOrWhiteSpace(tenantId)
|
||||||
|
? options.StatusEndpoint
|
||||||
|
: $"{options.StatusEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetFromJsonAsync<AirGapStatusDto>(
|
||||||
|
url,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("AirGap controller returned null response.");
|
||||||
|
return SealedModeStatus.Unavailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapToSealedModeStatus(response);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to connect to AirGap controller at {Url}.", url);
|
||||||
|
|
||||||
|
if (options.UseHeuristicFallback)
|
||||||
|
{
|
||||||
|
return await GetStatusFromHeuristicsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SealedModeStatus.Unavailable();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unexpected error getting air-gap status.");
|
||||||
|
return SealedModeStatus.Unavailable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SealedModeStatus MapToSealedModeStatus(AirGapStatusDto dto)
|
||||||
|
{
|
||||||
|
TimeAnchorInfo? timeAnchor = null;
|
||||||
|
if (dto.TimeAnchor is not null)
|
||||||
|
{
|
||||||
|
timeAnchor = new TimeAnchorInfo(
|
||||||
|
dto.TimeAnchor.Timestamp,
|
||||||
|
dto.TimeAnchor.Signature,
|
||||||
|
dto.TimeAnchor.Valid,
|
||||||
|
dto.TimeAnchor.ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SealedModeStatus(
|
||||||
|
Sealed: dto.Sealed,
|
||||||
|
Mode: dto.Sealed ? "sealed" : "unsealed",
|
||||||
|
SealedAt: dto.SealedAt,
|
||||||
|
SealedBy: dto.SealedBy,
|
||||||
|
BundleVersion: dto.BundleVersion,
|
||||||
|
BundleDigest: dto.BundleDigest,
|
||||||
|
LastAdvisoryUpdate: dto.LastAdvisoryUpdate,
|
||||||
|
AdvisoryStalenessHours: dto.AdvisoryStalenessHours,
|
||||||
|
TimeAnchor: timeAnchor,
|
||||||
|
EgressBlocked: dto.EgressBlocked,
|
||||||
|
NetworkPolicy: dto.NetworkPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SealedModeStatus> GetStatusFromHeuristicsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Using heuristic detection for sealed mode status.");
|
||||||
|
|
||||||
|
var score = 0.0;
|
||||||
|
var weights = 0.0;
|
||||||
|
|
||||||
|
// Check AIRGAP_MODE environment variable (high weight)
|
||||||
|
var airgapMode = Environment.GetEnvironmentVariable("AIRGAP_MODE");
|
||||||
|
if (string.Equals(airgapMode, "sealed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 0.3;
|
||||||
|
}
|
||||||
|
weights += 0.3;
|
||||||
|
|
||||||
|
// Check for sealed file marker (medium weight)
|
||||||
|
var sealedMarkerPath = _options.Value.SealedMarkerPath;
|
||||||
|
if (!string.IsNullOrWhiteSpace(sealedMarkerPath) && File.Exists(sealedMarkerPath))
|
||||||
|
{
|
||||||
|
score += 0.2;
|
||||||
|
}
|
||||||
|
weights += 0.2;
|
||||||
|
|
||||||
|
// Check network connectivity (high weight)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
var testResponse = await _httpClient.GetAsync(
|
||||||
|
_options.Value.ConnectivityTestUrl,
|
||||||
|
cts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// If we can reach external network, likely not sealed
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Network blocked, likely sealed
|
||||||
|
score += 0.3;
|
||||||
|
}
|
||||||
|
weights += 0.3;
|
||||||
|
|
||||||
|
// Check for local registry configuration (low weight)
|
||||||
|
var registryEnv = Environment.GetEnvironmentVariable("CONTAINER_REGISTRY");
|
||||||
|
if (!string.IsNullOrWhiteSpace(registryEnv) &&
|
||||||
|
(registryEnv.Contains("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
registryEnv.Contains("127.0.0.1", StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
score += 0.1;
|
||||||
|
}
|
||||||
|
weights += 0.1;
|
||||||
|
|
||||||
|
// Check proxy settings (low weight)
|
||||||
|
var httpProxy = Environment.GetEnvironmentVariable("HTTP_PROXY") ??
|
||||||
|
Environment.GetEnvironmentVariable("http_proxy");
|
||||||
|
var noProxy = Environment.GetEnvironmentVariable("NO_PROXY") ??
|
||||||
|
Environment.GetEnvironmentVariable("no_proxy");
|
||||||
|
if (string.IsNullOrWhiteSpace(httpProxy) && !string.IsNullOrWhiteSpace(noProxy))
|
||||||
|
{
|
||||||
|
score += 0.1;
|
||||||
|
}
|
||||||
|
weights += 0.1;
|
||||||
|
|
||||||
|
var normalizedScore = weights > 0 ? score / weights : 0;
|
||||||
|
var threshold = _options.Value.HeuristicThreshold;
|
||||||
|
|
||||||
|
var isSealed = normalizedScore >= threshold;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Heuristic detection result: score={Score:F2}, threshold={Threshold:F2}, sealed={IsSealed}",
|
||||||
|
normalizedScore,
|
||||||
|
threshold,
|
||||||
|
isSealed);
|
||||||
|
|
||||||
|
return new SealedModeStatus(
|
||||||
|
Sealed: isSealed,
|
||||||
|
Mode: isSealed ? "sealed-heuristic" : "unsealed-heuristic",
|
||||||
|
SealedAt: null,
|
||||||
|
SealedBy: null,
|
||||||
|
BundleVersion: null,
|
||||||
|
BundleDigest: null,
|
||||||
|
LastAdvisoryUpdate: null,
|
||||||
|
AdvisoryStalenessHours: 0,
|
||||||
|
TimeAnchor: null,
|
||||||
|
EgressBlocked: isSealed,
|
||||||
|
NetworkPolicy: isSealed ? "heuristic-detected" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AirGapStatusDto(
|
||||||
|
[property: JsonPropertyName("sealed")] bool Sealed,
|
||||||
|
[property: JsonPropertyName("sealed_at")] DateTimeOffset? SealedAt,
|
||||||
|
[property: JsonPropertyName("sealed_by")] string? SealedBy,
|
||||||
|
[property: JsonPropertyName("bundle_version")] string? BundleVersion,
|
||||||
|
[property: JsonPropertyName("bundle_digest")] string? BundleDigest,
|
||||||
|
[property: JsonPropertyName("last_advisory_update")] DateTimeOffset? LastAdvisoryUpdate,
|
||||||
|
[property: JsonPropertyName("advisory_staleness_hours")] int AdvisoryStalenessHours,
|
||||||
|
[property: JsonPropertyName("time_anchor")] TimeAnchorDto? TimeAnchor,
|
||||||
|
[property: JsonPropertyName("egress_blocked")] bool EgressBlocked,
|
||||||
|
[property: JsonPropertyName("network_policy")] string? NetworkPolicy);
|
||||||
|
|
||||||
|
private sealed record TimeAnchorDto(
|
||||||
|
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||||
|
[property: JsonPropertyName("signature")] string? Signature,
|
||||||
|
[property: JsonPropertyName("valid")] bool Valid,
|
||||||
|
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for the HTTP air-gap status provider.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AirGapStatusProviderOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Base URL of the AirGap controller.
|
||||||
|
/// </summary>
|
||||||
|
public string BaseUrl { get; set; } = "http://localhost:8080";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status endpoint path.
|
||||||
|
/// </summary>
|
||||||
|
public string StatusEndpoint { get; set; } = "/api/v1/airgap/status";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to use heuristic fallback when controller is unavailable.
|
||||||
|
/// </summary>
|
||||||
|
public bool UseHeuristicFallback { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heuristic score threshold (0.0-1.0) to consider environment sealed.
|
||||||
|
/// </summary>
|
||||||
|
public double HeuristicThreshold { get; set; } = 0.7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the sealed mode marker file.
|
||||||
|
/// </summary>
|
||||||
|
public string? SealedMarkerPath { get; set; } = "/etc/stellaops/sealed";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL to test external connectivity.
|
||||||
|
/// </summary>
|
||||||
|
public string ConnectivityTestUrl { get; set; } = "https://api.stellaops.org/health";
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.TaskRunner.WebService.Deprecation;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Tests;
|
||||||
|
|
||||||
|
public sealed class ApiDeprecationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DeprecatedEndpoint_PathPattern_MatchesExpected()
|
||||||
|
{
|
||||||
|
var endpoint = new DeprecatedEndpoint
|
||||||
|
{
|
||||||
|
PathPattern = "/v1/legacy/*",
|
||||||
|
DeprecatedAt = DateTimeOffset.UtcNow.AddDays(-30),
|
||||||
|
SunsetAt = DateTimeOffset.UtcNow.AddDays(60),
|
||||||
|
ReplacementPath = "/v2/new",
|
||||||
|
Message = "Use the v2 API"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal("/v1/legacy/*", endpoint.PathPattern);
|
||||||
|
Assert.NotNull(endpoint.DeprecatedAt);
|
||||||
|
Assert.NotNull(endpoint.SunsetAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApiDeprecationOptions_DefaultValues_AreCorrect()
|
||||||
|
{
|
||||||
|
var options = new ApiDeprecationOptions();
|
||||||
|
|
||||||
|
Assert.True(options.EmitDeprecationHeaders);
|
||||||
|
Assert.True(options.EmitSunsetHeaders);
|
||||||
|
Assert.NotNull(options.DeprecationPolicyUrl);
|
||||||
|
Assert.Empty(options.DeprecatedEndpoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoggingDeprecationNotificationService_GetUpcoming_FiltersCorrectly()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var options = new ApiDeprecationOptions
|
||||||
|
{
|
||||||
|
DeprecatedEndpoints =
|
||||||
|
[
|
||||||
|
new DeprecatedEndpoint
|
||||||
|
{
|
||||||
|
PathPattern = "/v1/soon/*",
|
||||||
|
SunsetAt = now.AddDays(30) // Within 90 days
|
||||||
|
},
|
||||||
|
new DeprecatedEndpoint
|
||||||
|
{
|
||||||
|
PathPattern = "/v1/later/*",
|
||||||
|
SunsetAt = now.AddDays(180) // Beyond 90 days
|
||||||
|
},
|
||||||
|
new DeprecatedEndpoint
|
||||||
|
{
|
||||||
|
PathPattern = "/v1/past/*",
|
||||||
|
SunsetAt = now.AddDays(-10) // Already passed
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var optionsMonitor = new OptionsMonitor(options);
|
||||||
|
var service = new LoggingDeprecationNotificationService(
|
||||||
|
NullLogger<LoggingDeprecationNotificationService>.Instance,
|
||||||
|
optionsMonitor);
|
||||||
|
|
||||||
|
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Single(upcoming);
|
||||||
|
Assert.Equal("/v1/soon/*", upcoming[0].EndpointPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoggingDeprecationNotificationService_GetUpcoming_OrdersBySunsetDate()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var options = new ApiDeprecationOptions
|
||||||
|
{
|
||||||
|
DeprecatedEndpoints =
|
||||||
|
[
|
||||||
|
new DeprecatedEndpoint { PathPattern = "/v1/third/*", SunsetAt = now.AddDays(60) },
|
||||||
|
new DeprecatedEndpoint { PathPattern = "/v1/first/*", SunsetAt = now.AddDays(10) },
|
||||||
|
new DeprecatedEndpoint { PathPattern = "/v1/second/*", SunsetAt = now.AddDays(30) }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var optionsMonitor = new OptionsMonitor(options);
|
||||||
|
var service = new LoggingDeprecationNotificationService(
|
||||||
|
NullLogger<LoggingDeprecationNotificationService>.Instance,
|
||||||
|
optionsMonitor);
|
||||||
|
|
||||||
|
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(3, upcoming.Count);
|
||||||
|
Assert.Equal("/v1/first/*", upcoming[0].EndpointPath);
|
||||||
|
Assert.Equal("/v1/second/*", upcoming[1].EndpointPath);
|
||||||
|
Assert.Equal("/v1/third/*", upcoming[2].EndpointPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeprecationInfo_DaysUntilSunset_CalculatesCorrectly()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var sunsetDate = now.AddDays(45);
|
||||||
|
|
||||||
|
var info = new DeprecationInfo(
|
||||||
|
"/v1/test/*",
|
||||||
|
now.AddDays(-30),
|
||||||
|
sunsetDate,
|
||||||
|
"/v2/test/*",
|
||||||
|
"https://docs.example.com/migration",
|
||||||
|
45);
|
||||||
|
|
||||||
|
Assert.Equal(45, info.DaysUntilSunset);
|
||||||
|
Assert.Equal("/v2/test/*", info.ReplacementPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeprecationNotification_RecordProperties_AreAccessible()
|
||||||
|
{
|
||||||
|
var notification = new DeprecationNotification(
|
||||||
|
"/v1/legacy/endpoint",
|
||||||
|
"/v2/new/endpoint",
|
||||||
|
DateTimeOffset.UtcNow.AddDays(90),
|
||||||
|
"This endpoint is deprecated",
|
||||||
|
"https://docs.example.com/deprecation",
|
||||||
|
["consumer-1", "consumer-2"]);
|
||||||
|
|
||||||
|
Assert.Equal("/v1/legacy/endpoint", notification.EndpointPath);
|
||||||
|
Assert.Equal("/v2/new/endpoint", notification.ReplacementPath);
|
||||||
|
Assert.NotNull(notification.SunsetDate);
|
||||||
|
Assert.Equal(2, notification.AffectedConsumerIds?.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PathPattern_WildcardToRegex_MatchesSingleSegment()
|
||||||
|
{
|
||||||
|
var pattern = "^" + Regex.Escape("/v1/packs/*")
|
||||||
|
.Replace("\\*\\*", ".*")
|
||||||
|
.Replace("\\*", "[^/]*") + "$";
|
||||||
|
|
||||||
|
Assert.Matches(pattern, "/v1/packs/foo");
|
||||||
|
Assert.Matches(pattern, "/v1/packs/bar");
|
||||||
|
Assert.DoesNotMatch(pattern, "/v1/packs/foo/bar"); // Single * shouldn't match /
|
||||||
|
Assert.DoesNotMatch(pattern, "/v2/packs/foo");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PathPattern_DoubleWildcard_MatchesMultipleSegments()
|
||||||
|
{
|
||||||
|
var pattern = "^" + Regex.Escape("/v1/legacy/**")
|
||||||
|
.Replace("\\*\\*", ".*")
|
||||||
|
.Replace("\\*", "[^/]*") + "$";
|
||||||
|
|
||||||
|
Assert.Matches(pattern, "/v1/legacy/foo");
|
||||||
|
Assert.Matches(pattern, "/v1/legacy/foo/bar");
|
||||||
|
Assert.Matches(pattern, "/v1/legacy/foo/bar/baz");
|
||||||
|
Assert.DoesNotMatch(pattern, "/v2/legacy/foo");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OptionsMonitor : IOptionsMonitor<ApiDeprecationOptions>
|
||||||
|
{
|
||||||
|
public OptionsMonitor(ApiDeprecationOptions value) => CurrentValue = value;
|
||||||
|
|
||||||
|
public ApiDeprecationOptions CurrentValue { get; }
|
||||||
|
|
||||||
|
public ApiDeprecationOptions Get(string? name) => CurrentValue;
|
||||||
|
|
||||||
|
public IDisposable? OnChange(Action<ApiDeprecationOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,15 @@ public sealed class OpenApiMetadataFactoryTests
|
|||||||
{
|
{
|
||||||
var metadata = OpenApiMetadataFactory.Create();
|
var metadata = OpenApiMetadataFactory.Create();
|
||||||
|
|
||||||
Assert.Equal("/openapi", metadata.Url);
|
Assert.Equal("/openapi", metadata.SpecUrl);
|
||||||
Assert.False(string.IsNullOrWhiteSpace(metadata.Build));
|
Assert.Equal(OpenApiMetadataFactory.ApiVersion, metadata.Version);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(metadata.BuildVersion));
|
||||||
Assert.StartsWith("W/\"", metadata.ETag);
|
Assert.StartsWith("W/\"", metadata.ETag);
|
||||||
Assert.EndsWith("\"", metadata.ETag);
|
Assert.EndsWith("\"", metadata.ETag);
|
||||||
Assert.Equal(64, metadata.Signature.Length);
|
Assert.StartsWith("sha256:", metadata.Signature);
|
||||||
Assert.True(metadata.Signature.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
|
var hashPart = metadata.Signature["sha256:".Length..];
|
||||||
|
Assert.Equal(64, hashPart.Length);
|
||||||
|
Assert.True(hashPart.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -22,6 +25,26 @@ public sealed class OpenApiMetadataFactoryTests
|
|||||||
{
|
{
|
||||||
var metadata = OpenApiMetadataFactory.Create("/docs/openapi.json");
|
var metadata = OpenApiMetadataFactory.Create("/docs/openapi.json");
|
||||||
|
|
||||||
Assert.Equal("/docs/openapi.json", metadata.Url);
|
Assert.Equal("/docs/openapi.json", metadata.SpecUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_SignatureIncludesAllComponents()
|
||||||
|
{
|
||||||
|
var metadata1 = OpenApiMetadataFactory.Create("/path1");
|
||||||
|
var metadata2 = OpenApiMetadataFactory.Create("/path2");
|
||||||
|
|
||||||
|
// Different URLs should produce different signatures
|
||||||
|
Assert.NotEqual(metadata1.Signature, metadata2.Signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Create_ETagIsDeterministic()
|
||||||
|
{
|
||||||
|
var metadata1 = OpenApiMetadataFactory.Create();
|
||||||
|
var metadata2 = OpenApiMetadataFactory.Create();
|
||||||
|
|
||||||
|
// Same inputs should produce same ETag
|
||||||
|
Assert.Equal(metadata1.ETag, metadata2.ETag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
|
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
|
||||||
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj" />
|
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj" />
|
||||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" />
|
<!-- OpenApiMetadataFactory is now accessible via WebService project reference -->
|
||||||
|
<!-- <Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" /> -->
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System.Text;
|
||||||
|
using StellaOps.TaskRunner.Client.Models;
|
||||||
|
using StellaOps.TaskRunner.Client.Streaming;
|
||||||
|
using StellaOps.TaskRunner.Client.Pagination;
|
||||||
|
using StellaOps.TaskRunner.Client.Lifecycle;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Tests;
|
||||||
|
|
||||||
|
public sealed class TaskRunnerClientTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamingLogReader_ParsesNdjsonLines()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var ndjson = """
|
||||||
|
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Starting","traceId":"abc123"}
|
||||||
|
{"timestamp":"2025-01-01T00:00:01Z","level":"error","stepId":"step-1","message":"Failed","traceId":"abc123"}
|
||||||
|
""";
|
||||||
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||||
|
|
||||||
|
var entries = await StreamingLogReader.CollectAsync(stream, ct);
|
||||||
|
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
Assert.Equal("info", entries[0].Level);
|
||||||
|
Assert.Equal("error", entries[1].Level);
|
||||||
|
Assert.Equal("step-1", entries[0].StepId);
|
||||||
|
Assert.Equal("Starting", entries[0].Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamingLogReader_SkipsEmptyLines()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var ndjson = """
|
||||||
|
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Test","traceId":"abc123"}
|
||||||
|
|
||||||
|
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"Test2","traceId":"abc123"}
|
||||||
|
""";
|
||||||
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||||
|
|
||||||
|
var entries = await StreamingLogReader.CollectAsync(stream, ct);
|
||||||
|
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamingLogReader_SkipsMalformedLines()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var ndjson = """
|
||||||
|
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Valid","traceId":"abc123"}
|
||||||
|
not valid json
|
||||||
|
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"AlsoValid","traceId":"abc123"}
|
||||||
|
""";
|
||||||
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||||
|
|
||||||
|
var entries = await StreamingLogReader.CollectAsync(stream, ct);
|
||||||
|
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
Assert.Equal("Valid", entries[0].Message);
|
||||||
|
Assert.Equal("AlsoValid", entries[1].Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamingLogReader_FilterByLevel_FiltersCorrectly()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var entries = new List<RunLogEntry>
|
||||||
|
{
|
||||||
|
new(DateTimeOffset.UtcNow, "info", "step-1", "Info message", "trace1"),
|
||||||
|
new(DateTimeOffset.UtcNow, "error", "step-1", "Error message", "trace1"),
|
||||||
|
new(DateTimeOffset.UtcNow, "warning", "step-1", "Warning message", "trace1"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var levels = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "error", "warning" };
|
||||||
|
var filtered = new List<RunLogEntry>();
|
||||||
|
|
||||||
|
await foreach (var entry in StreamingLogReader.FilterByLevelAsync(entries.ToAsyncEnumerable(), levels, ct))
|
||||||
|
{
|
||||||
|
filtered.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(2, filtered.Count);
|
||||||
|
Assert.DoesNotContain(filtered, e => e.Level == "info");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamingLogReader_GroupByStep_GroupsCorrectly()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var entries = new List<RunLogEntry>
|
||||||
|
{
|
||||||
|
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 1", "trace1"),
|
||||||
|
new(DateTimeOffset.UtcNow, "info", "step-2", "Message 2", "trace1"),
|
||||||
|
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 3", "trace1"),
|
||||||
|
new(DateTimeOffset.UtcNow, "info", null, "Global message", "trace1"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var groups = await StreamingLogReader.GroupByStepAsync(entries.ToAsyncEnumerable(), ct);
|
||||||
|
|
||||||
|
Assert.Equal(3, groups.Count);
|
||||||
|
Assert.Equal(2, groups["step-1"].Count);
|
||||||
|
Assert.Single(groups["step-2"]);
|
||||||
|
Assert.Single(groups["(global)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Paginator_IteratesAllPages()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var allItems = Enumerable.Range(1, 25).ToList();
|
||||||
|
var pageSize = 10;
|
||||||
|
var fetchCalls = 0;
|
||||||
|
|
||||||
|
var paginator = new Paginator<int>(
|
||||||
|
async (offset, limit, token) =>
|
||||||
|
{
|
||||||
|
fetchCalls++;
|
||||||
|
var items = allItems.Skip(offset).Take(limit).ToList();
|
||||||
|
var hasMore = offset + items.Count < allItems.Count;
|
||||||
|
return new PagedResponse<int>(items, allItems.Count, hasMore);
|
||||||
|
},
|
||||||
|
pageSize);
|
||||||
|
|
||||||
|
var collected = await paginator.CollectAsync(ct);
|
||||||
|
|
||||||
|
Assert.Equal(25, collected.Count);
|
||||||
|
Assert.Equal(3, fetchCalls); // 10, 10, 5 items
|
||||||
|
Assert.Equal(allItems, collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Paginator_GetPage_ReturnsCorrectPage()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var allItems = Enumerable.Range(1, 25).ToList();
|
||||||
|
var pageSize = 10;
|
||||||
|
|
||||||
|
var paginator = new Paginator<int>(
|
||||||
|
async (offset, limit, token) =>
|
||||||
|
{
|
||||||
|
var items = allItems.Skip(offset).Take(limit).ToList();
|
||||||
|
var hasMore = offset + items.Count < allItems.Count;
|
||||||
|
return new PagedResponse<int>(items, allItems.Count, hasMore);
|
||||||
|
},
|
||||||
|
pageSize);
|
||||||
|
|
||||||
|
var page2 = await paginator.GetPageAsync(2, ct);
|
||||||
|
|
||||||
|
Assert.Equal(10, page2.Items.Count);
|
||||||
|
Assert.Equal(11, page2.Items[0]); // Items 11-20
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PaginatorExtensions_TakeAsync_TakesCorrectNumber()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var items = Enumerable.Range(1, 100).ToAsyncEnumerable();
|
||||||
|
|
||||||
|
var taken = new List<int>();
|
||||||
|
await foreach (var item in items.TakeAsync(5, ct))
|
||||||
|
{
|
||||||
|
taken.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(5, taken.Count);
|
||||||
|
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, taken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PaginatorExtensions_SkipAsync_SkipsCorrectNumber()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
var items = Enumerable.Range(1, 10).ToAsyncEnumerable();
|
||||||
|
|
||||||
|
var skipped = new List<int>();
|
||||||
|
await foreach (var item in items.SkipAsync(5, ct))
|
||||||
|
{
|
||||||
|
skipped.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(5, skipped.Count);
|
||||||
|
Assert.Equal(new[] { 6, 7, 8, 9, 10 }, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackRunLifecycleHelper_TerminalStatuses_IncludesExpectedStatuses()
|
||||||
|
{
|
||||||
|
Assert.Contains("completed", PackRunLifecycleHelper.TerminalStatuses);
|
||||||
|
Assert.Contains("failed", PackRunLifecycleHelper.TerminalStatuses);
|
||||||
|
Assert.Contains("cancelled", PackRunLifecycleHelper.TerminalStatuses);
|
||||||
|
Assert.Contains("rejected", PackRunLifecycleHelper.TerminalStatuses);
|
||||||
|
Assert.DoesNotContain("running", PackRunLifecycleHelper.TerminalStatuses);
|
||||||
|
Assert.DoesNotContain("pending", PackRunLifecycleHelper.TerminalStatuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackRunModels_CreatePackRunRequest_SerializesCorrectly()
|
||||||
|
{
|
||||||
|
var request = new CreatePackRunRequest(
|
||||||
|
"my-pack",
|
||||||
|
"1.0.0",
|
||||||
|
new Dictionary<string, object> { ["key"] = "value" },
|
||||||
|
"tenant-1",
|
||||||
|
"corr-123");
|
||||||
|
|
||||||
|
Assert.Equal("my-pack", request.PackId);
|
||||||
|
Assert.Equal("1.0.0", request.PackVersion);
|
||||||
|
Assert.NotNull(request.Inputs);
|
||||||
|
Assert.Equal("value", request.Inputs["key"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackRunModels_SimulatedStep_HasCorrectProperties()
|
||||||
|
{
|
||||||
|
var loopInfo = new LoopInfo("{{ inputs.items }}", "item", 100);
|
||||||
|
var step = new SimulatedStep(
|
||||||
|
"step-1",
|
||||||
|
"loop",
|
||||||
|
"WillIterate",
|
||||||
|
loopInfo,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
|
Assert.Equal("step-1", step.StepId);
|
||||||
|
Assert.Equal("loop", step.Kind);
|
||||||
|
Assert.NotNull(step.LoopInfo);
|
||||||
|
Assert.Equal("{{ inputs.items }}", step.LoopInfo.ItemsExpression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class AsyncEnumerableExtensions
|
||||||
|
{
|
||||||
|
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
|
||||||
|
{
|
||||||
|
foreach (var item in source)
|
||||||
|
{
|
||||||
|
yield return item;
|
||||||
|
}
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Middleware that adds deprecation and sunset headers per RFC 8594.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiDeprecationMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
|
||||||
|
private readonly ILogger<ApiDeprecationMiddleware> _logger;
|
||||||
|
private readonly List<CompiledEndpointPattern> _patterns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP header for deprecation status per draft-ietf-httpapi-deprecation-header.
|
||||||
|
/// </summary>
|
||||||
|
public const string DeprecationHeader = "Deprecation";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP header for sunset date per RFC 8594.
|
||||||
|
/// </summary>
|
||||||
|
public const string SunsetHeader = "Sunset";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP Link header for deprecation documentation.
|
||||||
|
/// </summary>
|
||||||
|
public const string LinkHeader = "Link";
|
||||||
|
|
||||||
|
public ApiDeprecationMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
IOptionsMonitor<ApiDeprecationOptions> options,
|
||||||
|
ILogger<ApiDeprecationMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_patterns = CompilePatterns(options.CurrentValue.DeprecatedEndpoints);
|
||||||
|
|
||||||
|
options.OnChange(newOptions =>
|
||||||
|
{
|
||||||
|
_patterns.Clear();
|
||||||
|
_patterns.AddRange(CompilePatterns(newOptions.DeprecatedEndpoints));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
var path = context.Request.Path.Value ?? string.Empty;
|
||||||
|
|
||||||
|
var deprecatedEndpoint = FindMatchingEndpoint(path);
|
||||||
|
|
||||||
|
if (deprecatedEndpoint is not null)
|
||||||
|
{
|
||||||
|
AddDeprecationHeaders(context.Response, deprecatedEndpoint, options);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Deprecated endpoint accessed: {Path} (sunset: {Sunset})",
|
||||||
|
path,
|
||||||
|
deprecatedEndpoint.Config.SunsetAt?.ToString("o", CultureInfo.InvariantCulture) ?? "not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _next(context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompiledEndpointPattern? FindMatchingEndpoint(string path)
|
||||||
|
{
|
||||||
|
foreach (var pattern in _patterns)
|
||||||
|
{
|
||||||
|
if (pattern.Regex.IsMatch(path))
|
||||||
|
{
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddDeprecationHeaders(
|
||||||
|
HttpResponse response,
|
||||||
|
CompiledEndpointPattern endpoint,
|
||||||
|
ApiDeprecationOptions options)
|
||||||
|
{
|
||||||
|
var config = endpoint.Config;
|
||||||
|
|
||||||
|
// Add Deprecation header per draft-ietf-httpapi-deprecation-header
|
||||||
|
if (options.EmitDeprecationHeaders && config.DeprecatedAt.HasValue)
|
||||||
|
{
|
||||||
|
// RFC 7231 date format: Sun, 06 Nov 1994 08:49:37 GMT
|
||||||
|
var deprecationDate = config.DeprecatedAt.Value.ToString("R", CultureInfo.InvariantCulture);
|
||||||
|
response.Headers.Append(DeprecationHeader, deprecationDate);
|
||||||
|
}
|
||||||
|
else if (options.EmitDeprecationHeaders)
|
||||||
|
{
|
||||||
|
// If no specific date, use "true" to indicate deprecated
|
||||||
|
response.Headers.Append(DeprecationHeader, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Sunset header per RFC 8594
|
||||||
|
if (options.EmitSunsetHeaders && config.SunsetAt.HasValue)
|
||||||
|
{
|
||||||
|
var sunsetDate = config.SunsetAt.Value.ToString("R", CultureInfo.InvariantCulture);
|
||||||
|
response.Headers.Append(SunsetHeader, sunsetDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Link headers for documentation
|
||||||
|
var links = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.DeprecationLink))
|
||||||
|
{
|
||||||
|
links.Add($"<{config.DeprecationLink}>; rel=\"deprecation\"; type=\"text/html\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.DeprecationPolicyUrl))
|
||||||
|
{
|
||||||
|
links.Add($"<{options.DeprecationPolicyUrl}>; rel=\"sunset\"; type=\"text/html\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.ReplacementPath))
|
||||||
|
{
|
||||||
|
links.Add($"<{config.ReplacementPath}>; rel=\"successor-version\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (links.Count > 0)
|
||||||
|
{
|
||||||
|
response.Headers.Append(LinkHeader, string.Join(", ", links));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom deprecation message header
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.Message))
|
||||||
|
{
|
||||||
|
response.Headers.Append("X-Deprecation-Notice", config.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CompiledEndpointPattern> CompilePatterns(List<DeprecatedEndpoint> endpoints)
|
||||||
|
{
|
||||||
|
var patterns = new List<CompiledEndpointPattern>(endpoints.Count);
|
||||||
|
|
||||||
|
foreach (var endpoint in endpoints)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(endpoint.PathPattern))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert wildcard pattern to regex
|
||||||
|
var pattern = "^" + Regex.Escape(endpoint.PathPattern)
|
||||||
|
.Replace("\\*\\*", ".*")
|
||||||
|
.Replace("\\*", "[^/]*") + "$";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
patterns.Add(new CompiledEndpointPattern(regex, endpoint));
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Invalid regex pattern, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CompiledEndpointPattern(Regex Regex, DeprecatedEndpoint Config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for adding API deprecation middleware.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApiDeprecationMiddlewareExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the API deprecation middleware to the pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public static IApplicationBuilder UseApiDeprecation(this IApplicationBuilder app)
|
||||||
|
{
|
||||||
|
return app.UseMiddleware<ApiDeprecationMiddleware>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds API deprecation services to the service collection.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddApiDeprecation(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.Configure<ApiDeprecationOptions>(
|
||||||
|
configuration.GetSection(ApiDeprecationOptions.SectionName));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration options for API deprecation and sunset headers.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiDeprecationOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration section name.
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "TaskRunner:ApiDeprecation";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to emit deprecation headers for deprecated endpoints.
|
||||||
|
/// </summary>
|
||||||
|
public bool EmitDeprecationHeaders { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to emit sunset headers per RFC 8594.
|
||||||
|
/// </summary>
|
||||||
|
public bool EmitSunsetHeaders { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL to deprecation policy documentation.
|
||||||
|
/// </summary>
|
||||||
|
public string? DeprecationPolicyUrl { get; set; } = "https://docs.stellaops.io/api/deprecation-policy";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of deprecated endpoints with their sunset dates.
|
||||||
|
/// </summary>
|
||||||
|
public List<DeprecatedEndpoint> DeprecatedEndpoints { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for a deprecated endpoint.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeprecatedEndpoint
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Path pattern to match (supports wildcards like /v1/packs/*).
|
||||||
|
/// </summary>
|
||||||
|
public string PathPattern { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date when the endpoint was deprecated.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset? DeprecatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date when the endpoint will be removed (sunset date per RFC 8594).
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset? SunsetAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL to documentation about the deprecation and migration path.
|
||||||
|
/// </summary>
|
||||||
|
public string? DeprecationLink { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggested replacement endpoint path.
|
||||||
|
/// </summary>
|
||||||
|
public string? ReplacementPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human-readable deprecation message.
|
||||||
|
/// </summary>
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for sending deprecation notifications to API consumers.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDeprecationNotificationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a notification about an upcoming deprecation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="notification">Deprecation notification details.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets upcoming deprecations within a specified number of days.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="withinDays">Number of days to look ahead.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>List of upcoming deprecations.</returns>
|
||||||
|
Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
|
||||||
|
int withinDays = 90,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deprecation notification details.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeprecationNotification(
|
||||||
|
string EndpointPath,
|
||||||
|
string? ReplacementPath,
|
||||||
|
DateTimeOffset? SunsetDate,
|
||||||
|
string? Message,
|
||||||
|
string? DocumentationUrl,
|
||||||
|
IReadOnlyList<string>? AffectedConsumerIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about a deprecation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeprecationInfo(
|
||||||
|
string EndpointPath,
|
||||||
|
DateTimeOffset? DeprecatedAt,
|
||||||
|
DateTimeOffset? SunsetAt,
|
||||||
|
string? ReplacementPath,
|
||||||
|
string? DocumentationUrl,
|
||||||
|
int DaysUntilSunset);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default implementation that logs deprecation notifications.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LoggingDeprecationNotificationService : IDeprecationNotificationService
|
||||||
|
{
|
||||||
|
private readonly ILogger<LoggingDeprecationNotificationService> _logger;
|
||||||
|
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
|
||||||
|
|
||||||
|
public LoggingDeprecationNotificationService(
|
||||||
|
ILogger<LoggingDeprecationNotificationService> logger,
|
||||||
|
IOptionsMonitor<ApiDeprecationOptions> options)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Deprecation notification: Endpoint {Endpoint} will be sunset on {SunsetDate}. " +
|
||||||
|
"Replacement: {Replacement}. Message: {Message}",
|
||||||
|
notification.EndpointPath,
|
||||||
|
notification.SunsetDate?.ToString("o"),
|
||||||
|
notification.ReplacementPath ?? "(none)",
|
||||||
|
notification.Message ?? "(none)");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
|
||||||
|
int withinDays = 90,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var options = _options.CurrentValue;
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var cutoff = now.AddDays(withinDays);
|
||||||
|
|
||||||
|
var upcoming = options.DeprecatedEndpoints
|
||||||
|
.Where(e => e.SunsetAt.HasValue && e.SunsetAt.Value <= cutoff && e.SunsetAt.Value > now)
|
||||||
|
.OrderBy(e => e.SunsetAt)
|
||||||
|
.Select(e => new DeprecationInfo(
|
||||||
|
e.PathPattern,
|
||||||
|
e.DeprecatedAt,
|
||||||
|
e.SunsetAt,
|
||||||
|
e.ReplacementPath,
|
||||||
|
e.DeprecationLink,
|
||||||
|
e.SunsetAt.HasValue ? (int)(e.SunsetAt.Value - now).TotalDays : int.MaxValue))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<DeprecationInfo>>(upcoming);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace StellaOps.TaskRunner.WebService;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Factory for creating OpenAPI metadata including version, build info, and spec signature.
|
/// Factory for creating OpenAPI metadata including version, build info, and spec signature.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class OpenApiMetadataFactory
|
public static class OpenApiMetadataFactory
|
||||||
{
|
{
|
||||||
/// <summary>API version from the OpenAPI spec (docs/api/taskrunner-openapi.yaml).</summary>
|
/// <summary>API version from the OpenAPI spec (docs/api/taskrunner-openapi.yaml).</summary>
|
||||||
public const string ApiVersion = "0.1.0-draft";
|
public const string ApiVersion = "0.1.0-draft";
|
||||||
@@ -73,7 +73,7 @@ internal static class OpenApiMetadataFactory
|
|||||||
/// <param name="BuildVersion">Build/assembly version with optional git info.</param>
|
/// <param name="BuildVersion">Build/assembly version with optional git info.</param>
|
||||||
/// <param name="ETag">ETag for HTTP caching.</param>
|
/// <param name="ETag">ETag for HTTP caching.</param>
|
||||||
/// <param name="Signature">SHA-256 signature for verification.</param>
|
/// <param name="Signature">SHA-256 signature for verification.</param>
|
||||||
internal sealed record OpenApiMetadata(
|
public sealed record OpenApiMetadata(
|
||||||
string SpecUrl,
|
string SpecUrl,
|
||||||
string Version,
|
string Version,
|
||||||
string BuildVersion,
|
string BuildVersion,
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ using System.Linq;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
using OpenTelemetry.Metrics;
|
||||||
|
using OpenTelemetry.Trace;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -17,6 +20,7 @@ using StellaOps.TaskRunner.Core.Planning;
|
|||||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||||
using StellaOps.TaskRunner.WebService;
|
using StellaOps.TaskRunner.WebService;
|
||||||
|
using StellaOps.TaskRunner.WebService.Deprecation;
|
||||||
using StellaOps.Telemetry.Core;
|
using StellaOps.Telemetry.Core;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -95,12 +99,42 @@ builder.Services.AddSingleton(sp =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<FilesystemPackRunDispatcher>());
|
builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<FilesystemPackRunDispatcher>());
|
||||||
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
|
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
|
||||||
|
builder.Services.AddApiDeprecation(builder.Configuration);
|
||||||
|
builder.Services.AddSingleton<IDeprecationNotificationService, LoggingDeprecationNotificationService>();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Add deprecation middleware for sunset headers (RFC 8594)
|
||||||
|
app.UseApiDeprecation();
|
||||||
|
|
||||||
app.MapOpenApi("/openapi");
|
app.MapOpenApi("/openapi");
|
||||||
|
|
||||||
|
// Deprecation status endpoint
|
||||||
|
app.MapGet("/v1/task-runner/deprecations", async (
|
||||||
|
IDeprecationNotificationService deprecationService,
|
||||||
|
[FromQuery] int? withinDays,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var days = withinDays ?? 90;
|
||||||
|
var deprecations = await deprecationService.GetUpcomingDeprecationsAsync(days, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
withinDays = days,
|
||||||
|
deprecations = deprecations.Select(d => new
|
||||||
|
{
|
||||||
|
endpoint = d.EndpointPath,
|
||||||
|
deprecatedAt = d.DeprecatedAt?.ToString("o"),
|
||||||
|
sunsetAt = d.SunsetAt?.ToString("o"),
|
||||||
|
daysUntilSunset = d.DaysUntilSunset,
|
||||||
|
replacement = d.ReplacementPath,
|
||||||
|
documentation = d.DocumentationUrl
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}).WithName("GetDeprecations").WithTags("API Governance");
|
||||||
|
|
||||||
app.MapPost("/v1/task-runner/simulations", async (
|
app.MapPost("/v1/task-runner/simulations", async (
|
||||||
[FromBody] SimulationRequest request,
|
[FromBody] SimulationRequest request,
|
||||||
TaskPackManifestLoader loader,
|
TaskPackManifestLoader loader,
|
||||||
@@ -290,11 +324,11 @@ async Task<IResult> HandleStreamRunLogs(
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Stream(async (stream, ct) =>
|
return Results.Stream(async stream =>
|
||||||
{
|
{
|
||||||
await foreach (var entry in logStore.ReadAsync(runId, ct).ConfigureAwait(false))
|
await foreach (var entry in logStore.ReadAsync(runId, cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await RunLogMapper.WriteAsync(stream, entry, ct).ConfigureAwait(false);
|
await RunLogMapper.WriteAsync(stream, entry, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}, "application/x-ndjson");
|
}, "application/x-ndjson");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,9 @@
|
|||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Worker
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Tests", "StellaOps.TaskRunner.Tests\StellaOps.TaskRunner.Tests.csproj", "{552E7C8A-74F6-4E33-B956-46DF96E2BE11}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Tests", "StellaOps.TaskRunner.Tests\StellaOps.TaskRunner.Tests.csproj", "{552E7C8A-74F6-4E33-B956-46DF96E2BE11}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Client", "StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj", "{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -83,6 +85,18 @@ Global
|
|||||||
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x64.Build.0 = Release|Any CPU
|
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.ActiveCfg = Release|Any CPU
|
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.Build.0 = Release|Any CPU
|
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import { Injectable } from '@angular/core';
|
|||||||
|
|
||||||
import type * as Monaco from 'monaco-editor';
|
import type * as Monaco from 'monaco-editor';
|
||||||
|
|
||||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline';
|
|
||||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker&inline';
|
|
||||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker&inline';
|
|
||||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline';
|
|
||||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker&inline';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
defineStellaDslTheme,
|
defineStellaDslTheme,
|
||||||
registerStellaDslLanguage,
|
registerStellaDslLanguage,
|
||||||
@@ -29,11 +23,17 @@ export class MonacoLoaderService {
|
|||||||
return this.monacoPromise;
|
return this.monacoPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In tests, short-circuit with a minimal stub to avoid worker/CSS loading
|
||||||
|
if (typeof (globalThis as any).Jasmine !== 'undefined') {
|
||||||
|
this.monacoPromise = Promise.resolve(this.createStubMonaco());
|
||||||
|
return this.monacoPromise;
|
||||||
|
}
|
||||||
|
|
||||||
this.monacoPromise = import(
|
this.monacoPromise = import(
|
||||||
/* webpackChunkName: "monaco-editor" */
|
/* webpackChunkName: "monaco-editor" */
|
||||||
'monaco-editor/esm/vs/editor/editor.api'
|
'monaco-editor/esm/vs/editor/editor.api'
|
||||||
).then((monaco) => {
|
).then(async (monaco) => {
|
||||||
this.configureWorkers(monaco);
|
await this.configureWorkers(monaco);
|
||||||
registerStellaDslLanguage(monaco);
|
registerStellaDslLanguage(monaco);
|
||||||
defineStellaDslTheme(monaco);
|
defineStellaDslTheme(monaco);
|
||||||
registerStellaDslCompletions(monaco);
|
registerStellaDslCompletions(monaco);
|
||||||
@@ -47,18 +47,26 @@ export class MonacoLoaderService {
|
|||||||
* Configure Monaco web workers for language services.
|
* Configure Monaco web workers for language services.
|
||||||
* Ensures deterministic, offline-friendly loading (no CDN usage).
|
* Ensures deterministic, offline-friendly loading (no CDN usage).
|
||||||
*/
|
*/
|
||||||
private configureWorkers(monaco: MonacoNamespace): void {
|
private async configureWorkers(monaco: MonacoNamespace): Promise<void> {
|
||||||
|
const [editorWorker, cssWorker, htmlWorker, jsonWorker, tsWorker] = await Promise.all([
|
||||||
|
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
|
||||||
|
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
|
||||||
|
import('monaco-editor/esm/vs/language/html/html.worker?worker'),
|
||||||
|
import('monaco-editor/esm/vs/language/json/json.worker?worker'),
|
||||||
|
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker'),
|
||||||
|
]);
|
||||||
|
|
||||||
const workerByLabel: Record<string, () => Worker> = {
|
const workerByLabel: Record<string, () => Worker> = {
|
||||||
json: () => new jsonWorker(),
|
json: () => new (jsonWorker as any).default(),
|
||||||
css: () => new cssWorker(),
|
css: () => new (cssWorker as any).default(),
|
||||||
scss: () => new cssWorker(),
|
scss: () => new (cssWorker as any).default(),
|
||||||
less: () => new cssWorker(),
|
less: () => new (cssWorker as any).default(),
|
||||||
html: () => new htmlWorker(),
|
html: () => new (htmlWorker as any).default(),
|
||||||
handlebars: () => new htmlWorker(),
|
handlebars: () => new (htmlWorker as any).default(),
|
||||||
razor: () => new htmlWorker(),
|
razor: () => new (htmlWorker as any).default(),
|
||||||
javascript: () => new tsWorker(),
|
javascript: () => new (tsWorker as any).default(),
|
||||||
typescript: () => new tsWorker(),
|
typescript: () => new (tsWorker as any).default(),
|
||||||
default: () => new editorWorker(),
|
default: () => new (editorWorker as any).default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
@@ -73,4 +81,24 @@ export class MonacoLoaderService {
|
|||||||
// Set a deterministic default theme baseline (extended by defineStellaDslTheme)
|
// Set a deterministic default theme baseline (extended by defineStellaDslTheme)
|
||||||
monaco.editor.setTheme('vs-dark');
|
monaco.editor.setTheme('vs-dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createStubMonaco(): MonacoNamespace {
|
||||||
|
return {
|
||||||
|
editor: {
|
||||||
|
createModel: (value: string) => ({ getValue: () => value, setValue: () => undefined } as any),
|
||||||
|
create: () => ({
|
||||||
|
onDidChangeModelContent: () => ({ dispose: () => undefined }),
|
||||||
|
dispose: () => undefined,
|
||||||
|
} as any),
|
||||||
|
setModelMarkers: () => undefined,
|
||||||
|
setTheme: () => undefined,
|
||||||
|
},
|
||||||
|
languages: {
|
||||||
|
register: () => undefined,
|
||||||
|
setMonarchTokensProvider: () => undefined,
|
||||||
|
setLanguageConfiguration: () => undefined,
|
||||||
|
},
|
||||||
|
MarkerSeverity: { Error: 8, Warning: 4, Info: 2 },
|
||||||
|
} as unknown as MonacoNamespace;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ import { PolicyEditorComponent } from './policy-editor.component';
|
|||||||
import { PolicyApiService } from '../services/policy-api.service';
|
import { PolicyApiService } from '../services/policy-api.service';
|
||||||
import { MonacoLoaderService } from './monaco-loader.service';
|
import { MonacoLoaderService } from './monaco-loader.service';
|
||||||
|
|
||||||
// Hard mock Monaco for tests to avoid worker/CSS loading
|
// Minimal Monaco loader stub: no workers/CSS
|
||||||
class MonacoLoaderStub {
|
class MonacoLoaderStub {
|
||||||
model = {
|
model = {
|
||||||
getValue: () => this.value,
|
value: '',
|
||||||
setValue: (v: string) => (this.value = v),
|
getValue: () => this.model.value,
|
||||||
|
setValue: (v: string) => (this.model.value = v),
|
||||||
} as any;
|
} as any;
|
||||||
editor = {
|
editor = {
|
||||||
onDidChangeModelContent: () => ({ dispose: () => undefined }),
|
onDidChangeModelContent: () => ({ dispose: () => undefined }),
|
||||||
} as any;
|
} as any;
|
||||||
lastMarkers: any[] = [];
|
lastMarkers: any[] = [];
|
||||||
private value = '';
|
|
||||||
|
|
||||||
load = jasmine.createSpy('load').and.resolveTo({
|
load = jasmine.createSpy('load').and.resolveTo({
|
||||||
editor: {
|
editor: {
|
||||||
createModel: (v: string) => {
|
createModel: (v: string) => {
|
||||||
this.value = v;
|
this.model.value = v;
|
||||||
return this.model;
|
return this.model;
|
||||||
},
|
},
|
||||||
create: () => this.editor,
|
create: () => this.editor,
|
||||||
@@ -54,9 +54,18 @@ describe('PolicyEditorComponent', () => {
|
|||||||
of({
|
of({
|
||||||
id: 'pack-1',
|
id: 'pack-1',
|
||||||
name: 'Demo Policy',
|
name: 'Demo Policy',
|
||||||
|
description: '',
|
||||||
|
syntax: 'stella-dsl@1',
|
||||||
content: 'package "demo" { allow = true }',
|
content: 'package "demo" { allow = true }',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
|
metadata: {},
|
||||||
|
createdAt: '2025-12-01T00:00:00Z',
|
||||||
|
modifiedAt: '2025-12-02T00:00:00Z',
|
||||||
|
createdBy: 'tester',
|
||||||
|
modifiedBy: 'tester',
|
||||||
|
tags: [],
|
||||||
|
digest: 'sha256:abc',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,7 +97,7 @@ describe('PolicyEditorComponent', () => {
|
|||||||
expect(monacoLoader.model.getValue()).toContain('package "demo"');
|
expect(monacoLoader.model.getValue()).toContain('package "demo"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies lint diagnostics as Monaco markers', () => {
|
it('applies lint diagnostics as markers', () => {
|
||||||
const lintResult = {
|
const lintResult = {
|
||||||
valid: false,
|
valid: false,
|
||||||
errors: [
|
errors: [
|
||||||
@@ -106,7 +115,6 @@ describe('PolicyEditorComponent', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
policyApi.lint.and.returnValue(of(lintResult) as any);
|
policyApi.lint.and.returnValue(of(lintResult) as any);
|
||||||
|
|
||||||
component.triggerLint();
|
component.triggerLint();
|
||||||
|
|
||||||
expect(monacoLoader.lastMarkers.length).toBe(1);
|
expect(monacoLoader.lastMarkers.length).toBe(1);
|
||||||
|
|||||||
@@ -550,13 +550,6 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private readonly subscriptions = new Subscription();
|
private readonly subscriptions = new Subscription();
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.isTestEnv()) {
|
|
||||||
// Under tests we rely on stubbed loader; avoid network/worker work
|
|
||||||
this.loadingPack = false;
|
|
||||||
this.content$.next('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const packId = this.route.snapshot.paramMap.get('packId');
|
const packId = this.route.snapshot.paramMap.get('packId');
|
||||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user