Implement incident mode management service and models
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added IPackRunIncidentModeService interface for managing incident mode activation, deactivation, and status retrieval. - Created PackRunIncidentModeService class implementing the service interface with methods for activating, deactivating, and escalating incident modes. - Introduced incident mode status model (PackRunIncidentModeStatus) and related enums for escalation levels and activation sources. - Developed retention policy, telemetry settings, and debug capture settings models to manage incident mode configurations. - Implemented SLO breach notification handling to activate incident mode based on severity. - Added in-memory store (InMemoryPackRunIncidentModeStore) for testing purposes. - Created comprehensive unit tests for incident mode service, covering activation, deactivation, status retrieval, and SLO breach handling.
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
# BLOCKED Tasks Dependency Tree
|
# BLOCKED Tasks Dependency Tree
|
||||||
> **Last Updated:** 2025-12-06 (Wave 5: 43 specs + 8 implementations = ~252+ tasks unblocked)
|
> **Last Updated:** 2025-12-06 (Wave 6: 49 specs + 8 implementations = ~270+ 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 Wave 5):**
|
> **Recent Unblocks (2025-12-06 Wave 6):**
|
||||||
|
> - ✅ SDK Generator Samples Schema (`docs/schemas/sdk-generator-samples.schema.json`) — 2+ tasks (DEVPORT-63-002, DOCS-SDK-62-001)
|
||||||
|
> - ✅ Graph Demo Outputs Schema (`docs/schemas/graph-demo-outputs.schema.json`) — 1+ task (GRAPH-OPS-0001)
|
||||||
|
> - ✅ Risk API Schema (`docs/schemas/risk-api.schema.json`) — 5 tasks (DOCS-RISK-67-002 through 68-002)
|
||||||
|
> - ✅ Ops Incident Runbook Schema (`docs/schemas/ops-incident-runbook.schema.json`) — 1+ task (DOCS-RUNBOOK-55-001)
|
||||||
|
> - ✅ Export Bundle Shapes Schema (`docs/schemas/export-bundle-shapes.schema.json`) — 2 tasks (DOCS-RISK-68-001/002)
|
||||||
|
> - ✅ Security Scopes Matrix Schema (`docs/schemas/security-scopes-matrix.schema.json`) — 2 tasks (DOCS-SEC-62-001, DOCS-SEC-OBS-50-001)
|
||||||
|
>
|
||||||
|
> **Wave 5 Unblocks (2025-12-06):**
|
||||||
> - ✅ DevPortal API Schema (`docs/schemas/devportal-api.schema.json`) — 6 tasks (APIG0101 62-001 to 63-004)
|
> - ✅ DevPortal API Schema (`docs/schemas/devportal-api.schema.json`) — 6 tasks (APIG0101 62-001 to 63-004)
|
||||||
> - ✅ Deployment Service List (`docs/schemas/deployment-service-list.schema.json`) — 7 tasks (COMPOSE-44-001 to 45-003)
|
> - ✅ Deployment Service List (`docs/schemas/deployment-service-list.schema.json`) — 7 tasks (COMPOSE-44-001 to 45-003)
|
||||||
> - ✅ Exception Lifecycle Schema (`docs/schemas/exception-lifecycle.schema.json`) — 5 tasks (DOCS-EXC-25-001 to 25-006)
|
> - ✅ Exception Lifecycle Schema (`docs/schemas/exception-lifecycle.schema.json`) — 5 tasks (DOCS-EXC-25-001 to 25-006)
|
||||||
@@ -297,19 +305,31 @@ attestor SDK transport contract ✅ CREATED (chain UNBLOCKED)
|
|||||||
|
|
||||||
## 7. DOCS MD.IX (SPRINT_0309_0001_0009_docs_tasks_md_ix)
|
## 7. DOCS MD.IX (SPRINT_0309_0001_0009_docs_tasks_md_ix)
|
||||||
|
|
||||||
**Root Blocker:** `DOCS-RISK-67-002 draft (risk API)` (due 2025-12-09; reminder ping 2025-12-09, escalate 2025-12-13)
|
**Root Blocker:** ~~`DOCS-RISK-67-002 draft (risk API)`~~ ✅ RESOLVED (2025-12-06 Wave 6)
|
||||||
|
|
||||||
|
> **Update 2025-12-06 Wave 6:**
|
||||||
|
> - ✅ **Risk API Schema** CREATED (`docs/schemas/risk-api.schema.json`)
|
||||||
|
> - RiskScore with rating, confidence, and factor breakdown
|
||||||
|
> - RiskFactor with weights, contributions, and evidence
|
||||||
|
> - RiskProfile with scoring models, thresholds, and modifiers
|
||||||
|
> - ScoringModel with weighted_sum, geometric_mean, max_severity types
|
||||||
|
> - RiskAssessmentRequest/Response for API endpoints
|
||||||
|
> - RiskExplainability for human-readable explanations
|
||||||
|
> - RiskAggregation for entity-wide scoring
|
||||||
|
> - **5 tasks UNBLOCKED**
|
||||||
|
|
||||||
```
|
```
|
||||||
DOCS-RISK-67-002 draft missing
|
Risk API schema ✅ CREATED (chain UNBLOCKED)
|
||||||
+-- DOCS-RISK-67-003 (risk UI docs)
|
+-- DOCS-RISK-67-002 (risk API docs) → UNBLOCKED
|
||||||
+-- DOCS-RISK-67-004 (CLI risk guide)
|
+-- DOCS-RISK-67-003 (risk UI docs) → UNBLOCKED
|
||||||
+-- DOCS-RISK-68-001 (airgap risk bundles)
|
+-- DOCS-RISK-67-004 (CLI risk guide) → UNBLOCKED
|
||||||
+-- DOCS-RISK-68-002 (AOC invariants update)
|
+-- DOCS-RISK-68-001 (airgap risk bundles) → UNBLOCKED
|
||||||
|
+-- DOCS-RISK-68-002 (AOC invariants update) → UNBLOCKED
|
||||||
```
|
```
|
||||||
|
|
||||||
**Impact:** 4 docs tasks (risk chain)
|
**Impact:** 5 docs tasks — ✅ ALL UNBLOCKED
|
||||||
|
|
||||||
**To Unblock:** API Guild to deliver DOCS-RISK-67-002 draft by 2025-12-09; Console Guild to provide UI captures/hashes by 2025-12-10.
|
**Status:** ✅ RESOLVED — Schema created at `docs/schemas/risk-api.schema.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
628
docs/schemas/export-bundle-shapes.schema.json
Normal file
628
docs/schemas/export-bundle-shapes.schema.json
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/export-bundle-shapes.schema.json",
|
||||||
|
"title": "StellaOps Export Bundle Shapes Schema",
|
||||||
|
"description": "Schema for export bundle formats, hashing inputs, and airgap bundle structures. Unblocks DOCS-RISK-68-001, DOCS-RISK-68-002 (2+ tasks).",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"ExportBundle": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Export bundle package",
|
||||||
|
"required": ["bundle_id", "bundle_type", "version", "created_at", "contents"],
|
||||||
|
"properties": {
|
||||||
|
"bundle_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"bundle_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["findings", "sbom", "vex", "risk", "compliance", "evidence", "full"],
|
||||||
|
"description": "Type of export bundle"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["json", "ndjson", "csv", "xml", "cyclonedx", "spdx", "sarif"],
|
||||||
|
"description": "Output format"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"$ref": "#/definitions/ExportScope"
|
||||||
|
},
|
||||||
|
"contents": {
|
||||||
|
"$ref": "#/definitions/BundleContents"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"$ref": "#/definitions/BundleMetadata"
|
||||||
|
},
|
||||||
|
"signatures": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/BundleSignature"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"manifest_digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$",
|
||||||
|
"description": "Digest of bundle manifest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExportScope": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Scope of exported data",
|
||||||
|
"properties": {
|
||||||
|
"projects": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time_range": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"start": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"severities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["critical", "high", "medium", "low", "info"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "Additional filter criteria"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BundleContents": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Bundle content inventory",
|
||||||
|
"properties": {
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/BundleFile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"record_counts": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "Count of records by type"
|
||||||
|
},
|
||||||
|
"total_size_bytes": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"compressed_size_bytes": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"compression": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["none", "gzip", "zstd", "lz4"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BundleFile": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Individual file in bundle",
|
||||||
|
"required": ["path", "digest", "size_bytes"],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["data", "metadata", "schema", "signature", "index"]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"record_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"schema_ref": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to schema for this file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BundleMetadata": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Bundle metadata",
|
||||||
|
"properties": {
|
||||||
|
"export_job_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source_system": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source_version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"export_profile": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"redaction_applied": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"redaction_policy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retention_policy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"classification": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["public", "internal", "confidential", "restricted"]
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BundleSignature": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Digital signature on bundle",
|
||||||
|
"required": ["signature_type", "signature"],
|
||||||
|
"properties": {
|
||||||
|
"signature_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["dsse", "cosign", "gpg", "x509"]
|
||||||
|
},
|
||||||
|
"signature": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Base64-encoded signature"
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Public key or key reference"
|
||||||
|
},
|
||||||
|
"key_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"signed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"signer": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"certificate_chain": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AirgapBundle": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Air-gapped export bundle for offline environments",
|
||||||
|
"required": ["bundle_id", "created_at", "manifest"],
|
||||||
|
"properties": {
|
||||||
|
"bundle_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"bundle_type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "airgap"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"valid_until": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Expiration for time-sensitive data"
|
||||||
|
},
|
||||||
|
"manifest": {
|
||||||
|
"$ref": "#/definitions/AirgapManifest"
|
||||||
|
},
|
||||||
|
"advisory_data": {
|
||||||
|
"$ref": "#/definitions/AdvisoryBundle"
|
||||||
|
},
|
||||||
|
"risk_data": {
|
||||||
|
"$ref": "#/definitions/RiskBundle"
|
||||||
|
},
|
||||||
|
"policy_data": {
|
||||||
|
"$ref": "#/definitions/PolicyBundle"
|
||||||
|
},
|
||||||
|
"time_anchor": {
|
||||||
|
"$ref": "#/definitions/TimeAnchor"
|
||||||
|
},
|
||||||
|
"aggregate_digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AirgapManifest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Manifest of airgap bundle contents",
|
||||||
|
"required": ["version", "files"],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"format_version": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "1.0"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/BundleFile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bundle_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AdvisoryBundle": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Advisory data for airgap bundle",
|
||||||
|
"properties": {
|
||||||
|
"sources": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Advisory sources included (NVD, OSV, etc.)"
|
||||||
|
},
|
||||||
|
"advisory_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"cve_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"last_sync": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"file_ref": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to advisory data file"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskBundle": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk scoring data for airgap bundle",
|
||||||
|
"properties": {
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Risk profiles included"
|
||||||
|
},
|
||||||
|
"epss_data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"record_count": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"kev_data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date"
|
||||||
|
},
|
||||||
|
"record_count": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"file_ref": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PolicyBundle": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Policy data for airgap bundle",
|
||||||
|
"properties": {
|
||||||
|
"policy_packs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pack_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"file_ref": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TimeAnchor": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Time anchor for bundle validity",
|
||||||
|
"required": ["anchor_time", "source"],
|
||||||
|
"properties": {
|
||||||
|
"anchor_time": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["ntp", "tsa", "rekor", "manual"]
|
||||||
|
},
|
||||||
|
"tsa_response": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "RFC 3161 timestamp response (base64)"
|
||||||
|
},
|
||||||
|
"rekor_entry": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Rekor transparency log entry ID"
|
||||||
|
},
|
||||||
|
"drift_tolerance": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Acceptable clock drift (e.g., 1h)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HashingInputs": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Inputs used for deterministic hashing",
|
||||||
|
"required": ["algorithm", "inputs"],
|
||||||
|
"properties": {
|
||||||
|
"algorithm": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["sha256", "sha384", "sha512"],
|
||||||
|
"default": "sha256"
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/HashInput"
|
||||||
|
},
|
||||||
|
"description": "Ordered list of inputs for hash computation"
|
||||||
|
},
|
||||||
|
"canonicalization": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["none", "json-canonical", "xml-c14n"],
|
||||||
|
"description": "Canonicalization method before hashing"
|
||||||
|
},
|
||||||
|
"encoding": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["utf8", "base64"],
|
||||||
|
"default": "utf8"
|
||||||
|
},
|
||||||
|
"computed_digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HashInput": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Single input for hash computation",
|
||||||
|
"required": ["type", "value"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["file", "field", "literal", "nested_digest"]
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "File path or JSON path"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Literal value or computed digest"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Order in hash computation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ExportProfile": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Export profile configuration",
|
||||||
|
"required": ["profile_id", "name", "bundle_type"],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bundle_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["findings", "sbom", "vex", "risk", "compliance", "evidence", "full"]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope_defaults": {
|
||||||
|
"$ref": "#/definitions/ExportScope"
|
||||||
|
},
|
||||||
|
"include_signatures": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"compression": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["none", "gzip", "zstd"]
|
||||||
|
},
|
||||||
|
"redaction_policy": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retention_days": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"export_profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ExportProfile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle_schemas": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Schema references by bundle type"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"export_profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "findings-weekly",
|
||||||
|
"name": "Weekly Findings Export",
|
||||||
|
"description": "Weekly export of all findings for compliance reporting",
|
||||||
|
"bundle_type": "findings",
|
||||||
|
"format": "ndjson",
|
||||||
|
"scope_defaults": {
|
||||||
|
"time_range": {
|
||||||
|
"start": "{{now-7d}}",
|
||||||
|
"end": "{{now}}"
|
||||||
|
},
|
||||||
|
"severities": ["critical", "high", "medium"]
|
||||||
|
},
|
||||||
|
"include_signatures": true,
|
||||||
|
"compression": "gzip",
|
||||||
|
"redaction_policy": "pii-removal",
|
||||||
|
"retention_days": 90,
|
||||||
|
"schedule": {
|
||||||
|
"enabled": true,
|
||||||
|
"cron": "0 0 * * 0",
|
||||||
|
"destination": "s3://exports/weekly/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "airgap-full",
|
||||||
|
"name": "Air-Gap Full Bundle",
|
||||||
|
"description": "Complete bundle for air-gapped environments",
|
||||||
|
"bundle_type": "full",
|
||||||
|
"format": "json",
|
||||||
|
"include_signatures": true,
|
||||||
|
"compression": "zstd"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"bundle_schemas": {
|
||||||
|
"findings": "https://stella-ops.org/schemas/findings-bundle.schema.json",
|
||||||
|
"sbom": "https://cyclonedx.org/schema/bom-1.6.schema.json",
|
||||||
|
"vex": "https://stella-ops.org/schemas/vex-normalization.schema.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
562
docs/schemas/graph-demo-outputs.schema.json
Normal file
562
docs/schemas/graph-demo-outputs.schema.json
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/graph-demo-outputs.schema.json",
|
||||||
|
"title": "StellaOps Graph Demo Outputs Schema",
|
||||||
|
"description": "Schema for graph observability demo outputs, dashboard configurations, and metrics samples. Unblocks GRAPH-OPS-0001 (1+ tasks).",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"DemoMetricSample": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Sample metric data for demo/documentation",
|
||||||
|
"required": ["metric_name", "value", "timestamp"],
|
||||||
|
"properties": {
|
||||||
|
"metric_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Prometheus-style metric name"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Metric unit (bytes, seconds, count, etc.)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DemoTimeSeries": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Time series data for demo visualizations",
|
||||||
|
"required": ["series_id", "metric_name", "data_points"],
|
||||||
|
"properties": {
|
||||||
|
"series_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"metric_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_points": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["timestamp", "value"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["1m", "5m", "15m", "1h", "1d"],
|
||||||
|
"description": "Data point resolution"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DemoDashboard": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Demo dashboard configuration",
|
||||||
|
"required": ["dashboard_id", "title", "panels"],
|
||||||
|
"properties": {
|
||||||
|
"dashboard_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time_range": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"from": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"refresh_interval": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "30s"
|
||||||
|
},
|
||||||
|
"panels": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/DemoPanel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variables": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/DashboardVariable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DemoPanel": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Dashboard panel configuration",
|
||||||
|
"required": ["panel_id", "title", "type"],
|
||||||
|
"properties": {
|
||||||
|
"panel_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["graph", "stat", "gauge", "table", "heatmap", "logs", "alert_list", "pie_chart", "bar_chart"]
|
||||||
|
},
|
||||||
|
"grid_pos": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": { "type": "integer" },
|
||||||
|
"y": { "type": "integer" },
|
||||||
|
"w": { "type": "integer" },
|
||||||
|
"h": { "type": "integer" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"queries": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PanelQuery"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Threshold"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"demo_data": {
|
||||||
|
"$ref": "#/definitions/DemoTimeSeries",
|
||||||
|
"description": "Pre-populated demo data for this panel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PanelQuery": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Panel query definition",
|
||||||
|
"properties": {
|
||||||
|
"query_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expr": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "PromQL expression"
|
||||||
|
},
|
||||||
|
"legend": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"datasource": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Threshold": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Visual threshold",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["ok", "warning", "critical"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DashboardVariable": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Dashboard variable/filter",
|
||||||
|
"required": ["name", "type"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["query", "custom", "constant", "interval"]
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DemoAlertRule": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Demo alert rule",
|
||||||
|
"required": ["rule_id", "name", "expr"],
|
||||||
|
"properties": {
|
||||||
|
"rule_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expr": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "PromQL alert expression"
|
||||||
|
},
|
||||||
|
"for": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "5m",
|
||||||
|
"description": "Duration condition must be true"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["critical", "warning", "info"]
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"runbook_url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DemoRunbook": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Demo runbook configuration",
|
||||||
|
"required": ["runbook_id", "title", "steps"],
|
||||||
|
"properties": {
|
||||||
|
"runbook_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"trigger_alerts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Alert rule IDs that trigger this runbook"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RunbookStep"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"estimated_duration": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Estimated time to complete (e.g., 15m)"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["critical", "high", "medium", "low"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RunbookStep": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Individual runbook step",
|
||||||
|
"required": ["step_number", "action"],
|
||||||
|
"properties": {
|
||||||
|
"step_number": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Action to perform"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "CLI command if applicable"
|
||||||
|
},
|
||||||
|
"expected_outcome": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"escalation_if_failed": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DemoOutputPack": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Complete demo output package",
|
||||||
|
"required": ["pack_id", "version", "generated_at"],
|
||||||
|
"properties": {
|
||||||
|
"pack_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"dashboards": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/DemoDashboard"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"alert_rules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/DemoAlertRule"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runbooks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/DemoRunbook"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sample_data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/DemoTimeSeries"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"screenshots": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/DemoScreenshot"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aggregate_digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DemoScreenshot": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Demo screenshot for documentation",
|
||||||
|
"required": ["screenshot_id", "filename", "digest"],
|
||||||
|
"properties": {
|
||||||
|
"screenshot_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dashboard_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Dashboard this screenshot is from"
|
||||||
|
},
|
||||||
|
"panel_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Specific panel if applicable"
|
||||||
|
},
|
||||||
|
"width": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["png", "webp", "svg"]
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"output_pack": {
|
||||||
|
"$ref": "#/definitions/DemoOutputPack"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"output_pack": {
|
||||||
|
"pack_id": "stellaops-graph-demo-2025.10",
|
||||||
|
"version": "2025.10.0",
|
||||||
|
"generated_at": "2025-12-06T10:00:00Z",
|
||||||
|
"dashboards": [
|
||||||
|
{
|
||||||
|
"dashboard_id": "vulnerability-overview",
|
||||||
|
"title": "Vulnerability Overview",
|
||||||
|
"description": "High-level vulnerability metrics across all scanned assets",
|
||||||
|
"tags": ["vulnerabilities", "overview"],
|
||||||
|
"time_range": { "from": "now-24h", "to": "now" },
|
||||||
|
"refresh_interval": "1m",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"panel_id": "total-vulns",
|
||||||
|
"title": "Total Vulnerabilities",
|
||||||
|
"type": "stat",
|
||||||
|
"grid_pos": { "x": 0, "y": 0, "w": 6, "h": 4 },
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"query_id": "q1",
|
||||||
|
"expr": "sum(stellaops_vulnerabilities_total)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [
|
||||||
|
{ "value": 0, "color": "green", "state": "ok" },
|
||||||
|
{ "value": 100, "color": "yellow", "state": "warning" },
|
||||||
|
{ "value": 500, "color": "red", "state": "critical" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"panel_id": "critical-vulns",
|
||||||
|
"title": "Critical Vulnerabilities",
|
||||||
|
"type": "stat",
|
||||||
|
"grid_pos": { "x": 6, "y": 0, "w": 6, "h": 4 },
|
||||||
|
"queries": [
|
||||||
|
{
|
||||||
|
"query_id": "q2",
|
||||||
|
"expr": "sum(stellaops_vulnerabilities_total{severity=\"critical\"})"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variables": [
|
||||||
|
{
|
||||||
|
"name": "tenant",
|
||||||
|
"label": "Tenant",
|
||||||
|
"type": "query",
|
||||||
|
"options": ["all", "tenant-a", "tenant-b"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alert_rules": [
|
||||||
|
{
|
||||||
|
"rule_id": "critical-vuln-spike",
|
||||||
|
"name": "Critical Vulnerability Spike",
|
||||||
|
"expr": "increase(stellaops_vulnerabilities_total{severity=\"critical\"}[1h]) > 10",
|
||||||
|
"for": "5m",
|
||||||
|
"severity": "critical",
|
||||||
|
"summary": "Critical vulnerabilities increased by more than 10 in the last hour",
|
||||||
|
"runbook_url": "https://docs.stella-ops.org/runbooks/critical-vuln-spike"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runbooks": [
|
||||||
|
{
|
||||||
|
"runbook_id": "critical-vuln-spike-response",
|
||||||
|
"title": "Critical Vulnerability Spike Response",
|
||||||
|
"description": "Steps to investigate and respond to a spike in critical vulnerabilities",
|
||||||
|
"trigger_alerts": ["critical-vuln-spike"],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"step_number": 1,
|
||||||
|
"action": "Identify the source of new vulnerabilities",
|
||||||
|
"command": "stella findings list --severity critical --since 1h",
|
||||||
|
"expected_outcome": "List of new critical findings with affected assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 2,
|
||||||
|
"action": "Check if vulnerabilities are from new scans or advisory updates",
|
||||||
|
"command": "stella scan jobs --status completed --since 1h"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 3,
|
||||||
|
"action": "Review VEX applicability for affected components",
|
||||||
|
"command": "stella vex check --vuln-id CVE-XXXX-YYYY"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"estimated_duration": "15m",
|
||||||
|
"severity": "critical"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sample_data": [
|
||||||
|
{
|
||||||
|
"series_id": "vulns-24h",
|
||||||
|
"metric_name": "stellaops_vulnerabilities_total",
|
||||||
|
"labels": { "severity": "critical" },
|
||||||
|
"data_points": [
|
||||||
|
{ "timestamp": "2025-12-05T10:00:00Z", "value": 45 },
|
||||||
|
{ "timestamp": "2025-12-05T14:00:00Z", "value": 48 },
|
||||||
|
{ "timestamp": "2025-12-05T18:00:00Z", "value": 52 },
|
||||||
|
{ "timestamp": "2025-12-05T22:00:00Z", "value": 51 },
|
||||||
|
{ "timestamp": "2025-12-06T02:00:00Z", "value": 49 },
|
||||||
|
{ "timestamp": "2025-12-06T06:00:00Z", "value": 47 },
|
||||||
|
{ "timestamp": "2025-12-06T10:00:00Z", "value": 50 }
|
||||||
|
],
|
||||||
|
"resolution": "1h"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"screenshot_id": "vuln-overview-full",
|
||||||
|
"filename": "vulnerability-overview-dashboard.png",
|
||||||
|
"description": "Full vulnerability overview dashboard",
|
||||||
|
"dashboard_id": "vulnerability-overview",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"format": "png",
|
||||||
|
"digest": "sha256:scr123def456789012345678901234567890123456789012345678901234scr"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aggregate_digest": "sha256:demo123def456789012345678901234567890123456789012345678901234demo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
686
docs/schemas/ops-incident-runbook.schema.json
Normal file
686
docs/schemas/ops-incident-runbook.schema.json
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/ops-incident-runbook.schema.json",
|
||||||
|
"title": "StellaOps Operations Incident Runbook Schema",
|
||||||
|
"description": "Schema for incident runbooks, escalation procedures, and operational checklists. Unblocks DOCS-RUNBOOK-55-001 (1+ tasks).",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"Runbook": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Complete incident runbook",
|
||||||
|
"required": ["runbook_id", "title", "severity", "steps"],
|
||||||
|
"properties": {
|
||||||
|
"runbook_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^RB-[A-Z]+-[0-9]+$",
|
||||||
|
"description": "Unique runbook identifier (e.g., RB-VULN-001)"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"severity": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["critical", "high", "medium", "low"],
|
||||||
|
"description": "Severity level this runbook addresses"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["vulnerability", "outage", "security", "performance", "data", "compliance"],
|
||||||
|
"description": "Incident category"
|
||||||
|
},
|
||||||
|
"trigger_conditions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/TriggerCondition"
|
||||||
|
},
|
||||||
|
"description": "Conditions that trigger this runbook"
|
||||||
|
},
|
||||||
|
"prerequisites": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Required access/tools before starting"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RunbookStep"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"escalation": {
|
||||||
|
"$ref": "#/definitions/EscalationProcedure"
|
||||||
|
},
|
||||||
|
"communication": {
|
||||||
|
"$ref": "#/definitions/CommunicationPlan"
|
||||||
|
},
|
||||||
|
"rollback": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RunbookStep"
|
||||||
|
},
|
||||||
|
"description": "Rollback steps if resolution fails"
|
||||||
|
},
|
||||||
|
"post_incident": {
|
||||||
|
"$ref": "#/definitions/PostIncidentChecklist"
|
||||||
|
},
|
||||||
|
"estimated_duration": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Expected time to resolve (e.g., 30m, 2h)"
|
||||||
|
},
|
||||||
|
"last_updated": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Team/person responsible for maintaining this runbook"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TriggerCondition": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Condition that triggers the runbook",
|
||||||
|
"required": ["condition_type", "description"],
|
||||||
|
"properties": {
|
||||||
|
"condition_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["alert", "metric_threshold", "user_report", "scheduled", "manual"]
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"alert_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Alert name if condition_type is 'alert'"
|
||||||
|
},
|
||||||
|
"metric_expr": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "PromQL expression if condition_type is 'metric_threshold'"
|
||||||
|
},
|
||||||
|
"threshold": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RunbookStep": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Individual runbook step",
|
||||||
|
"required": ["step_number", "action"],
|
||||||
|
"properties": {
|
||||||
|
"step_number": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What to do"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Detailed explanation"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "CLI command to execute"
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/CommandSpec"
|
||||||
|
},
|
||||||
|
"description": "Multiple commands if needed"
|
||||||
|
},
|
||||||
|
"expected_output": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What success looks like"
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Max time for this step (e.g., 5m)"
|
||||||
|
},
|
||||||
|
"decision_point": {
|
||||||
|
"$ref": "#/definitions/DecisionPoint",
|
||||||
|
"description": "If step requires a decision"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How to verify step completed successfully"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Additional context or warnings"
|
||||||
|
},
|
||||||
|
"skip_conditions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Conditions under which to skip this step"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CommandSpec": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Command specification",
|
||||||
|
"required": ["command"],
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"requires_sudo": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DecisionPoint": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Decision branch in runbook",
|
||||||
|
"required": ["question", "options"],
|
||||||
|
"properties": {
|
||||||
|
"question": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"condition": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"next_step": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["condition"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EscalationProcedure": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Escalation procedure",
|
||||||
|
"properties": {
|
||||||
|
"levels": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/EscalationLevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auto_escalate_after": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Time after which to auto-escalate (e.g., 30m)"
|
||||||
|
},
|
||||||
|
"escalation_criteria": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Conditions that trigger escalation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"EscalationLevel": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Single escalation level",
|
||||||
|
"required": ["level", "contacts"],
|
||||||
|
"properties": {
|
||||||
|
"level": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contacts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Contact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response_time_sla": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Expected response time (e.g., 15m)"
|
||||||
|
},
|
||||||
|
"notification_channels": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["pagerduty", "slack", "email", "phone", "sms", "teams"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Contact": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Contact information",
|
||||||
|
"required": ["name", "role"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"slack_handle": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"pagerduty_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CommunicationPlan": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Communication during incident",
|
||||||
|
"properties": {
|
||||||
|
"status_page": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "Public status page URL"
|
||||||
|
},
|
||||||
|
"internal_channel": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Internal communication channel (e.g., #incident-response)"
|
||||||
|
},
|
||||||
|
"stakeholder_updates": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"frequency": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Update frequency (e.g., every 30m)"
|
||||||
|
},
|
||||||
|
"recipients": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Status update template"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customer_notification": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"approval_required": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PostIncidentChecklist": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Post-incident activities",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"due": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Due timeframe (e.g., within 24h, within 1 week)"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["task"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postmortem_required": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"postmortem_due": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Timeframe for postmortem (e.g., 5 business days)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IncidentChecklist": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Pre-flight checklist for incident response",
|
||||||
|
"required": ["checklist_id", "name", "items"],
|
||||||
|
"properties": {
|
||||||
|
"checklist_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"item_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["access", "tools", "documentation", "communication", "monitoring"]
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["description"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RunbookCatalog": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Catalog of all runbooks",
|
||||||
|
"required": ["catalog_id", "version", "runbooks"],
|
||||||
|
"properties": {
|
||||||
|
"catalog_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"runbooks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Runbook"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checklists": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/IncidentChecklist"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"global_contacts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Contact"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"catalog": {
|
||||||
|
"$ref": "#/definitions/RunbookCatalog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"catalog": {
|
||||||
|
"catalog_id": "stellaops-runbooks",
|
||||||
|
"version": "2025.10.0",
|
||||||
|
"updated_at": "2025-12-06T10:00:00Z",
|
||||||
|
"runbooks": [
|
||||||
|
{
|
||||||
|
"runbook_id": "RB-VULN-001",
|
||||||
|
"title": "Critical Vulnerability Spike Response",
|
||||||
|
"description": "Response procedure when critical vulnerabilities spike significantly",
|
||||||
|
"severity": "critical",
|
||||||
|
"category": "vulnerability",
|
||||||
|
"trigger_conditions": [
|
||||||
|
{
|
||||||
|
"condition_type": "alert",
|
||||||
|
"description": "Critical vulnerability count increased by >10 in 1 hour",
|
||||||
|
"alert_name": "CriticalVulnerabilitySpike"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prerequisites": [
|
||||||
|
"Access to StellaOps CLI (stella)",
|
||||||
|
"Read access to Findings Ledger",
|
||||||
|
"Access to #security-incidents Slack channel"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"step_number": 1,
|
||||||
|
"action": "Acknowledge the alert",
|
||||||
|
"description": "Acknowledge in PagerDuty/alerting system to stop escalation",
|
||||||
|
"timeout": "5m"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 2,
|
||||||
|
"action": "Identify scope of new vulnerabilities",
|
||||||
|
"command": "stella findings list --severity critical --since 1h --format table",
|
||||||
|
"expected_output": "List of new critical findings with CVE IDs and affected assets",
|
||||||
|
"verification": "Output shows findings with timestamps within last hour"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 3,
|
||||||
|
"action": "Determine if spike is from new scans or advisory updates",
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "stella scan jobs --status completed --since 1h",
|
||||||
|
"description": "Check for recent scan completions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "stella advisory updates --since 1h",
|
||||||
|
"description": "Check for recent advisory updates"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decision_point": {
|
||||||
|
"question": "What caused the spike?",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"condition": "New scans completed",
|
||||||
|
"next_step": 4,
|
||||||
|
"action": "Review scan results"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"condition": "Advisory update",
|
||||||
|
"next_step": 5,
|
||||||
|
"action": "Review advisory impact"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"condition": "Unknown/Both",
|
||||||
|
"next_step": 4,
|
||||||
|
"action": "Continue with full investigation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 4,
|
||||||
|
"action": "Review affected assets and determine business impact",
|
||||||
|
"command": "stella findings group-by asset --severity critical --since 1h",
|
||||||
|
"description": "Group findings by asset to understand impact scope"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 5,
|
||||||
|
"action": "Check VEX applicability",
|
||||||
|
"command": "stella vex check --vuln-ids $(stella findings list --severity critical --since 1h --format ids)",
|
||||||
|
"description": "Check if any vulnerabilities have VEX statements that reduce severity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 6,
|
||||||
|
"action": "Update stakeholders",
|
||||||
|
"description": "Post status update to #security-incidents with findings summary",
|
||||||
|
"notes": "Use template: 'VULN SPIKE: [count] new critical vulns affecting [assets]. Investigation in progress.'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step_number": 7,
|
||||||
|
"action": "Create remediation tickets if needed",
|
||||||
|
"command": "stella findings export --severity critical --since 1h --format jira",
|
||||||
|
"skip_conditions": [
|
||||||
|
"All vulnerabilities covered by VEX not_affected",
|
||||||
|
"Vulnerabilities are duplicates from rescan"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"escalation": {
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"level": 1,
|
||||||
|
"name": "On-call Security Engineer",
|
||||||
|
"contacts": [
|
||||||
|
{
|
||||||
|
"name": "Security On-Call",
|
||||||
|
"role": "Security Engineer",
|
||||||
|
"slack_handle": "@security-oncall"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_time_sla": "15m",
|
||||||
|
"notification_channels": ["pagerduty", "slack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": 2,
|
||||||
|
"name": "Security Team Lead",
|
||||||
|
"contacts": [
|
||||||
|
{
|
||||||
|
"name": "Security Lead",
|
||||||
|
"role": "Security Team Lead",
|
||||||
|
"slack_handle": "@security-lead"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"response_time_sla": "30m",
|
||||||
|
"notification_channels": ["pagerduty", "slack", "phone"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auto_escalate_after": "30m",
|
||||||
|
"escalation_criteria": [
|
||||||
|
"No acknowledgment within 15 minutes",
|
||||||
|
"More than 50 critical vulnerabilities",
|
||||||
|
"Production systems affected"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"communication": {
|
||||||
|
"internal_channel": "#security-incidents",
|
||||||
|
"stakeholder_updates": {
|
||||||
|
"frequency": "every 30m during active incident",
|
||||||
|
"recipients": ["security-team", "engineering-leads"],
|
||||||
|
"template": "VULN INCIDENT UPDATE: Status: [status]. Critical count: [count]. Affected systems: [systems]. Next update: [time]."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post_incident": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"task": "Document incident timeline",
|
||||||
|
"owner": "Incident Commander",
|
||||||
|
"due": "within 24h",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task": "Update vulnerability scanning schedules if needed",
|
||||||
|
"owner": "Security Team",
|
||||||
|
"due": "within 1 week",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task": "Review and update this runbook",
|
||||||
|
"owner": "Runbook Owner",
|
||||||
|
"due": "within 1 week",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"postmortem_required": true,
|
||||||
|
"postmortem_due": "5 business days"
|
||||||
|
},
|
||||||
|
"estimated_duration": "1h",
|
||||||
|
"last_updated": "2025-12-06T10:00:00Z",
|
||||||
|
"owner": "Security Operations Team",
|
||||||
|
"tags": ["vulnerability", "security", "critical"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checklists": [
|
||||||
|
{
|
||||||
|
"checklist_id": "incident-preflight",
|
||||||
|
"name": "Incident Response Pre-flight Checklist",
|
||||||
|
"description": "Verify access and tools before incident response",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_id": "cli-access",
|
||||||
|
"description": "StellaOps CLI is installed and authenticated",
|
||||||
|
"category": "tools",
|
||||||
|
"verification": "Run 'stella whoami' successfully"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "slack-access",
|
||||||
|
"description": "Access to #security-incidents channel",
|
||||||
|
"category": "communication",
|
||||||
|
"verification": "Can post messages to channel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "pagerduty-access",
|
||||||
|
"description": "Can acknowledge alerts in PagerDuty",
|
||||||
|
"category": "tools",
|
||||||
|
"verification": "PagerDuty mobile app logged in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": "runbooks-access",
|
||||||
|
"description": "Can access runbook documentation",
|
||||||
|
"category": "documentation",
|
||||||
|
"verification": "docs.stella-ops.org/runbooks accessible"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"global_contacts": [
|
||||||
|
{
|
||||||
|
"name": "Security Operations",
|
||||||
|
"role": "Primary Response Team",
|
||||||
|
"email": "security-ops@example.com",
|
||||||
|
"slack_handle": "@security-ops"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
606
docs/schemas/risk-api.schema.json
Normal file
606
docs/schemas/risk-api.schema.json
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/risk-api.schema.json",
|
||||||
|
"title": "StellaOps Risk API Schema",
|
||||||
|
"description": "Schema for Risk API endpoints, scoring models, and factor weights. Unblocks DOCS-RISK-67-002 through 68-002 (5+ tasks).",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"RiskScore": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Computed risk score",
|
||||||
|
"required": ["score", "rating", "computed_at"],
|
||||||
|
"properties": {
|
||||||
|
"score": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100,
|
||||||
|
"description": "Numeric risk score (0-100)"
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["critical", "high", "medium", "low", "info", "none"],
|
||||||
|
"description": "Risk rating category"
|
||||||
|
},
|
||||||
|
"computed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Confidence level in the score (0-1)"
|
||||||
|
},
|
||||||
|
"factors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RiskFactor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trend": {
|
||||||
|
"$ref": "#/definitions/RiskTrend"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskFactor": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Individual risk factor contribution",
|
||||||
|
"required": ["factor_id", "name", "value", "weight", "contribution"],
|
||||||
|
"properties": {
|
||||||
|
"factor_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["vulnerability", "exposure", "asset", "context", "temporal", "environmental"]
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Raw factor value"
|
||||||
|
},
|
||||||
|
"normalized_value": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Normalized value (0-1)"
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "Factor weight in scoring model"
|
||||||
|
},
|
||||||
|
"contribution": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Contribution to final score"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Data source for this factor"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Evidence supporting this factor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskTrend": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk score trend over time",
|
||||||
|
"properties": {
|
||||||
|
"direction": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["improving", "stable", "degrading"]
|
||||||
|
},
|
||||||
|
"change_percent": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Percent change from previous period"
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["24h", "7d", "30d"]
|
||||||
|
},
|
||||||
|
"previous_score": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskProfile": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk profile configuration",
|
||||||
|
"required": ["profile_id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scoring_model": {
|
||||||
|
"$ref": "#/definitions/ScoringModel"
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"$ref": "#/definitions/RiskThresholds"
|
||||||
|
},
|
||||||
|
"factor_weights": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabled_factors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ScoringModel": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk scoring model configuration",
|
||||||
|
"required": ["model_id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"model_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["weighted_sum", "geometric_mean", "max_severity", "custom"],
|
||||||
|
"description": "Scoring algorithm type"
|
||||||
|
},
|
||||||
|
"base_score_source": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["cvss_v3", "cvss_v4", "epss", "custom"],
|
||||||
|
"description": "Primary score source"
|
||||||
|
},
|
||||||
|
"modifiers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/ScoreModifier"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ScoreModifier": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Score modifier rule",
|
||||||
|
"required": ["modifier_id", "condition", "adjustment"],
|
||||||
|
"properties": {
|
||||||
|
"modifier_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Condition expression (e.g., 'kev == true')"
|
||||||
|
},
|
||||||
|
"adjustment": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Score adjustment (can be positive or negative)"
|
||||||
|
},
|
||||||
|
"adjustment_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["absolute", "percent", "multiply"],
|
||||||
|
"default": "absolute"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Order of modifier application"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskThresholds": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk rating thresholds",
|
||||||
|
"properties": {
|
||||||
|
"critical": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 90,
|
||||||
|
"description": "Score >= this is Critical"
|
||||||
|
},
|
||||||
|
"high": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 70,
|
||||||
|
"description": "Score >= this is High"
|
||||||
|
},
|
||||||
|
"medium": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 40,
|
||||||
|
"description": "Score >= this is Medium"
|
||||||
|
},
|
||||||
|
"low": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 10,
|
||||||
|
"description": "Score >= this is Low"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0,
|
||||||
|
"description": "Score >= this is Info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskAssessmentRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Request to compute risk for an entity",
|
||||||
|
"required": ["entity_type", "entity_id"],
|
||||||
|
"properties": {
|
||||||
|
"entity_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["vulnerability", "asset", "component", "project", "tenant"]
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Risk profile to use (uses default if not specified)"
|
||||||
|
},
|
||||||
|
"include_factors": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Include factor breakdown in response"
|
||||||
|
},
|
||||||
|
"include_trend": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Include trend analysis"
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "Additional context for scoring"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskAssessmentResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk assessment result",
|
||||||
|
"required": ["assessment_id", "entity_type", "entity_id", "score"],
|
||||||
|
"properties": {
|
||||||
|
"assessment_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"entity_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"profile_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"score": {
|
||||||
|
"$ref": "#/definitions/RiskScore"
|
||||||
|
},
|
||||||
|
"explainability": {
|
||||||
|
"$ref": "#/definitions/RiskExplainability"
|
||||||
|
},
|
||||||
|
"recommendations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RiskRecommendation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskExplainability": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Human-readable explanation of risk score",
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "One-line summary"
|
||||||
|
},
|
||||||
|
"narrative": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Detailed explanation"
|
||||||
|
},
|
||||||
|
"top_factors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"factor": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"impact": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["major", "moderate", "minor"]
|
||||||
|
},
|
||||||
|
"explanation": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Top contributing factors"
|
||||||
|
},
|
||||||
|
"comparisons": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"org_percentile": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Percentile within organization"
|
||||||
|
},
|
||||||
|
"industry_percentile": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Percentile within industry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskRecommendation": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk mitigation recommendation",
|
||||||
|
"required": ["recommendation_id", "action"],
|
||||||
|
"properties": {
|
||||||
|
"recommendation_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Recommended action"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["critical", "high", "medium", "low"]
|
||||||
|
},
|
||||||
|
"impact_estimate": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Estimated score reduction if implemented"
|
||||||
|
},
|
||||||
|
"effort": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["minimal", "moderate", "significant"]
|
||||||
|
},
|
||||||
|
"related_factors": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskAggregation": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Aggregated risk across multiple entities",
|
||||||
|
"required": ["aggregation_id", "entity_type", "count", "scores"],
|
||||||
|
"properties": {
|
||||||
|
"aggregation_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"entity_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Aggregation scope (e.g., project, tenant)"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of entities aggregated"
|
||||||
|
},
|
||||||
|
"scores": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"average": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"median": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"min": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"p95": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"distribution": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"critical": { "type": "integer" },
|
||||||
|
"high": { "type": "integer" },
|
||||||
|
"medium": { "type": "integer" },
|
||||||
|
"low": { "type": "integer" },
|
||||||
|
"info": { "type": "integer" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trend": {
|
||||||
|
"$ref": "#/definitions/RiskTrend"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RiskApiEndpoint": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Risk API endpoint definition",
|
||||||
|
"required": ["path", "method", "operation_id"],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["GET", "POST", "PUT", "DELETE"]
|
||||||
|
},
|
||||||
|
"operation_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"request_schema": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to request schema"
|
||||||
|
},
|
||||||
|
"response_schema": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to response schema"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Required OAuth scopes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"endpoints": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RiskApiEndpoint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RiskProfile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"path": "/api/v1/risk/assess",
|
||||||
|
"method": "POST",
|
||||||
|
"operation_id": "assessRisk",
|
||||||
|
"summary": "Compute risk score for an entity",
|
||||||
|
"request_schema": "RiskAssessmentRequest",
|
||||||
|
"response_schema": "RiskAssessmentResponse",
|
||||||
|
"scopes": ["risk:read"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/v1/risk/profiles",
|
||||||
|
"method": "GET",
|
||||||
|
"operation_id": "listRiskProfiles",
|
||||||
|
"summary": "List available risk profiles",
|
||||||
|
"response_schema": "RiskProfile[]",
|
||||||
|
"scopes": ["risk:read"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/v1/risk/aggregate",
|
||||||
|
"method": "POST",
|
||||||
|
"operation_id": "aggregateRisk",
|
||||||
|
"summary": "Compute aggregated risk across entities",
|
||||||
|
"response_schema": "RiskAggregation",
|
||||||
|
"scopes": ["risk:read"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/v1/risk/profiles/{profile_id}",
|
||||||
|
"method": "PUT",
|
||||||
|
"operation_id": "updateRiskProfile",
|
||||||
|
"summary": "Update a risk profile",
|
||||||
|
"request_schema": "RiskProfile",
|
||||||
|
"scopes": ["risk:write", "admin"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/api/v1/risk/explain/{assessment_id}",
|
||||||
|
"method": "GET",
|
||||||
|
"operation_id": "explainRisk",
|
||||||
|
"summary": "Get detailed explanation for a risk assessment",
|
||||||
|
"response_schema": "RiskExplainability",
|
||||||
|
"scopes": ["risk:read"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_id": "default",
|
||||||
|
"name": "Default Risk Profile",
|
||||||
|
"description": "Standard risk scoring with balanced factor weights",
|
||||||
|
"scoring_model": {
|
||||||
|
"model_id": "weighted-sum-v1",
|
||||||
|
"name": "Weighted Sum Model",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "weighted_sum",
|
||||||
|
"base_score_source": "cvss_v3",
|
||||||
|
"modifiers": [
|
||||||
|
{
|
||||||
|
"modifier_id": "kev-boost",
|
||||||
|
"name": "KEV Exploit Known",
|
||||||
|
"condition": "kev == true",
|
||||||
|
"adjustment": 15,
|
||||||
|
"adjustment_type": "absolute",
|
||||||
|
"priority": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modifier_id": "reachability-reduction",
|
||||||
|
"name": "Not Reachable",
|
||||||
|
"condition": "reachability == 'not_reachable'",
|
||||||
|
"adjustment": -30,
|
||||||
|
"adjustment_type": "absolute",
|
||||||
|
"priority": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modifier_id": "vex-not-affected",
|
||||||
|
"name": "VEX Not Affected",
|
||||||
|
"condition": "vex_status == 'not_affected'",
|
||||||
|
"adjustment": -50,
|
||||||
|
"adjustment_type": "absolute",
|
||||||
|
"priority": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"critical": 90,
|
||||||
|
"high": 70,
|
||||||
|
"medium": 40,
|
||||||
|
"low": 10,
|
||||||
|
"info": 0
|
||||||
|
},
|
||||||
|
"factor_weights": {
|
||||||
|
"cvss_base": 0.35,
|
||||||
|
"exploitability": 0.25,
|
||||||
|
"reachability": 0.20,
|
||||||
|
"asset_criticality": 0.10,
|
||||||
|
"exposure": 0.10
|
||||||
|
},
|
||||||
|
"enabled_factors": [
|
||||||
|
"cvss_base",
|
||||||
|
"exploitability",
|
||||||
|
"kev",
|
||||||
|
"epss",
|
||||||
|
"reachability",
|
||||||
|
"vex_status",
|
||||||
|
"asset_criticality",
|
||||||
|
"exposure",
|
||||||
|
"age"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
498
docs/schemas/sdk-generator-samples.schema.json
Normal file
498
docs/schemas/sdk-generator-samples.schema.json
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/sdk-generator-samples.schema.json",
|
||||||
|
"title": "StellaOps SDK Generator Samples Schema",
|
||||||
|
"description": "Schema for SDK generator output samples, snippet packs, and language bindings. Unblocks DEVPORT-63-002, DOCS-SDK-62-001 (2+ tasks).",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"SdkLanguage": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["typescript", "python", "go", "java", "csharp", "ruby", "php", "rust"],
|
||||||
|
"description": "Supported SDK target languages"
|
||||||
|
},
|
||||||
|
"SdkSample": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Individual SDK code sample",
|
||||||
|
"required": ["sample_id", "language", "operation", "code"],
|
||||||
|
"properties": {
|
||||||
|
"sample_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]*$",
|
||||||
|
"description": "Unique sample identifier"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"$ref": "#/definitions/SdkLanguage"
|
||||||
|
},
|
||||||
|
"operation": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "API operation this sample demonstrates"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable title"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Explanation of what the sample demonstrates"
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The actual code sample"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Required imports/dependencies"
|
||||||
|
},
|
||||||
|
"prerequisites": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Setup steps before running"
|
||||||
|
},
|
||||||
|
"expected_output": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Expected console output or result"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Categorization tags (auth, scanning, vex, etc.)"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$",
|
||||||
|
"description": "SHA-256 hash of code content for verification"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SnippetPack": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Collection of SDK samples for a specific language",
|
||||||
|
"required": ["pack_id", "language", "version", "samples"],
|
||||||
|
"properties": {
|
||||||
|
"pack_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique pack identifier"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"$ref": "#/definitions/SdkLanguage"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
|
||||||
|
"description": "Pack version (semver)"
|
||||||
|
},
|
||||||
|
"sdk_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Target SDK version these samples work with"
|
||||||
|
},
|
||||||
|
"api_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "API version (e.g., v1, v2)"
|
||||||
|
},
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/SdkSample"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aggregate_digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$",
|
||||||
|
"description": "Hash of all sample digests combined"
|
||||||
|
},
|
||||||
|
"package_info": {
|
||||||
|
"$ref": "#/definitions/PackageInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PackageInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Language-specific package information",
|
||||||
|
"properties": {
|
||||||
|
"package_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package name (e.g., @stellaops/sdk, stellaops-sdk)"
|
||||||
|
},
|
||||||
|
"registry_url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "Package registry URL"
|
||||||
|
},
|
||||||
|
"install_command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Command to install the SDK"
|
||||||
|
},
|
||||||
|
"min_runtime_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Minimum runtime version required"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Dependency"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Dependency": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "SDK dependency",
|
||||||
|
"required": ["name", "version"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SdkGeneratorConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "SDK generator configuration",
|
||||||
|
"required": ["generator_id", "openapi_source"],
|
||||||
|
"properties": {
|
||||||
|
"generator_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"openapi_source": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"description": "OpenAPI spec URL or path"
|
||||||
|
},
|
||||||
|
"target_languages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/SdkLanguage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output_dir": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Custom templates per language"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"$ref": "#/definitions/GeneratorOptions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GeneratorOptions": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "SDK generation options",
|
||||||
|
"properties": {
|
||||||
|
"include_samples": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Generate usage samples"
|
||||||
|
},
|
||||||
|
"include_tests": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Generate test stubs"
|
||||||
|
},
|
||||||
|
"include_docs": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Generate API documentation"
|
||||||
|
},
|
||||||
|
"async_style": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["async_await", "promises", "callbacks", "sync"],
|
||||||
|
"default": "async_await"
|
||||||
|
},
|
||||||
|
"error_handling": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["exceptions", "result_types", "error_codes"],
|
||||||
|
"default": "exceptions"
|
||||||
|
},
|
||||||
|
"naming_convention": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["camelCase", "snake_case", "PascalCase"],
|
||||||
|
"description": "Override default for language"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SdkGeneratorOutput": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Output from SDK generation",
|
||||||
|
"required": ["output_id", "generator_id", "generated_at", "files"],
|
||||||
|
"properties": {
|
||||||
|
"output_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"generator_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"generated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"$ref": "#/definitions/SdkLanguage"
|
||||||
|
},
|
||||||
|
"sdk_version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"api_version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/GeneratedFile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"$ref": "#/definitions/GenerationStats"
|
||||||
|
},
|
||||||
|
"manifest_digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GeneratedFile": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Generated SDK file",
|
||||||
|
"required": ["path", "digest"],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["source", "test", "sample", "docs", "config"]
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"GenerationStats": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "SDK generation statistics",
|
||||||
|
"properties": {
|
||||||
|
"total_files": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"source_files": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"test_files": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"sample_files": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total_lines": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"endpoints_covered": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"models_generated": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SampleCategory": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Category grouping for samples",
|
||||||
|
"required": ["category_id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"category_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"samples": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Sample IDs in this category"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Display order"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SdkDocumentation": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "SDK documentation structure",
|
||||||
|
"properties": {
|
||||||
|
"overview": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SDK overview markdown"
|
||||||
|
},
|
||||||
|
"quickstart": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Quickstart guide markdown"
|
||||||
|
},
|
||||||
|
"authentication": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Authentication guide"
|
||||||
|
},
|
||||||
|
"error_handling": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Error handling guide"
|
||||||
|
},
|
||||||
|
"changelog": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SDK changelog"
|
||||||
|
},
|
||||||
|
"api_reference_url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"snippet_packs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/SnippetPack"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/SampleCategory"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"generator_outputs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/SdkGeneratorOutput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"snippet_packs": [
|
||||||
|
{
|
||||||
|
"pack_id": "stellaops-typescript-samples",
|
||||||
|
"language": "typescript",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"sdk_version": "2025.10.0",
|
||||||
|
"api_version": "v1",
|
||||||
|
"generated_at": "2025-12-06T10:00:00Z",
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"sample_id": "auth-token-exchange",
|
||||||
|
"language": "typescript",
|
||||||
|
"operation": "POST /oauth/token",
|
||||||
|
"title": "Token Exchange",
|
||||||
|
"description": "Exchange client credentials for an access token",
|
||||||
|
"code": "import { StellaOpsClient } from '@stellaops/sdk';\n\nconst client = new StellaOpsClient({\n clientId: process.env.STELLAOPS_CLIENT_ID,\n clientSecret: process.env.STELLAOPS_CLIENT_SECRET,\n});\n\nconst token = await client.auth.getToken();\nconsole.log('Token expires at:', token.expiresAt);",
|
||||||
|
"imports": ["@stellaops/sdk"],
|
||||||
|
"prerequisites": [
|
||||||
|
"Set STELLAOPS_CLIENT_ID environment variable",
|
||||||
|
"Set STELLAOPS_CLIENT_SECRET environment variable"
|
||||||
|
],
|
||||||
|
"expected_output": "Token expires at: 2025-12-06T11:00:00Z",
|
||||||
|
"tags": ["authentication", "oauth"],
|
||||||
|
"digest": "sha256:abc123def456789012345678901234567890123456789012345678901234abcd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sample_id": "scan-container-image",
|
||||||
|
"language": "typescript",
|
||||||
|
"operation": "POST /api/v1/scanner/scan",
|
||||||
|
"title": "Scan Container Image",
|
||||||
|
"description": "Scan a container image for vulnerabilities",
|
||||||
|
"code": "import { StellaOpsClient } from '@stellaops/sdk';\n\nconst client = new StellaOpsClient();\nconst result = await client.scanner.scanImage({\n image: 'nginx:1.25',\n generateSbom: true,\n format: 'cyclonedx',\n});\n\nconsole.log(`Found ${result.vulnerabilities.length} vulnerabilities`);",
|
||||||
|
"imports": ["@stellaops/sdk"],
|
||||||
|
"tags": ["scanning", "sbom", "vulnerabilities"],
|
||||||
|
"digest": "sha256:def456abc789012345678901234567890123456789012345678901234defabc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"aggregate_digest": "sha256:agg123def456789012345678901234567890123456789012345678901234agg",
|
||||||
|
"package_info": {
|
||||||
|
"package_name": "@stellaops/sdk",
|
||||||
|
"registry_url": "https://registry.npmjs.org",
|
||||||
|
"install_command": "npm install @stellaops/sdk",
|
||||||
|
"min_runtime_version": "18.0.0",
|
||||||
|
"dependencies": [
|
||||||
|
{ "name": "axios", "version": "^1.6.0" },
|
||||||
|
{ "name": "jose", "version": "^5.0.0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pack_id": "stellaops-python-samples",
|
||||||
|
"language": "python",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"sdk_version": "2025.10.0",
|
||||||
|
"api_version": "v1",
|
||||||
|
"generated_at": "2025-12-06T10:00:00Z",
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"sample_id": "auth-token-exchange-py",
|
||||||
|
"language": "python",
|
||||||
|
"operation": "POST /oauth/token",
|
||||||
|
"title": "Token Exchange",
|
||||||
|
"description": "Exchange client credentials for an access token",
|
||||||
|
"code": "from stellaops import StellaOpsClient\nimport os\n\nclient = StellaOpsClient(\n client_id=os.environ['STELLAOPS_CLIENT_ID'],\n client_secret=os.environ['STELLAOPS_CLIENT_SECRET'],\n)\n\ntoken = client.auth.get_token()\nprint(f'Token expires at: {token.expires_at}')",
|
||||||
|
"imports": ["stellaops"],
|
||||||
|
"tags": ["authentication", "oauth"],
|
||||||
|
"digest": "sha256:py123def456789012345678901234567890123456789012345678901234pyth"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"package_info": {
|
||||||
|
"package_name": "stellaops-sdk",
|
||||||
|
"registry_url": "https://pypi.org/project/stellaops-sdk/",
|
||||||
|
"install_command": "pip install stellaops-sdk",
|
||||||
|
"min_runtime_version": "3.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"category_id": "authentication",
|
||||||
|
"name": "Authentication",
|
||||||
|
"description": "Token exchange and authentication samples",
|
||||||
|
"samples": ["auth-token-exchange", "auth-token-exchange-py"],
|
||||||
|
"order": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category_id": "scanning",
|
||||||
|
"name": "Container Scanning",
|
||||||
|
"description": "Container image scanning and SBOM generation",
|
||||||
|
"samples": ["scan-container-image"],
|
||||||
|
"order": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
641
docs/schemas/security-scopes-matrix.schema.json
Normal file
641
docs/schemas/security-scopes-matrix.schema.json
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/security-scopes-matrix.schema.json",
|
||||||
|
"title": "StellaOps Security Scopes Matrix Schema",
|
||||||
|
"description": "Schema for security scopes, roles, permissions, and privacy controls. Unblocks DOCS-SEC-62-001, DOCS-SEC-OBS-50-001 (2+ tasks).",
|
||||||
|
"type": "object",
|
||||||
|
"definitions": {
|
||||||
|
"Scope": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "OAuth2/OIDC scope definition",
|
||||||
|
"required": ["scope_id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"scope_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9_:]+$",
|
||||||
|
"description": "Scope identifier (e.g., findings:read, admin:write)"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["read", "write", "admin", "system"],
|
||||||
|
"description": "Scope category"
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Resource this scope applies to"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["create", "read", "update", "delete", "list", "execute", "export", "import"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requires_mfa": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Whether MFA is required for this scope"
|
||||||
|
},
|
||||||
|
"sensitive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Whether this scope accesses sensitive data"
|
||||||
|
},
|
||||||
|
"audit_level": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["none", "basic", "detailed", "full"],
|
||||||
|
"default": "basic"
|
||||||
|
},
|
||||||
|
"parent_scope": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Parent scope that implies this scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Role": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Role definition with assigned scopes",
|
||||||
|
"required": ["role_id", "name", "scopes"],
|
||||||
|
"properties": {
|
||||||
|
"role_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["system", "tenant", "project", "custom"],
|
||||||
|
"description": "Role type"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Scopes assigned to this role"
|
||||||
|
},
|
||||||
|
"inherits_from": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Roles this role inherits from"
|
||||||
|
},
|
||||||
|
"restrictions": {
|
||||||
|
"$ref": "#/definitions/RoleRestrictions"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RoleRestrictions": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Restrictions on role usage",
|
||||||
|
"properties": {
|
||||||
|
"max_sessions": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum concurrent sessions"
|
||||||
|
},
|
||||||
|
"ip_allowlist": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"time_restrictions": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allowed_hours": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"start": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-2][0-9]:[0-5][0-9]$"
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-2][0-9]:[0-5][0-9]$"
|
||||||
|
},
|
||||||
|
"timezone": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowed_days": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require_approval": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Require approval for role activation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Permission": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Fine-grained permission",
|
||||||
|
"required": ["permission_id", "resource", "action"],
|
||||||
|
"properties": {
|
||||||
|
"permission_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["create", "read", "update", "delete", "list", "execute", "export", "import"]
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionCondition"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["allow", "deny"],
|
||||||
|
"default": "allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PermissionCondition": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Condition for permission evaluation",
|
||||||
|
"required": ["type", "value"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["attribute", "context", "time", "resource_owner", "tenant"]
|
||||||
|
},
|
||||||
|
"attribute": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["eq", "neq", "in", "not_in", "contains", "gt", "lt", "gte", "lte"]
|
||||||
|
},
|
||||||
|
"value": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"TenancyHeader": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Multi-tenancy header specification",
|
||||||
|
"required": ["header_name", "required"],
|
||||||
|
"properties": {
|
||||||
|
"header_name": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "X-Tenant-ID"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["uuid", "slug", "custom"]
|
||||||
|
},
|
||||||
|
"pattern": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"max_length": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default_value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Default tenant if header not provided"
|
||||||
|
},
|
||||||
|
"extract_from_token": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Allow extraction from JWT token"
|
||||||
|
},
|
||||||
|
"token_claim": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "tenant_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PrivacyControl": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Privacy control configuration",
|
||||||
|
"required": ["control_id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"control_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data_classification": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["public", "internal", "confidential", "restricted", "pii", "phi"]
|
||||||
|
},
|
||||||
|
"redaction_policy": {
|
||||||
|
"$ref": "#/definitions/RedactionPolicy"
|
||||||
|
},
|
||||||
|
"retention_policy": {
|
||||||
|
"$ref": "#/definitions/RetentionPolicy"
|
||||||
|
},
|
||||||
|
"consent_required": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"audit_access": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RedactionPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Data redaction policy",
|
||||||
|
"properties": {
|
||||||
|
"policy_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/RedactionRule"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default_action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["pass", "mask", "hash", "remove"],
|
||||||
|
"default": "pass"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RedactionRule": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Individual redaction rule",
|
||||||
|
"required": ["field_pattern", "action"],
|
||||||
|
"properties": {
|
||||||
|
"rule_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"field_pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON path or field name pattern"
|
||||||
|
},
|
||||||
|
"data_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["email", "phone", "ssn", "ip_address", "credit_card", "name", "address", "custom"]
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["mask", "hash", "remove", "tokenize", "truncate"]
|
||||||
|
},
|
||||||
|
"mask_char": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "*"
|
||||||
|
},
|
||||||
|
"preserve_chars": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of chars to preserve (e.g., last 4 of phone)"
|
||||||
|
},
|
||||||
|
"hash_algorithm": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["sha256", "sha512", "hmac-sha256"]
|
||||||
|
},
|
||||||
|
"conditions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionCondition"
|
||||||
|
},
|
||||||
|
"description": "Conditions when to apply redaction"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RetentionPolicy": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Data retention policy",
|
||||||
|
"properties": {
|
||||||
|
"policy_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default_retention_days": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retention_days": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"action_on_expiry": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["delete", "archive", "anonymize"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"legal_hold_enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DebugOptIn": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Debug/diagnostic opt-in configuration",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"opt_in_required": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"scopes_required": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Scopes required to access debug data"
|
||||||
|
},
|
||||||
|
"data_collected": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retention_hours": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"redaction_applied": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ScopeMatrix": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Complete scope matrix",
|
||||||
|
"required": ["version", "scopes"],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"scopes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Scope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Role"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tenancy_config": {
|
||||||
|
"$ref": "#/definitions/TenancyHeader"
|
||||||
|
},
|
||||||
|
"privacy_controls": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PrivacyControl"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"debug_config": {
|
||||||
|
"$ref": "#/definitions/DebugOptIn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"matrix": {
|
||||||
|
"$ref": "#/definitions/ScopeMatrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"matrix": {
|
||||||
|
"version": "2025.10.0",
|
||||||
|
"updated_at": "2025-12-06T10:00:00Z",
|
||||||
|
"scopes": [
|
||||||
|
{
|
||||||
|
"scope_id": "findings:read",
|
||||||
|
"name": "Read Findings",
|
||||||
|
"description": "Read vulnerability findings",
|
||||||
|
"category": "read",
|
||||||
|
"resource": "findings",
|
||||||
|
"actions": ["read", "list"],
|
||||||
|
"audit_level": "basic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope_id": "findings:write",
|
||||||
|
"name": "Write Findings",
|
||||||
|
"description": "Create and update findings",
|
||||||
|
"category": "write",
|
||||||
|
"resource": "findings",
|
||||||
|
"actions": ["create", "update"],
|
||||||
|
"audit_level": "detailed",
|
||||||
|
"parent_scope": "findings:read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope_id": "findings:delete",
|
||||||
|
"name": "Delete Findings",
|
||||||
|
"description": "Delete findings (requires approval)",
|
||||||
|
"category": "admin",
|
||||||
|
"resource": "findings",
|
||||||
|
"actions": ["delete"],
|
||||||
|
"requires_mfa": true,
|
||||||
|
"audit_level": "full",
|
||||||
|
"parent_scope": "findings:write"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope_id": "scanner:execute",
|
||||||
|
"name": "Execute Scans",
|
||||||
|
"description": "Initiate container scans",
|
||||||
|
"category": "write",
|
||||||
|
"resource": "scanner",
|
||||||
|
"actions": ["execute"],
|
||||||
|
"audit_level": "detailed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope_id": "risk:read",
|
||||||
|
"name": "Read Risk Scores",
|
||||||
|
"description": "Access risk scoring data",
|
||||||
|
"category": "read",
|
||||||
|
"resource": "risk",
|
||||||
|
"actions": ["read", "list"],
|
||||||
|
"audit_level": "basic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope_id": "admin:*",
|
||||||
|
"name": "Full Admin Access",
|
||||||
|
"description": "Full administrative access",
|
||||||
|
"category": "admin",
|
||||||
|
"resource": "*",
|
||||||
|
"actions": ["create", "read", "update", "delete", "list", "execute"],
|
||||||
|
"requires_mfa": true,
|
||||||
|
"sensitive": true,
|
||||||
|
"audit_level": "full"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role_id": "viewer",
|
||||||
|
"name": "Viewer",
|
||||||
|
"description": "Read-only access to findings and risk data",
|
||||||
|
"type": "tenant",
|
||||||
|
"scopes": ["findings:read", "risk:read"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "analyst",
|
||||||
|
"name": "Security Analyst",
|
||||||
|
"description": "Can view and update findings, execute scans",
|
||||||
|
"type": "tenant",
|
||||||
|
"scopes": ["findings:read", "findings:write", "scanner:execute", "risk:read"],
|
||||||
|
"inherits_from": ["viewer"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "admin",
|
||||||
|
"name": "Tenant Admin",
|
||||||
|
"description": "Full tenant administrative access",
|
||||||
|
"type": "tenant",
|
||||||
|
"scopes": ["findings:read", "findings:write", "findings:delete", "scanner:execute", "risk:read", "risk:write"],
|
||||||
|
"inherits_from": ["analyst"],
|
||||||
|
"restrictions": {
|
||||||
|
"max_sessions": 3,
|
||||||
|
"require_approval": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": "super_admin",
|
||||||
|
"name": "Super Admin",
|
||||||
|
"description": "System-wide administrative access",
|
||||||
|
"type": "system",
|
||||||
|
"scopes": ["admin:*"],
|
||||||
|
"restrictions": {
|
||||||
|
"max_sessions": 1,
|
||||||
|
"require_approval": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tenancy_config": {
|
||||||
|
"header_name": "X-Tenant-ID",
|
||||||
|
"required": true,
|
||||||
|
"validation": {
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"extract_from_token": true,
|
||||||
|
"token_claim": "tenant_id"
|
||||||
|
},
|
||||||
|
"privacy_controls": [
|
||||||
|
{
|
||||||
|
"control_id": "pii-protection",
|
||||||
|
"name": "PII Protection",
|
||||||
|
"description": "Protection for personally identifiable information",
|
||||||
|
"data_classification": "pii",
|
||||||
|
"redaction_policy": {
|
||||||
|
"policy_id": "pii-redaction",
|
||||||
|
"name": "PII Redaction",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"rule_id": "email-mask",
|
||||||
|
"field_pattern": "$.**.email",
|
||||||
|
"data_type": "email",
|
||||||
|
"action": "mask",
|
||||||
|
"preserve_chars": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule_id": "ip-hash",
|
||||||
|
"field_pattern": "$.**.ip_address",
|
||||||
|
"data_type": "ip_address",
|
||||||
|
"action": "hash",
|
||||||
|
"hash_algorithm": "sha256"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_action": "pass"
|
||||||
|
},
|
||||||
|
"retention_policy": {
|
||||||
|
"policy_id": "pii-retention",
|
||||||
|
"name": "PII Retention",
|
||||||
|
"default_retention_days": 90,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"data_type": "audit_logs",
|
||||||
|
"retention_days": 365,
|
||||||
|
"action_on_expiry": "archive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"consent_required": true,
|
||||||
|
"audit_access": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"debug_config": {
|
||||||
|
"enabled": true,
|
||||||
|
"opt_in_required": true,
|
||||||
|
"scopes_required": ["admin:*"],
|
||||||
|
"data_collected": [
|
||||||
|
{
|
||||||
|
"data_type": "request_traces",
|
||||||
|
"description": "HTTP request/response traces for debugging",
|
||||||
|
"retention_hours": 24
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data_type": "performance_metrics",
|
||||||
|
"description": "Detailed performance timing",
|
||||||
|
"retention_hours": 72
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"redaction_applied": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal file
232
src/Policy/StellaOps.Policy.Registry/Storage/Entities.cs
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
using StellaOps.Policy.Registry.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Registry.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage entity for policy pack.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PolicyPackEntity
|
||||||
|
{
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
public required Guid PackId { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Version { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
public required PolicyPackStatus Status { get; init; }
|
||||||
|
public IReadOnlyList<PolicyRule>? Rules { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public required DateTimeOffset UpdatedAt { get; init; }
|
||||||
|
public DateTimeOffset? PublishedAt { get; init; }
|
||||||
|
public string? Digest { get; init; }
|
||||||
|
public string? CreatedBy { get; init; }
|
||||||
|
public string? UpdatedBy { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to API contract.
|
||||||
|
/// </summary>
|
||||||
|
public PolicyPack ToContract() => new()
|
||||||
|
{
|
||||||
|
PackId = PackId,
|
||||||
|
Name = Name,
|
||||||
|
Version = Version,
|
||||||
|
Description = Description,
|
||||||
|
Status = Status,
|
||||||
|
Rules = Rules,
|
||||||
|
Metadata = Metadata,
|
||||||
|
CreatedAt = CreatedAt,
|
||||||
|
UpdatedAt = UpdatedAt,
|
||||||
|
PublishedAt = PublishedAt,
|
||||||
|
Digest = Digest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage entity for verification policy.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VerificationPolicyEntity
|
||||||
|
{
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
public required string PolicyId { get; init; }
|
||||||
|
public required string Version { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
public required string TenantScope { get; init; }
|
||||||
|
public required IReadOnlyList<string> PredicateTypes { get; init; }
|
||||||
|
public required SignerRequirements SignerRequirements { get; init; }
|
||||||
|
public ValidityWindow? ValidityWindow { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public required DateTimeOffset UpdatedAt { get; init; }
|
||||||
|
public string? CreatedBy { get; init; }
|
||||||
|
public string? UpdatedBy { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to API contract.
|
||||||
|
/// </summary>
|
||||||
|
public VerificationPolicy ToContract() => new()
|
||||||
|
{
|
||||||
|
PolicyId = PolicyId,
|
||||||
|
Version = Version,
|
||||||
|
Description = Description,
|
||||||
|
TenantScope = TenantScope,
|
||||||
|
PredicateTypes = PredicateTypes,
|
||||||
|
SignerRequirements = SignerRequirements,
|
||||||
|
ValidityWindow = ValidityWindow,
|
||||||
|
Metadata = Metadata,
|
||||||
|
CreatedAt = CreatedAt,
|
||||||
|
UpdatedAt = UpdatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage entity for snapshot.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SnapshotEntity
|
||||||
|
{
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
public required Guid SnapshotId { get; init; }
|
||||||
|
public required string Digest { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
public IReadOnlyList<Guid>? PackIds { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public string? CreatedBy { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to API contract.
|
||||||
|
/// </summary>
|
||||||
|
public Snapshot ToContract() => new()
|
||||||
|
{
|
||||||
|
SnapshotId = SnapshotId,
|
||||||
|
Digest = Digest,
|
||||||
|
Description = Description,
|
||||||
|
PackIds = PackIds,
|
||||||
|
Metadata = Metadata,
|
||||||
|
CreatedAt = CreatedAt,
|
||||||
|
CreatedBy = CreatedBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage entity for violation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ViolationEntity
|
||||||
|
{
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
public required Guid ViolationId { get; init; }
|
||||||
|
public string? PolicyId { get; init; }
|
||||||
|
public required string RuleId { get; init; }
|
||||||
|
public required Severity Severity { get; init; }
|
||||||
|
public required string Message { get; init; }
|
||||||
|
public string? Purl { get; init; }
|
||||||
|
public string? CveId { get; init; }
|
||||||
|
public IReadOnlyDictionary<string, object>? Context { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to API contract.
|
||||||
|
/// </summary>
|
||||||
|
public Violation ToContract() => new()
|
||||||
|
{
|
||||||
|
ViolationId = ViolationId,
|
||||||
|
PolicyId = PolicyId,
|
||||||
|
RuleId = RuleId,
|
||||||
|
Severity = Severity,
|
||||||
|
Message = Message,
|
||||||
|
Purl = Purl,
|
||||||
|
CveId = CveId,
|
||||||
|
Context = Context,
|
||||||
|
CreatedAt = CreatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage entity for override.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OverrideEntity
|
||||||
|
{
|
||||||
|
public required Guid TenantId { get; init; }
|
||||||
|
public required Guid OverrideId { get; init; }
|
||||||
|
public Guid? ProfileId { get; init; }
|
||||||
|
public required string RuleId { get; init; }
|
||||||
|
public required OverrideStatus Status { get; init; }
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
public OverrideScope? Scope { get; init; }
|
||||||
|
public DateTimeOffset? ExpiresAt { get; init; }
|
||||||
|
public string? ApprovedBy { get; init; }
|
||||||
|
public DateTimeOffset? ApprovedAt { get; init; }
|
||||||
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public string? CreatedBy { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to API contract.
|
||||||
|
/// </summary>
|
||||||
|
public Override ToContract() => new()
|
||||||
|
{
|
||||||
|
OverrideId = OverrideId,
|
||||||
|
ProfileId = ProfileId,
|
||||||
|
RuleId = RuleId,
|
||||||
|
Status = Status,
|
||||||
|
Reason = Reason,
|
||||||
|
Scope = Scope,
|
||||||
|
ExpiresAt = ExpiresAt,
|
||||||
|
ApprovedBy = ApprovedBy,
|
||||||
|
ApprovedAt = ApprovedAt,
|
||||||
|
CreatedAt = CreatedAt,
|
||||||
|
CreatedBy = CreatedBy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// History entry for policy pack changes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PolicyPackHistoryEntry
|
||||||
|
{
|
||||||
|
public required Guid PackId { get; init; }
|
||||||
|
public required string Action { get; init; }
|
||||||
|
public required DateTimeOffset Timestamp { get; init; }
|
||||||
|
public string? PerformedBy { get; init; }
|
||||||
|
public PolicyPackStatus? PreviousStatus { get; init; }
|
||||||
|
public PolicyPackStatus? NewStatus { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result for paginated policy pack list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PolicyPackListResult
|
||||||
|
{
|
||||||
|
public required IReadOnlyList<PolicyPackEntity> Items { get; init; }
|
||||||
|
public string? NextPageToken { get; init; }
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result for paginated verification policy list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VerificationPolicyListResult
|
||||||
|
{
|
||||||
|
public required IReadOnlyList<VerificationPolicyEntity> Items { get; init; }
|
||||||
|
public string? NextPageToken { get; init; }
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result for paginated snapshot list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SnapshotListResult
|
||||||
|
{
|
||||||
|
public required IReadOnlyList<SnapshotEntity> Items { get; init; }
|
||||||
|
public string? NextPageToken { get; init; }
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result for paginated violation list.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ViolationListResult
|
||||||
|
{
|
||||||
|
public required IReadOnlyList<ViolationEntity> Items { get; init; }
|
||||||
|
public string? NextPageToken { get; init; }
|
||||||
|
public int TotalCount { get; init; }
|
||||||
|
}
|
||||||
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal file
212
src/Policy/StellaOps.Policy.Registry/Storage/IPolicyPackStore.cs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
using StellaOps.Policy.Registry.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Registry.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store interface for policy pack workspace operations with history tracking.
|
||||||
|
/// Implements REGISTRY-API-27-002: Workspace storage with CRUD + history.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPolicyPackStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new policy pack.
|
||||||
|
/// </summary>
|
||||||
|
Task<PolicyPackEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreatePolicyPackRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a policy pack by ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<PolicyPackEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a policy pack by name.
|
||||||
|
/// </summary>
|
||||||
|
Task<PolicyPackEntity?> GetByNameAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string name,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists policy packs with optional filtering.
|
||||||
|
/// </summary>
|
||||||
|
Task<PolicyPackListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
PolicyPackStatus? status = null,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates a policy pack.
|
||||||
|
/// </summary>
|
||||||
|
Task<PolicyPackEntity?> UpdateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
UpdatePolicyPackRequest request,
|
||||||
|
string? updatedBy = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a policy pack (only drafts can be deleted).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the status of a policy pack.
|
||||||
|
/// </summary>
|
||||||
|
Task<PolicyPackEntity?> UpdateStatusAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
PolicyPackStatus newStatus,
|
||||||
|
string? updatedBy = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the history of changes for a policy pack.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
int limit = 50,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store interface for verification policy operations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IVerificationPolicyStore
|
||||||
|
{
|
||||||
|
Task<VerificationPolicyEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateVerificationPolicyRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<VerificationPolicyEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string policyId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<VerificationPolicyListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<VerificationPolicyEntity?> UpdateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string policyId,
|
||||||
|
UpdateVerificationPolicyRequest request,
|
||||||
|
string? updatedBy = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string policyId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store interface for policy snapshot operations.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISnapshotStore
|
||||||
|
{
|
||||||
|
Task<SnapshotEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateSnapshotRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<SnapshotEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid snapshotId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<SnapshotEntity?> GetByDigestAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string digest,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<SnapshotListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid snapshotId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store interface for violation operations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IViolationStore
|
||||||
|
{
|
||||||
|
Task<ViolationEntity> AppendAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateViolationRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ViolationBatchResult> AppendBatchAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
IReadOnlyList<CreateViolationRequest> requests,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ViolationEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid violationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ViolationListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Severity? severity = null,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store interface for override operations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOverrideStore
|
||||||
|
{
|
||||||
|
Task<OverrideEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateOverrideRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OverrideEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OverrideEntity?> ApproveAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
string? approvedBy = null,
|
||||||
|
string? comment = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<OverrideEntity?> DisableAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using StellaOps.Policy.Registry.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Registry.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory implementation of IOverrideStore for testing and development.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemoryOverrideStore : IOverrideStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
|
||||||
|
|
||||||
|
public Task<OverrideEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateOverrideRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var overrideId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var entity = new OverrideEntity
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
OverrideId = overrideId,
|
||||||
|
ProfileId = request.ProfileId,
|
||||||
|
RuleId = request.RuleId,
|
||||||
|
Status = OverrideStatus.Pending,
|
||||||
|
Reason = request.Reason,
|
||||||
|
Scope = request.Scope,
|
||||||
|
ExpiresAt = request.ExpiresAt,
|
||||||
|
CreatedAt = now,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_overrides[(tenantId, overrideId)] = entity;
|
||||||
|
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OverrideEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_overrides.TryGetValue((tenantId, overrideId), out var entity);
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_overrides.TryRemove((tenantId, overrideId), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OverrideEntity?> ApproveAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
string? approvedBy = null,
|
||||||
|
string? comment = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
|
||||||
|
{
|
||||||
|
return Task.FromResult<OverrideEntity?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only pending overrides can be approved
|
||||||
|
if (existing.Status != OverrideStatus.Pending)
|
||||||
|
{
|
||||||
|
return Task.FromResult<OverrideEntity?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var updated = existing with
|
||||||
|
{
|
||||||
|
Status = OverrideStatus.Approved,
|
||||||
|
ApprovedBy = approvedBy,
|
||||||
|
ApprovedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_overrides[(tenantId, overrideId)] = updated;
|
||||||
|
|
||||||
|
return Task.FromResult<OverrideEntity?>(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OverrideEntity?> DisableAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid overrideId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_overrides.TryGetValue((tenantId, overrideId), out var existing))
|
||||||
|
{
|
||||||
|
return Task.FromResult<OverrideEntity?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = existing with
|
||||||
|
{
|
||||||
|
Status = OverrideStatus.Disabled
|
||||||
|
};
|
||||||
|
|
||||||
|
_overrides[(tenantId, overrideId)] = updated;
|
||||||
|
|
||||||
|
return Task.FromResult<OverrideEntity?>(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using StellaOps.Policy.Registry.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Registry.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory implementation of IPolicyPackStore for testing and development.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemoryPolicyPackStore : IPolicyPackStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
|
||||||
|
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
|
||||||
|
|
||||||
|
public Task<PolicyPackEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreatePolicyPackRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var packId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var entity = new PolicyPackEntity
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
PackId = packId,
|
||||||
|
Name = request.Name,
|
||||||
|
Version = request.Version,
|
||||||
|
Description = request.Description,
|
||||||
|
Status = PolicyPackStatus.Draft,
|
||||||
|
Rules = request.Rules,
|
||||||
|
Metadata = request.Metadata,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_packs[(tenantId, packId)] = entity;
|
||||||
|
|
||||||
|
// Add history entry
|
||||||
|
AddHistoryEntry(tenantId, packId, "created", createdBy, null, PolicyPackStatus.Draft, "Policy pack created");
|
||||||
|
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PolicyPackEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_packs.TryGetValue((tenantId, packId), out var entity);
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PolicyPackEntity?> GetByNameAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string name,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = _packs.Values
|
||||||
|
.Where(p => p.TenantId == tenantId && p.Name == name)
|
||||||
|
.OrderByDescending(p => p.UpdatedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PolicyPackListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
PolicyPackStatus? status = null,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = _packs.Values
|
||||||
|
.Where(p => p.TenantId == tenantId);
|
||||||
|
|
||||||
|
if (status.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(p => p.Status == status.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = query
|
||||||
|
.OrderByDescending(p => p.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int skip = 0;
|
||||||
|
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||||
|
{
|
||||||
|
skip = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||||
|
string? nextToken = skip + pagedItems.Count < items.Count
|
||||||
|
? (skip + pagedItems.Count).ToString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Task.FromResult(new PolicyPackListResult
|
||||||
|
{
|
||||||
|
Items = pagedItems,
|
||||||
|
NextPageToken = nextToken,
|
||||||
|
TotalCount = items.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PolicyPackEntity?> UpdateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
UpdatePolicyPackRequest request,
|
||||||
|
string? updatedBy = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||||
|
{
|
||||||
|
return Task.FromResult<PolicyPackEntity?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow updates to drafts
|
||||||
|
if (existing.Status != PolicyPackStatus.Draft)
|
||||||
|
{
|
||||||
|
return Task.FromResult<PolicyPackEntity?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = existing with
|
||||||
|
{
|
||||||
|
Name = request.Name ?? existing.Name,
|
||||||
|
Description = request.Description ?? existing.Description,
|
||||||
|
Rules = request.Rules ?? existing.Rules,
|
||||||
|
Metadata = request.Metadata ?? existing.Metadata,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedBy = updatedBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_packs[(tenantId, packId)] = updated;
|
||||||
|
|
||||||
|
AddHistoryEntry(tenantId, packId, "updated", updatedBy, existing.Status, updated.Status, "Policy pack updated");
|
||||||
|
|
||||||
|
return Task.FromResult<PolicyPackEntity?>(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||||
|
{
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow deletion of drafts
|
||||||
|
if (existing.Status != PolicyPackStatus.Draft)
|
||||||
|
{
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = _packs.TryRemove((tenantId, packId), out _);
|
||||||
|
if (removed)
|
||||||
|
{
|
||||||
|
_history.TryRemove((tenantId, packId), out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<PolicyPackEntity?> UpdateStatusAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
PolicyPackStatus newStatus,
|
||||||
|
string? updatedBy = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_packs.TryGetValue((tenantId, packId), out var existing))
|
||||||
|
{
|
||||||
|
return Task.FromResult<PolicyPackEntity?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var updated = existing with
|
||||||
|
{
|
||||||
|
Status = newStatus,
|
||||||
|
UpdatedAt = now,
|
||||||
|
UpdatedBy = updatedBy,
|
||||||
|
PublishedAt = newStatus == PolicyPackStatus.Published ? now : existing.PublishedAt,
|
||||||
|
Digest = newStatus == PolicyPackStatus.Published ? ComputeDigest(existing) : existing.Digest
|
||||||
|
};
|
||||||
|
|
||||||
|
_packs[(tenantId, packId)] = updated;
|
||||||
|
|
||||||
|
AddHistoryEntry(tenantId, packId, $"status_changed_to_{newStatus}", updatedBy, existing.Status, newStatus,
|
||||||
|
$"Status changed from {existing.Status} to {newStatus}");
|
||||||
|
|
||||||
|
return Task.FromResult<PolicyPackEntity?>(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<PolicyPackHistoryEntry>> GetHistoryAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
int limit = 50,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_history.TryGetValue((tenantId, packId), out var history))
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(Array.Empty<PolicyPackHistoryEntry>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = history
|
||||||
|
.OrderByDescending(h => h.Timestamp)
|
||||||
|
.Take(limit)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<PolicyPackHistoryEntry>>(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddHistoryEntry(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid packId,
|
||||||
|
string action,
|
||||||
|
string? performedBy,
|
||||||
|
PolicyPackStatus? previousStatus,
|
||||||
|
PolicyPackStatus? newStatus,
|
||||||
|
string? description)
|
||||||
|
{
|
||||||
|
var entry = new PolicyPackHistoryEntry
|
||||||
|
{
|
||||||
|
PackId = packId,
|
||||||
|
Action = action,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow,
|
||||||
|
PerformedBy = performedBy,
|
||||||
|
PreviousStatus = previousStatus,
|
||||||
|
NewStatus = newStatus,
|
||||||
|
Description = description
|
||||||
|
};
|
||||||
|
|
||||||
|
_history.AddOrUpdate(
|
||||||
|
(tenantId, packId),
|
||||||
|
_ => [entry],
|
||||||
|
(_, list) =>
|
||||||
|
{
|
||||||
|
list.Add(entry);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeDigest(PolicyPackEntity pack)
|
||||||
|
{
|
||||||
|
var content = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
pack.Name,
|
||||||
|
pack.Version,
|
||||||
|
pack.Rules
|
||||||
|
});
|
||||||
|
|
||||||
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||||
|
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using StellaOps.Policy.Registry.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Registry.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory implementation of ISnapshotStore for testing and development.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemorySnapshotStore : ISnapshotStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
|
||||||
|
|
||||||
|
public Task<SnapshotEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateSnapshotRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var snapshotId = Guid.NewGuid();
|
||||||
|
|
||||||
|
// Compute digest from pack IDs and timestamp for uniqueness
|
||||||
|
var digest = ComputeDigest(request.PackIds, now);
|
||||||
|
|
||||||
|
var entity = new SnapshotEntity
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
SnapshotId = snapshotId,
|
||||||
|
Digest = digest,
|
||||||
|
Description = request.Description,
|
||||||
|
PackIds = request.PackIds,
|
||||||
|
Metadata = request.Metadata,
|
||||||
|
CreatedAt = now,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_snapshots[(tenantId, snapshotId)] = entity;
|
||||||
|
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SnapshotEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid snapshotId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_snapshots.TryGetValue((tenantId, snapshotId), out var entity);
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SnapshotEntity?> GetByDigestAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string digest,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = _snapshots.Values
|
||||||
|
.Where(s => s.TenantId == tenantId && s.Digest == digest)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SnapshotListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var items = _snapshots.Values
|
||||||
|
.Where(s => s.TenantId == tenantId)
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int skip = 0;
|
||||||
|
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||||
|
{
|
||||||
|
skip = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||||
|
string? nextToken = skip + pagedItems.Count < items.Count
|
||||||
|
? (skip + pagedItems.Count).ToString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Task.FromResult(new SnapshotListResult
|
||||||
|
{
|
||||||
|
Items = pagedItems,
|
||||||
|
NextPageToken = nextToken,
|
||||||
|
TotalCount = items.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid snapshotId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_snapshots.TryRemove((tenantId, snapshotId), out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeDigest(IReadOnlyList<Guid> packIds, DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
var content = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
PackIds = packIds.OrderBy(id => id).ToList(),
|
||||||
|
Timestamp = timestamp.ToUnixTimeMilliseconds()
|
||||||
|
});
|
||||||
|
|
||||||
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||||
|
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using StellaOps.Policy.Registry.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Registry.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory implementation of IVerificationPolicyStore for testing and development.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
|
||||||
|
|
||||||
|
public Task<VerificationPolicyEntity> CreateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateVerificationPolicyRequest request,
|
||||||
|
string? createdBy = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var entity = new VerificationPolicyEntity
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
PolicyId = request.PolicyId,
|
||||||
|
Version = request.Version,
|
||||||
|
Description = request.Description,
|
||||||
|
TenantScope = request.TenantScope ?? tenantId.ToString(),
|
||||||
|
PredicateTypes = request.PredicateTypes,
|
||||||
|
SignerRequirements = request.SignerRequirements ?? new SignerRequirements
|
||||||
|
{
|
||||||
|
MinimumSignatures = 1,
|
||||||
|
TrustedKeyFingerprints = Array.Empty<string>()
|
||||||
|
},
|
||||||
|
ValidityWindow = request.ValidityWindow,
|
||||||
|
Metadata = request.Metadata,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now,
|
||||||
|
CreatedBy = createdBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_policies[(tenantId, request.PolicyId)] = entity;
|
||||||
|
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<VerificationPolicyEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string policyId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_policies.TryGetValue((tenantId, policyId), out var entity);
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<VerificationPolicyListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var items = _policies.Values
|
||||||
|
.Where(p => p.TenantId == tenantId)
|
||||||
|
.OrderByDescending(p => p.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int skip = 0;
|
||||||
|
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||||
|
{
|
||||||
|
skip = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||||
|
string? nextToken = skip + pagedItems.Count < items.Count
|
||||||
|
? (skip + pagedItems.Count).ToString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Task.FromResult(new VerificationPolicyListResult
|
||||||
|
{
|
||||||
|
Items = pagedItems,
|
||||||
|
NextPageToken = nextToken,
|
||||||
|
TotalCount = items.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<VerificationPolicyEntity?> UpdateAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string policyId,
|
||||||
|
UpdateVerificationPolicyRequest request,
|
||||||
|
string? updatedBy = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!_policies.TryGetValue((tenantId, policyId), out var existing))
|
||||||
|
{
|
||||||
|
return Task.FromResult<VerificationPolicyEntity?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = existing with
|
||||||
|
{
|
||||||
|
Version = request.Version ?? existing.Version,
|
||||||
|
Description = request.Description ?? existing.Description,
|
||||||
|
PredicateTypes = request.PredicateTypes ?? existing.PredicateTypes,
|
||||||
|
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
|
||||||
|
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
|
||||||
|
Metadata = request.Metadata ?? existing.Metadata,
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
UpdatedBy = updatedBy
|
||||||
|
};
|
||||||
|
|
||||||
|
_policies[(tenantId, policyId)] = updated;
|
||||||
|
|
||||||
|
return Task.FromResult<VerificationPolicyEntity?>(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> DeleteAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string policyId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_policies.TryRemove((tenantId, policyId), out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using StellaOps.Policy.Registry.Contracts;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Registry.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory implementation of IViolationStore for testing and development.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemoryViolationStore : IViolationStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
|
||||||
|
|
||||||
|
public Task<ViolationEntity> AppendAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
CreateViolationRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var violationId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var entity = new ViolationEntity
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
ViolationId = violationId,
|
||||||
|
PolicyId = request.PolicyId,
|
||||||
|
RuleId = request.RuleId,
|
||||||
|
Severity = request.Severity,
|
||||||
|
Message = request.Message,
|
||||||
|
Purl = request.Purl,
|
||||||
|
CveId = request.CveId,
|
||||||
|
Context = request.Context,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_violations[(tenantId, violationId)] = entity;
|
||||||
|
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ViolationBatchResult> AppendBatchAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
IReadOnlyList<CreateViolationRequest> requests,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
int created = 0;
|
||||||
|
int failed = 0;
|
||||||
|
var errors = new List<BatchError>();
|
||||||
|
|
||||||
|
for (int i = 0; i < requests.Count; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = requests[i];
|
||||||
|
var violationId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var entity = new ViolationEntity
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
ViolationId = violationId,
|
||||||
|
PolicyId = request.PolicyId,
|
||||||
|
RuleId = request.RuleId,
|
||||||
|
Severity = request.Severity,
|
||||||
|
Message = request.Message,
|
||||||
|
Purl = request.Purl,
|
||||||
|
CveId = request.CveId,
|
||||||
|
Context = request.Context,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
_violations[(tenantId, violationId)] = entity;
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
errors.Add(new BatchError
|
||||||
|
{
|
||||||
|
Index = i,
|
||||||
|
Error = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new ViolationBatchResult
|
||||||
|
{
|
||||||
|
Created = created,
|
||||||
|
Failed = failed,
|
||||||
|
Errors = errors.Count > 0 ? errors : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ViolationEntity?> GetByIdAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid violationId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_violations.TryGetValue((tenantId, violationId), out var entity);
|
||||||
|
return Task.FromResult(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ViolationListResult> ListAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Severity? severity = null,
|
||||||
|
int pageSize = 20,
|
||||||
|
string? pageToken = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = _violations.Values
|
||||||
|
.Where(v => v.TenantId == tenantId);
|
||||||
|
|
||||||
|
if (severity.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(v => v.Severity == severity.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = query
|
||||||
|
.OrderByDescending(v => v.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int skip = 0;
|
||||||
|
if (!string.IsNullOrEmpty(pageToken) && int.TryParse(pageToken, out var offset))
|
||||||
|
{
|
||||||
|
skip = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pagedItems = items.Skip(skip).Take(pageSize).ToList();
|
||||||
|
string? nextToken = skip + pagedItems.Count < items.Count
|
||||||
|
? (skip + pagedItems.Count).ToString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Task.FromResult(new ViolationListResult
|
||||||
|
{
|
||||||
|
Items = pagedItems,
|
||||||
|
NextPageToken = nextToken,
|
||||||
|
TotalCount = items.Count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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.Tests.Parsers;
|
||||||
|
|
||||||
|
public sealed class JavaPropertyResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesSimpleProperty()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["version"] = "1.0.0"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var result = resolver.Resolve("${version}");
|
||||||
|
|
||||||
|
Assert.Equal("1.0.0", result.ResolvedValue);
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Empty(result.UnresolvedProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesMultipleProperties()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["groupId"] = "com.example",
|
||||||
|
["artifactId"] = "demo",
|
||||||
|
["version"] = "2.0.0"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var result = resolver.Resolve("${groupId}:${artifactId}:${version}");
|
||||||
|
|
||||||
|
Assert.Equal("com.example:demo:2.0.0", result.ResolvedValue);
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesNestedProperties()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["slf4j.version"] = "2.0.7",
|
||||||
|
["logging.version"] = "${slf4j.version}"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var result = resolver.Resolve("${logging.version}");
|
||||||
|
|
||||||
|
Assert.Equal("2.0.7", result.ResolvedValue);
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandlesCircularReference()
|
||||||
|
{
|
||||||
|
// Circular: a → b → a (should stop at max depth)
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["a"] = "${b}",
|
||||||
|
["b"] = "${a}"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var result = resolver.Resolve("${a}");
|
||||||
|
|
||||||
|
// Should stop recursing and return whatever state it reaches
|
||||||
|
Assert.False(result.IsFullyResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandlesMaxRecursionDepth()
|
||||||
|
{
|
||||||
|
// Create a chain of 15 nested properties (exceeds max depth of 10)
|
||||||
|
var properties = new Dictionary<string, string>();
|
||||||
|
for (int i = 0; i < 15; i++)
|
||||||
|
{
|
||||||
|
properties[$"prop{i}"] = $"${{prop{i + 1}}}";
|
||||||
|
}
|
||||||
|
properties["prop15"] = "final-value";
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties.ToImmutableDictionary());
|
||||||
|
var result = resolver.Resolve("${prop0}");
|
||||||
|
|
||||||
|
// Should not reach final-value due to depth limit
|
||||||
|
Assert.Contains("${", result.ResolvedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreservesUnresolvedPlaceholder()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["known"] = "value"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var result = resolver.Resolve("${unknown}");
|
||||||
|
|
||||||
|
Assert.Equal("${unknown}", result.ResolvedValue);
|
||||||
|
Assert.False(result.IsFullyResolved);
|
||||||
|
Assert.Single(result.UnresolvedProperties);
|
||||||
|
Assert.Contains("unknown", result.UnresolvedProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesMavenStandardProperties()
|
||||||
|
{
|
||||||
|
var resolver = new JavaPropertyResolver();
|
||||||
|
|
||||||
|
var basedir = resolver.Resolve("${project.basedir}");
|
||||||
|
Assert.Equal(".", basedir.ResolvedValue);
|
||||||
|
|
||||||
|
var buildDir = resolver.Resolve("${project.build.directory}");
|
||||||
|
Assert.Equal("target", buildDir.ResolvedValue);
|
||||||
|
|
||||||
|
var sourceDir = resolver.Resolve("${project.build.sourceDirectory}");
|
||||||
|
Assert.Equal("src/main/java", sourceDir.ResolvedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandlesEmptyInput()
|
||||||
|
{
|
||||||
|
var resolver = new JavaPropertyResolver();
|
||||||
|
|
||||||
|
var nullResult = resolver.Resolve(null);
|
||||||
|
Assert.Equal(PropertyResolutionResult.Empty, nullResult);
|
||||||
|
|
||||||
|
var emptyResult = resolver.Resolve(string.Empty);
|
||||||
|
Assert.Equal(PropertyResolutionResult.Empty, emptyResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandlesInputWithoutPlaceholders()
|
||||||
|
{
|
||||||
|
var resolver = new JavaPropertyResolver();
|
||||||
|
var result = resolver.Resolve("plain-text-value");
|
||||||
|
|
||||||
|
Assert.Equal("plain-text-value", result.ResolvedValue);
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Empty(result.UnresolvedProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesFromParentChain()
|
||||||
|
{
|
||||||
|
var childProps = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["child.version"] = "1.0.0"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var parentProps = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["parent.version"] = "2.0.0",
|
||||||
|
["shared.version"] = "parent-value"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var grandparentProps = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grandparent.version"] = "3.0.0"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(childProps, [parentProps, grandparentProps]);
|
||||||
|
|
||||||
|
// Child property
|
||||||
|
Assert.Equal("1.0.0", resolver.Resolve("${child.version}").ResolvedValue);
|
||||||
|
|
||||||
|
// Parent property
|
||||||
|
Assert.Equal("2.0.0", resolver.Resolve("${parent.version}").ResolvedValue);
|
||||||
|
|
||||||
|
// Grandparent property
|
||||||
|
Assert.Equal("3.0.0", resolver.Resolve("${grandparent.version}").ResolvedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChildPropertyOverridesParent()
|
||||||
|
{
|
||||||
|
var childProps = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["version"] = "child-value"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var parentProps = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["version"] = "parent-value"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(childProps, [parentProps]);
|
||||||
|
var result = resolver.Resolve("${version}");
|
||||||
|
|
||||||
|
Assert.Equal("child-value", result.ResolvedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesProjectCoordinateProperties()
|
||||||
|
{
|
||||||
|
var builder = new JavaPropertyBuilder()
|
||||||
|
.AddProjectCoordinates("com.example", "demo", "1.0.0");
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(builder.Build());
|
||||||
|
|
||||||
|
Assert.Equal("com.example", resolver.Resolve("${project.groupId}").ResolvedValue);
|
||||||
|
Assert.Equal("com.example", resolver.Resolve("${groupId}").ResolvedValue);
|
||||||
|
Assert.Equal("demo", resolver.Resolve("${project.artifactId}").ResolvedValue);
|
||||||
|
Assert.Equal("1.0.0", resolver.Resolve("${project.version}").ResolvedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesDependencyVersion()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["slf4j.version"] = "2.0.7"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
|
||||||
|
var dependency = new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = "org.slf4j",
|
||||||
|
ArtifactId = "slf4j-api",
|
||||||
|
Version = "${slf4j.version}",
|
||||||
|
Source = "pom.xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
var resolved = resolver.ResolveDependency(dependency);
|
||||||
|
|
||||||
|
Assert.Equal("2.0.7", resolved.Version);
|
||||||
|
Assert.Equal(JavaVersionSource.Property, resolved.VersionSource);
|
||||||
|
Assert.Equal("slf4j.version", resolved.VersionProperty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesDependencyWithUnresolvedVersion()
|
||||||
|
{
|
||||||
|
var resolver = new JavaPropertyResolver();
|
||||||
|
|
||||||
|
var dependency = new JavaDependencyDeclaration
|
||||||
|
{
|
||||||
|
GroupId = "org.unknown",
|
||||||
|
ArtifactId = "unknown",
|
||||||
|
Version = "${unknown.version}",
|
||||||
|
Source = "pom.xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
var resolved = resolver.ResolveDependency(dependency);
|
||||||
|
|
||||||
|
Assert.Equal("${unknown.version}", resolved.Version);
|
||||||
|
Assert.Equal(JavaVersionSource.Unresolved, resolved.VersionSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PropertyBuilderAddRange()
|
||||||
|
{
|
||||||
|
var existing = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["existing"] = "value1",
|
||||||
|
["override"] = "original"
|
||||||
|
};
|
||||||
|
|
||||||
|
var builder = new JavaPropertyBuilder()
|
||||||
|
.Add("override", "new-value") // Added first
|
||||||
|
.AddRange(existing); // Won't override
|
||||||
|
|
||||||
|
var props = builder.Build();
|
||||||
|
|
||||||
|
Assert.Equal("new-value", props["override"]);
|
||||||
|
Assert.Equal("value1", props["existing"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PropertyBuilderAddParentCoordinates()
|
||||||
|
{
|
||||||
|
var parent = new JavaParentReference
|
||||||
|
{
|
||||||
|
GroupId = "org.springframework.boot",
|
||||||
|
ArtifactId = "spring-boot-starter-parent",
|
||||||
|
Version = "3.1.0"
|
||||||
|
};
|
||||||
|
|
||||||
|
var builder = new JavaPropertyBuilder().AddParentCoordinates(parent);
|
||||||
|
var props = builder.Build();
|
||||||
|
|
||||||
|
Assert.Equal("org.springframework.boot", props["project.parent.groupId"]);
|
||||||
|
Assert.Equal("spring-boot-starter-parent", props["project.parent.artifactId"]);
|
||||||
|
Assert.Equal("3.1.0", props["project.parent.version"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesComplexMavenExpression()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["spring.version"] = "6.0.0",
|
||||||
|
["project.version"] = "1.0.0-SNAPSHOT"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var result = resolver.Resolve("spring-${spring.version}-app-${project.version}");
|
||||||
|
|
||||||
|
Assert.Equal("spring-6.0.0-app-1.0.0-SNAPSHOT", result.ResolvedValue);
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandlesMixedResolvedAndUnresolved()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["known"] = "resolved"
|
||||||
|
}.ToImmutableDictionary();
|
||||||
|
|
||||||
|
var resolver = new JavaPropertyResolver(properties);
|
||||||
|
var result = resolver.Resolve("${known}-${unknown}");
|
||||||
|
|
||||||
|
Assert.Equal("resolved-${unknown}", result.ResolvedValue);
|
||||||
|
Assert.False(result.IsFullyResolved);
|
||||||
|
Assert.Single(result.UnresolvedProperties);
|
||||||
|
Assert.Contains("unknown", result.UnresolvedProperties);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||||
|
|
||||||
|
public sealed class MavenEffectivePomBuilderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task BuildsEffectivePomWithParentPropertiesAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with properties
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<guava.version>31.1-jre</guava.version>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child using parent properties
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.guava</groupId>
|
||||||
|
<artifactId>guava</artifactId>
|
||||||
|
<version>${guava.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Equal("17", result.EffectiveProperties["java.version"]);
|
||||||
|
Assert.Single(result.ResolvedDependencies);
|
||||||
|
|
||||||
|
var dep = result.ResolvedDependencies[0];
|
||||||
|
Assert.Equal("31.1-jre", dep.Version);
|
||||||
|
Assert.Equal(JavaVersionSource.Property, dep.VersionSource);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergesParentDependencyManagementAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with dependencyManagement
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>2.0.7</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>4.13.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child with version-less dependencies
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.ResolvedDependencies.Length);
|
||||||
|
|
||||||
|
var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||||
|
Assert.Equal("2.0.7", slf4j.Version);
|
||||||
|
|
||||||
|
var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit");
|
||||||
|
Assert.Equal("4.13.2", junit.Version);
|
||||||
|
Assert.Equal("test", junit.Scope);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChildDependencyManagementOverridesParentAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with version
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>1.7.36</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child overriding version
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>2.0.9</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||||
|
Assert.Equal("2.0.9", dep.Version); // Child's version wins
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandlesStandalonePomAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pomPath = Path.Combine(root, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(pomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>standalone</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<properties>
|
||||||
|
<encoding>UTF-8</encoding>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>2.0.7</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Single(result.ParentChain); // Only the POM itself
|
||||||
|
Assert.Equal("UTF-8", result.EffectiveProperties["encoding"]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolvesPropertyInDependencyManagementVersionAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pomPath = Path.Combine(root, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(pomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>app</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<properties>
|
||||||
|
<commons.version>3.12.0</commons.version>
|
||||||
|
</properties>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-lang3</artifactId>
|
||||||
|
<version>${commons.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-lang3</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||||
|
|
||||||
|
var dep = result.ResolvedDependencies.First(d => d.ArtifactId == "commons-lang3");
|
||||||
|
Assert.Equal("3.12.0", dep.Version);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TracksVersionSourceCorrectlyAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with dependencyManagement
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>2.0.7</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child with various version sources
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
<properties>
|
||||||
|
<guava.version>31.1-jre</guava.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<!-- Direct version -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>4.13.2</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- Property version -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.guava</groupId>
|
||||||
|
<artifactId>guava</artifactId>
|
||||||
|
<version>${guava.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- Dependency management version -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
var junit = result.ResolvedDependencies.First(d => d.ArtifactId == "junit");
|
||||||
|
Assert.Equal("4.13.2", junit.Version);
|
||||||
|
Assert.Equal(JavaVersionSource.Direct, junit.VersionSource);
|
||||||
|
|
||||||
|
var guava = result.ResolvedDependencies.First(d => d.ArtifactId == "guava");
|
||||||
|
Assert.Equal("31.1-jre", guava.Version);
|
||||||
|
Assert.Equal(JavaVersionSource.Property, guava.VersionSource);
|
||||||
|
Assert.Equal("guava.version", guava.VersionProperty);
|
||||||
|
|
||||||
|
var slf4j = result.ResolvedDependencies.First(d => d.ArtifactId == "slf4j-api");
|
||||||
|
Assert.Equal("2.0.7", slf4j.Version);
|
||||||
|
// From parent's dependencyManagement
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CollectsAllLicensesAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with Apache license
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<licenses>
|
||||||
|
<license>
|
||||||
|
<name>Apache License 2.0</name>
|
||||||
|
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||||
|
</license>
|
||||||
|
</licenses>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child (inherits license)
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Single(result.Licenses);
|
||||||
|
Assert.Equal("Apache-2.0", result.Licenses[0].SpdxId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetsUnresolvedDependenciesAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pomPath = Path.Combine(root, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(pomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>app</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<dependencies>
|
||||||
|
<!-- Unresolved property -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>${undefined.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- No version and not in dependencyManagement -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>missing</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||||
|
|
||||||
|
var unresolved = result.GetUnresolvedDependencies().ToList();
|
||||||
|
Assert.Equal(2, unresolved.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PopulatesManagedVersionsIndexAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pomPath = Path.Combine(root, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(pomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>app</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>2.0.7</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.guava</groupId>
|
||||||
|
<artifactId>guava</artifactId>
|
||||||
|
<version>31.1-jre</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||||
|
var builder = new MavenEffectivePomBuilder(root);
|
||||||
|
var result = await builder.BuildAsync(pom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.ManagedVersions.Count);
|
||||||
|
Assert.True(result.ManagedVersions.ContainsKey("org.slf4j:slf4j-api"));
|
||||||
|
Assert.True(result.ManagedVersions.ContainsKey("com.google.guava:guava"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||||
|
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Analyzers.Lang.Java.Tests.Parsers;
|
||||||
|
|
||||||
|
public sealed class MavenParentResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolvesRelativePathParentAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create parent/pom.xml
|
||||||
|
var parentDir = Path.Combine(root, "parent");
|
||||||
|
Directory.CreateDirectory(parentDir);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Create child/pom.xml with relativePath to parent
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<relativePath>../parent/pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Equal(2, result.ParentChain.Length); // child + parent
|
||||||
|
Assert.Equal("17", result.EffectiveProperties["java.version"]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolvesDefaultRelativePathAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create parent pom.xml in parent directory (default ../pom.xml)
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>2.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Create child in subdirectory with no relativePath (defaults to ../pom.xml)
|
||||||
|
var childDir = Path.Combine(root, "module");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>2.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>module</artifactId>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Equal("2.0.0", result.EffectiveVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolvesMultiLevelParentChainAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Grandparent
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>grandparent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<properties>
|
||||||
|
<grandparent.prop>gp-value</grandparent.prop>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Parent
|
||||||
|
var parentDir = Path.Combine(root, "parent");
|
||||||
|
Directory.CreateDirectory(parentDir);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(parentDir, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>grandparent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<properties>
|
||||||
|
<parent.prop>parent-value</parent.prop>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child
|
||||||
|
var childDir = Path.Combine(parentDir, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Equal(3, result.ParentChain.Length); // child, parent, grandparent
|
||||||
|
Assert.Equal("gp-value", result.EffectiveProperties["grandparent.prop"]);
|
||||||
|
Assert.Equal("parent-value", result.EffectiveProperties["parent.prop"]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReturnsUnresolvedForMissingParentAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var childPomPath = Path.Combine(root, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>missing-parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>orphan</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsFullyResolved);
|
||||||
|
Assert.Single(result.UnresolvedParents);
|
||||||
|
Assert.Contains("com.example:missing-parent:1.0.0", result.UnresolvedParents);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandlesNoParentAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pomPath = Path.Combine(root, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(pomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>standalone</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(pom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.IsFullyResolved);
|
||||||
|
Assert.Single(result.ParentChain); // Only the original POM
|
||||||
|
Assert.Equal("com.example", result.EffectiveGroupId);
|
||||||
|
Assert.Equal("1.0.0", result.EffectiveVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InheritsGroupIdFromParentAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with groupId
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>org.parent</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child without explicit groupId
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.parent</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("org.parent", result.EffectiveGroupId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InheritsVersionFromParentAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>2.5.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>2.5.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("2.5.0", result.EffectiveVersion);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolvesDependencyVersionFromManagementAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with dependencyManagement
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>2.0.7</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child with dependency without version
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Single(result.ResolvedDependencies);
|
||||||
|
var dep = result.ResolvedDependencies[0];
|
||||||
|
Assert.Equal("2.0.7", dep.Version);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolvesPropertyInDependencyVersionAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pomPath = Path.Combine(root, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(pomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>app</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<properties>
|
||||||
|
<guava.version>31.1-jre</guava.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.guava</groupId>
|
||||||
|
<artifactId>guava</artifactId>
|
||||||
|
<version>${guava.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var pom = await MavenPomParser.ParseAsync(pomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(pom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Single(result.ResolvedDependencies);
|
||||||
|
var dep = result.ResolvedDependencies[0];
|
||||||
|
Assert.Equal("31.1-jre", dep.Version);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CollectsLicensesFromChainAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with license
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<licenses>
|
||||||
|
<license>
|
||||||
|
<name>Apache License 2.0</name>
|
||||||
|
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
|
||||||
|
</license>
|
||||||
|
</licenses>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
Assert.Single(result.AllLicenses);
|
||||||
|
Assert.Equal("Apache-2.0", result.AllLicenses[0].SpdxId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChildPropertyOverridesParentPropertyAsync()
|
||||||
|
{
|
||||||
|
var cancellationToken = TestContext.Current.CancellationToken;
|
||||||
|
var root = TestPaths.CreateTemporaryDirectory();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parent with property
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "pom.xml"), """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<properties>
|
||||||
|
<java.version>11</java.version>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
// Child overriding property
|
||||||
|
var childDir = Path.Combine(root, "child");
|
||||||
|
Directory.CreateDirectory(childDir);
|
||||||
|
var childPomPath = Path.Combine(childDir, "pom.xml");
|
||||||
|
await File.WriteAllTextAsync(childPomPath, """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>parent</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</parent>
|
||||||
|
<artifactId>child</artifactId>
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
|
""", cancellationToken);
|
||||||
|
|
||||||
|
var childPom = await MavenPomParser.ParseAsync(childPomPath, cancellationToken);
|
||||||
|
var resolver = new MavenParentResolver(root);
|
||||||
|
var result = await resolver.ResolveAsync(childPom, cancellationToken);
|
||||||
|
|
||||||
|
// Child property should win
|
||||||
|
Assert.Equal("17", result.EffectiveProperties["java.version"]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TestPaths.SafeDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -328,6 +328,18 @@ public static class PackRunEventTypes
|
|||||||
/// <summary>Attestation was revoked.</summary>
|
/// <summary>Attestation was revoked.</summary>
|
||||||
public const string AttestationRevoked = "pack.attestation.revoked";
|
public const string AttestationRevoked = "pack.attestation.revoked";
|
||||||
|
|
||||||
|
/// <summary>Incident mode activated (per TASKRUN-OBS-55-001).</summary>
|
||||||
|
public const string IncidentModeActivated = "pack.incident.activated";
|
||||||
|
|
||||||
|
/// <summary>Incident mode deactivated.</summary>
|
||||||
|
public const string IncidentModeDeactivated = "pack.incident.deactivated";
|
||||||
|
|
||||||
|
/// <summary>Incident mode escalated to higher level.</summary>
|
||||||
|
public const string IncidentModeEscalated = "pack.incident.escalated";
|
||||||
|
|
||||||
|
/// <summary>SLO breach detected triggering incident mode.</summary>
|
||||||
|
public const string SloBreachDetected = "pack.incident.slo_breach";
|
||||||
|
|
||||||
/// <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);
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.TaskRunner.Core.Events;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Core.IncidentMode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing pack run incident mode.
|
||||||
|
/// Per TASKRUN-OBS-55-001.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPackRunIncidentModeService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Activates incident mode for a run.
|
||||||
|
/// </summary>
|
||||||
|
Task<IncidentModeActivationResult> ActivateAsync(
|
||||||
|
IncidentModeActivationRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deactivates incident mode for a run.
|
||||||
|
/// </summary>
|
||||||
|
Task<IncidentModeActivationResult> DeactivateAsync(
|
||||||
|
string runId,
|
||||||
|
string? reason = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current incident mode status for a run.
|
||||||
|
/// </summary>
|
||||||
|
Task<PackRunIncidentModeStatus> GetStatusAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles an SLO breach notification.
|
||||||
|
/// </summary>
|
||||||
|
Task<IncidentModeActivationResult> HandleSloBreachAsync(
|
||||||
|
SloBreachNotification notification,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escalates incident mode to a higher level.
|
||||||
|
/// </summary>
|
||||||
|
Task<IncidentModeActivationResult> EscalateAsync(
|
||||||
|
string runId,
|
||||||
|
IncidentEscalationLevel newLevel,
|
||||||
|
string? reason = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets settings for the current incident mode level.
|
||||||
|
/// </summary>
|
||||||
|
IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store for incident mode state.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPackRunIncidentModeStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Stores incident mode status.
|
||||||
|
/// </summary>
|
||||||
|
Task StoreAsync(
|
||||||
|
string runId,
|
||||||
|
PackRunIncidentModeStatus status,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets incident mode status.
|
||||||
|
/// </summary>
|
||||||
|
Task<PackRunIncidentModeStatus?> GetAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all runs in incident mode.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> ListActiveRunsAsync(
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes incident mode status.
|
||||||
|
/// </summary>
|
||||||
|
Task RemoveAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings for incident mode levels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IncidentModeSettings(
|
||||||
|
/// <summary>Escalation level.</summary>
|
||||||
|
IncidentEscalationLevel Level,
|
||||||
|
|
||||||
|
/// <summary>Retention policy.</summary>
|
||||||
|
IncidentRetentionPolicy RetentionPolicy,
|
||||||
|
|
||||||
|
/// <summary>Telemetry settings.</summary>
|
||||||
|
IncidentTelemetrySettings TelemetrySettings,
|
||||||
|
|
||||||
|
/// <summary>Debug capture settings.</summary>
|
||||||
|
IncidentDebugCaptureSettings DebugCaptureSettings);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default implementation of pack run incident mode service.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PackRunIncidentModeService : IPackRunIncidentModeService
|
||||||
|
{
|
||||||
|
private readonly IPackRunIncidentModeStore _store;
|
||||||
|
private readonly IPackRunTimelineEventEmitter? _timelineEmitter;
|
||||||
|
private readonly ILogger<PackRunIncidentModeService> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public PackRunIncidentModeService(
|
||||||
|
IPackRunIncidentModeStore store,
|
||||||
|
ILogger<PackRunIncidentModeService> logger,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IPackRunTimelineEventEmitter? timelineEmitter = null)
|
||||||
|
{
|
||||||
|
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_timelineEmitter = timelineEmitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IncidentModeActivationResult> ActivateAsync(
|
||||||
|
IncidentModeActivationRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
var settings = GetSettingsForLevel(request.Level);
|
||||||
|
|
||||||
|
var expiresAt = request.DurationMinutes.HasValue
|
||||||
|
? now.AddMinutes(request.DurationMinutes.Value)
|
||||||
|
: (DateTimeOffset?)null;
|
||||||
|
|
||||||
|
var status = new PackRunIncidentModeStatus(
|
||||||
|
Active: true,
|
||||||
|
Level: request.Level,
|
||||||
|
ActivatedAt: now,
|
||||||
|
ActivationReason: request.Reason,
|
||||||
|
Source: request.Source,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
RetentionPolicy: settings.RetentionPolicy,
|
||||||
|
TelemetrySettings: settings.TelemetrySettings,
|
||||||
|
DebugCaptureSettings: settings.DebugCaptureSettings);
|
||||||
|
|
||||||
|
await _store.StoreAsync(request.RunId, status, cancellationToken);
|
||||||
|
|
||||||
|
// Emit timeline event
|
||||||
|
await EmitTimelineEventAsync(
|
||||||
|
request.TenantId,
|
||||||
|
request.RunId,
|
||||||
|
PackRunIncidentEventTypes.IncidentModeActivated,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["level"] = request.Level.ToString(),
|
||||||
|
["source"] = request.Source.ToString(),
|
||||||
|
["reason"] = request.Reason,
|
||||||
|
["requestedBy"] = request.RequestedBy ?? "system"
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Incident mode activated for run {RunId} at level {Level} due to: {Reason}",
|
||||||
|
request.RunId,
|
||||||
|
request.Level,
|
||||||
|
request.Reason);
|
||||||
|
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: true,
|
||||||
|
Status: status,
|
||||||
|
Error: null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to activate incident mode for run {RunId}", request.RunId);
|
||||||
|
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: false,
|
||||||
|
Status: PackRunIncidentModeStatus.Inactive(),
|
||||||
|
Error: ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IncidentModeActivationResult> DeactivateAsync(
|
||||||
|
string runId,
|
||||||
|
string? reason = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var current = await _store.GetAsync(runId, cancellationToken);
|
||||||
|
if (current is null || !current.Active)
|
||||||
|
{
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: true,
|
||||||
|
Status: PackRunIncidentModeStatus.Inactive(),
|
||||||
|
Error: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.RemoveAsync(runId, cancellationToken);
|
||||||
|
var inactive = PackRunIncidentModeStatus.Inactive();
|
||||||
|
|
||||||
|
// Emit timeline event (using default tenant since we don't have it)
|
||||||
|
await EmitTimelineEventAsync(
|
||||||
|
"default",
|
||||||
|
runId,
|
||||||
|
PackRunIncidentEventTypes.IncidentModeDeactivated,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["previousLevel"] = current.Level.ToString(),
|
||||||
|
["reason"] = reason ?? "Manual deactivation",
|
||||||
|
["activeDuration"] = current.ActivatedAt.HasValue
|
||||||
|
? (_timeProvider.GetUtcNow() - current.ActivatedAt.Value).ToString()
|
||||||
|
: "unknown"
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Incident mode deactivated for run {RunId}. Reason: {Reason}",
|
||||||
|
runId,
|
||||||
|
reason ?? "Manual deactivation");
|
||||||
|
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: true,
|
||||||
|
Status: inactive,
|
||||||
|
Error: null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to deactivate incident mode for run {RunId}", runId);
|
||||||
|
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: false,
|
||||||
|
Status: PackRunIncidentModeStatus.Inactive(),
|
||||||
|
Error: ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<PackRunIncidentModeStatus> GetStatusAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var status = await _store.GetAsync(runId, cancellationToken);
|
||||||
|
|
||||||
|
if (status is null)
|
||||||
|
{
|
||||||
|
return PackRunIncidentModeStatus.Inactive();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if (status.ExpiresAt.HasValue && status.ExpiresAt.Value <= _timeProvider.GetUtcNow())
|
||||||
|
{
|
||||||
|
await _store.RemoveAsync(runId, cancellationToken);
|
||||||
|
return PackRunIncidentModeStatus.Inactive();
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IncidentModeActivationResult> HandleSloBreachAsync(
|
||||||
|
SloBreachNotification notification,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(notification);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(notification.ResourceId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Received SLO breach notification {BreachId} without resource ID, skipping incident activation",
|
||||||
|
notification.BreachId);
|
||||||
|
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: false,
|
||||||
|
Status: PackRunIncidentModeStatus.Inactive(),
|
||||||
|
Error: "No resource ID in SLO breach notification");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map severity to escalation level
|
||||||
|
var level = notification.Severity?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"CRITICAL" => IncidentEscalationLevel.Critical,
|
||||||
|
"HIGH" => IncidentEscalationLevel.High,
|
||||||
|
"MEDIUM" => IncidentEscalationLevel.Medium,
|
||||||
|
"LOW" => IncidentEscalationLevel.Low,
|
||||||
|
_ => IncidentEscalationLevel.Medium
|
||||||
|
};
|
||||||
|
|
||||||
|
var request = new IncidentModeActivationRequest(
|
||||||
|
RunId: notification.ResourceId,
|
||||||
|
TenantId: notification.TenantId ?? "default",
|
||||||
|
Level: level,
|
||||||
|
Source: IncidentModeSource.SloBreach,
|
||||||
|
Reason: $"SLO breach: {notification.SloName} ({notification.CurrentValue:F2} vs threshold {notification.Threshold:F2})",
|
||||||
|
DurationMinutes: 60, // Auto-expire after 1 hour
|
||||||
|
RequestedBy: "slo-monitor");
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Processing SLO breach {BreachId} for {SloName} on resource {ResourceId}",
|
||||||
|
notification.BreachId,
|
||||||
|
notification.SloName,
|
||||||
|
notification.ResourceId);
|
||||||
|
|
||||||
|
return await ActivateAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IncidentModeActivationResult> EscalateAsync(
|
||||||
|
string runId,
|
||||||
|
IncidentEscalationLevel newLevel,
|
||||||
|
string? reason = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var current = await _store.GetAsync(runId, cancellationToken);
|
||||||
|
|
||||||
|
if (current is null || !current.Active)
|
||||||
|
{
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: false,
|
||||||
|
Status: PackRunIncidentModeStatus.Inactive(),
|
||||||
|
Error: "Incident mode is not active for this run");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newLevel <= current.Level)
|
||||||
|
{
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: false,
|
||||||
|
Status: current,
|
||||||
|
Error: $"Cannot escalate to {newLevel} - current level is {current.Level}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = GetSettingsForLevel(newLevel);
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
var escalated = current with
|
||||||
|
{
|
||||||
|
Level = newLevel,
|
||||||
|
ActivationReason = $"{current.ActivationReason} [Escalated: {reason ?? "Manual escalation"}]",
|
||||||
|
RetentionPolicy = settings.RetentionPolicy,
|
||||||
|
TelemetrySettings = settings.TelemetrySettings,
|
||||||
|
DebugCaptureSettings = settings.DebugCaptureSettings
|
||||||
|
};
|
||||||
|
|
||||||
|
await _store.StoreAsync(runId, escalated, cancellationToken);
|
||||||
|
|
||||||
|
// Emit timeline event
|
||||||
|
await EmitTimelineEventAsync(
|
||||||
|
"default",
|
||||||
|
runId,
|
||||||
|
PackRunIncidentEventTypes.IncidentModeEscalated,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["previousLevel"] = current.Level.ToString(),
|
||||||
|
["newLevel"] = newLevel.ToString(),
|
||||||
|
["reason"] = reason ?? "Manual escalation"
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Incident mode escalated for run {RunId} from {OldLevel} to {NewLevel}. Reason: {Reason}",
|
||||||
|
runId,
|
||||||
|
current.Level,
|
||||||
|
newLevel,
|
||||||
|
reason ?? "Manual escalation");
|
||||||
|
|
||||||
|
return new IncidentModeActivationResult(
|
||||||
|
Success: true,
|
||||||
|
Status: escalated,
|
||||||
|
Error: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IncidentModeSettings GetSettingsForLevel(IncidentEscalationLevel level) => level switch
|
||||||
|
{
|
||||||
|
IncidentEscalationLevel.None => new IncidentModeSettings(
|
||||||
|
level,
|
||||||
|
IncidentRetentionPolicy.Default(),
|
||||||
|
IncidentTelemetrySettings.Default(),
|
||||||
|
IncidentDebugCaptureSettings.Default()),
|
||||||
|
|
||||||
|
IncidentEscalationLevel.Low => new IncidentModeSettings(
|
||||||
|
level,
|
||||||
|
IncidentRetentionPolicy.Default() with { LogRetentionDays = 30 },
|
||||||
|
IncidentTelemetrySettings.Default() with
|
||||||
|
{
|
||||||
|
EnhancedTelemetryActive = true,
|
||||||
|
LogVerbosity = IncidentLogVerbosity.Verbose,
|
||||||
|
TraceSamplingRate = 0.5
|
||||||
|
},
|
||||||
|
IncidentDebugCaptureSettings.Default()),
|
||||||
|
|
||||||
|
IncidentEscalationLevel.Medium => new IncidentModeSettings(
|
||||||
|
level,
|
||||||
|
IncidentRetentionPolicy.Extended(),
|
||||||
|
IncidentTelemetrySettings.Enhanced(),
|
||||||
|
IncidentDebugCaptureSettings.Basic()),
|
||||||
|
|
||||||
|
IncidentEscalationLevel.High => new IncidentModeSettings(
|
||||||
|
level,
|
||||||
|
IncidentRetentionPolicy.Extended() with { LogRetentionDays = 180, ArtifactRetentionDays = 365 },
|
||||||
|
IncidentTelemetrySettings.Enhanced() with { LogVerbosity = IncidentLogVerbosity.Debug },
|
||||||
|
IncidentDebugCaptureSettings.Full()),
|
||||||
|
|
||||||
|
IncidentEscalationLevel.Critical => new IncidentModeSettings(
|
||||||
|
level,
|
||||||
|
IncidentRetentionPolicy.Maximum(),
|
||||||
|
IncidentTelemetrySettings.Maximum(),
|
||||||
|
IncidentDebugCaptureSettings.Full() with { MaxCaptureSizeMb = 1000 }),
|
||||||
|
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(level))
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task EmitTimelineEventAsync(
|
||||||
|
string tenantId,
|
||||||
|
string runId,
|
||||||
|
string eventType,
|
||||||
|
IReadOnlyDictionary<string, string> attributes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_timelineEmitter is null) return;
|
||||||
|
|
||||||
|
await _timelineEmitter.EmitAsync(
|
||||||
|
PackRunTimelineEvent.Create(
|
||||||
|
tenantId: tenantId,
|
||||||
|
eventType: eventType,
|
||||||
|
source: "taskrunner-incident-mode",
|
||||||
|
occurredAt: _timeProvider.GetUtcNow(),
|
||||||
|
runId: runId,
|
||||||
|
severity: PackRunEventSeverity.Warning,
|
||||||
|
attributes: attributes),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Incident mode timeline event types.
|
||||||
|
/// </summary>
|
||||||
|
public static class PackRunIncidentEventTypes
|
||||||
|
{
|
||||||
|
/// <summary>Incident mode activated.</summary>
|
||||||
|
public const string IncidentModeActivated = "pack.incident.activated";
|
||||||
|
|
||||||
|
/// <summary>Incident mode deactivated.</summary>
|
||||||
|
public const string IncidentModeDeactivated = "pack.incident.deactivated";
|
||||||
|
|
||||||
|
/// <summary>Incident mode escalated.</summary>
|
||||||
|
public const string IncidentModeEscalated = "pack.incident.escalated";
|
||||||
|
|
||||||
|
/// <summary>SLO breach detected.</summary>
|
||||||
|
public const string SloBreachDetected = "pack.incident.slo_breach";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory incident mode store for testing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemoryPackRunIncidentModeStore : IPackRunIncidentModeStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, PackRunIncidentModeStatus> _statuses = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StoreAsync(
|
||||||
|
string runId,
|
||||||
|
PackRunIncidentModeStatus status,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_statuses[runId] = status;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<PackRunIncidentModeStatus?> GetAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_statuses.TryGetValue(runId, out var status);
|
||||||
|
return Task.FromResult(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<string>> ListActiveRunsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var active = _statuses
|
||||||
|
.Where(kvp => kvp.Value.Active)
|
||||||
|
.Select(kvp => kvp.Key)
|
||||||
|
.ToList();
|
||||||
|
return Task.FromResult<IReadOnlyList<string>>(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task RemoveAsync(
|
||||||
|
string runId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_statuses.Remove(runId);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gets count of stored statuses.</summary>
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get { lock (_lock) { return _statuses.Count; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears all statuses.</summary>
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
lock (_lock) { _statuses.Clear(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Core.IncidentMode;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Incident mode status for a pack run.
|
||||||
|
/// Per TASKRUN-OBS-55-001.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PackRunIncidentModeStatus(
|
||||||
|
/// <summary>Whether incident mode is active.</summary>
|
||||||
|
bool Active,
|
||||||
|
|
||||||
|
/// <summary>Current escalation level.</summary>
|
||||||
|
IncidentEscalationLevel Level,
|
||||||
|
|
||||||
|
/// <summary>When incident mode was activated.</summary>
|
||||||
|
DateTimeOffset? ActivatedAt,
|
||||||
|
|
||||||
|
/// <summary>Reason for activation.</summary>
|
||||||
|
string? ActivationReason,
|
||||||
|
|
||||||
|
/// <summary>Source of activation (SLO breach, manual, etc.).</summary>
|
||||||
|
IncidentModeSource Source,
|
||||||
|
|
||||||
|
/// <summary>When incident mode will auto-deactivate (if set).</summary>
|
||||||
|
DateTimeOffset? ExpiresAt,
|
||||||
|
|
||||||
|
/// <summary>Current retention policy in effect.</summary>
|
||||||
|
IncidentRetentionPolicy RetentionPolicy,
|
||||||
|
|
||||||
|
/// <summary>Active telemetry escalation settings.</summary>
|
||||||
|
IncidentTelemetrySettings TelemetrySettings,
|
||||||
|
|
||||||
|
/// <summary>Debug artifact capture settings.</summary>
|
||||||
|
IncidentDebugCaptureSettings DebugCaptureSettings)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a default inactive status.
|
||||||
|
/// </summary>
|
||||||
|
public static PackRunIncidentModeStatus Inactive() => new(
|
||||||
|
Active: false,
|
||||||
|
Level: IncidentEscalationLevel.None,
|
||||||
|
ActivatedAt: null,
|
||||||
|
ActivationReason: null,
|
||||||
|
Source: IncidentModeSource.None,
|
||||||
|
ExpiresAt: null,
|
||||||
|
RetentionPolicy: IncidentRetentionPolicy.Default(),
|
||||||
|
TelemetrySettings: IncidentTelemetrySettings.Default(),
|
||||||
|
DebugCaptureSettings: IncidentDebugCaptureSettings.Default());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes to JSON.
|
||||||
|
/// </summary>
|
||||||
|
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Incident escalation levels.
|
||||||
|
/// </summary>
|
||||||
|
public enum IncidentEscalationLevel
|
||||||
|
{
|
||||||
|
/// <summary>No incident mode.</summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>Low severity - enhanced logging.</summary>
|
||||||
|
Low = 1,
|
||||||
|
|
||||||
|
/// <summary>Medium severity - debug capture enabled.</summary>
|
||||||
|
Medium = 2,
|
||||||
|
|
||||||
|
/// <summary>High severity - full debug + extended retention.</summary>
|
||||||
|
High = 3,
|
||||||
|
|
||||||
|
/// <summary>Critical - maximum telemetry + indefinite retention.</summary>
|
||||||
|
Critical = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source of incident mode activation.
|
||||||
|
/// </summary>
|
||||||
|
public enum IncidentModeSource
|
||||||
|
{
|
||||||
|
/// <summary>No incident mode.</summary>
|
||||||
|
None,
|
||||||
|
|
||||||
|
/// <summary>Activated manually by operator.</summary>
|
||||||
|
Manual,
|
||||||
|
|
||||||
|
/// <summary>Activated by SLO breach webhook.</summary>
|
||||||
|
SloBreach,
|
||||||
|
|
||||||
|
/// <summary>Activated by error rate threshold.</summary>
|
||||||
|
ErrorRate,
|
||||||
|
|
||||||
|
/// <summary>Activated by policy evaluation.</summary>
|
||||||
|
PolicyTrigger,
|
||||||
|
|
||||||
|
/// <summary>Activated by external system.</summary>
|
||||||
|
External
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retention policy during incident mode.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IncidentRetentionPolicy(
|
||||||
|
/// <summary>Whether extended retention is active.</summary>
|
||||||
|
bool ExtendedRetentionActive,
|
||||||
|
|
||||||
|
/// <summary>Log retention in days.</summary>
|
||||||
|
int LogRetentionDays,
|
||||||
|
|
||||||
|
/// <summary>Artifact retention in days.</summary>
|
||||||
|
int ArtifactRetentionDays,
|
||||||
|
|
||||||
|
/// <summary>Debug capture retention in days.</summary>
|
||||||
|
int DebugCaptureRetentionDays,
|
||||||
|
|
||||||
|
/// <summary>Trace retention in days.</summary>
|
||||||
|
int TraceRetentionDays)
|
||||||
|
{
|
||||||
|
/// <summary>Default retention policy.</summary>
|
||||||
|
public static IncidentRetentionPolicy Default() => new(
|
||||||
|
ExtendedRetentionActive: false,
|
||||||
|
LogRetentionDays: 7,
|
||||||
|
ArtifactRetentionDays: 30,
|
||||||
|
DebugCaptureRetentionDays: 3,
|
||||||
|
TraceRetentionDays: 7);
|
||||||
|
|
||||||
|
/// <summary>Extended retention for incident mode.</summary>
|
||||||
|
public static IncidentRetentionPolicy Extended() => new(
|
||||||
|
ExtendedRetentionActive: true,
|
||||||
|
LogRetentionDays: 90,
|
||||||
|
ArtifactRetentionDays: 180,
|
||||||
|
DebugCaptureRetentionDays: 30,
|
||||||
|
TraceRetentionDays: 90);
|
||||||
|
|
||||||
|
/// <summary>Maximum retention for critical incidents.</summary>
|
||||||
|
public static IncidentRetentionPolicy Maximum() => new(
|
||||||
|
ExtendedRetentionActive: true,
|
||||||
|
LogRetentionDays: 365,
|
||||||
|
ArtifactRetentionDays: 365,
|
||||||
|
DebugCaptureRetentionDays: 90,
|
||||||
|
TraceRetentionDays: 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telemetry settings during incident mode.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IncidentTelemetrySettings(
|
||||||
|
/// <summary>Whether enhanced telemetry is active.</summary>
|
||||||
|
bool EnhancedTelemetryActive,
|
||||||
|
|
||||||
|
/// <summary>Log verbosity level.</summary>
|
||||||
|
IncidentLogVerbosity LogVerbosity,
|
||||||
|
|
||||||
|
/// <summary>Trace sampling rate (0.0 to 1.0).</summary>
|
||||||
|
double TraceSamplingRate,
|
||||||
|
|
||||||
|
/// <summary>Whether to capture environment variables.</summary>
|
||||||
|
bool CaptureEnvironment,
|
||||||
|
|
||||||
|
/// <summary>Whether to capture step inputs/outputs.</summary>
|
||||||
|
bool CaptureStepIo,
|
||||||
|
|
||||||
|
/// <summary>Whether to capture network calls.</summary>
|
||||||
|
bool CaptureNetworkCalls,
|
||||||
|
|
||||||
|
/// <summary>Maximum trace spans per step.</summary>
|
||||||
|
int MaxTraceSpansPerStep)
|
||||||
|
{
|
||||||
|
/// <summary>Default telemetry settings.</summary>
|
||||||
|
public static IncidentTelemetrySettings Default() => new(
|
||||||
|
EnhancedTelemetryActive: false,
|
||||||
|
LogVerbosity: IncidentLogVerbosity.Normal,
|
||||||
|
TraceSamplingRate: 0.1,
|
||||||
|
CaptureEnvironment: false,
|
||||||
|
CaptureStepIo: false,
|
||||||
|
CaptureNetworkCalls: false,
|
||||||
|
MaxTraceSpansPerStep: 100);
|
||||||
|
|
||||||
|
/// <summary>Enhanced telemetry for incident mode.</summary>
|
||||||
|
public static IncidentTelemetrySettings Enhanced() => new(
|
||||||
|
EnhancedTelemetryActive: true,
|
||||||
|
LogVerbosity: IncidentLogVerbosity.Verbose,
|
||||||
|
TraceSamplingRate: 1.0,
|
||||||
|
CaptureEnvironment: true,
|
||||||
|
CaptureStepIo: true,
|
||||||
|
CaptureNetworkCalls: true,
|
||||||
|
MaxTraceSpansPerStep: 1000);
|
||||||
|
|
||||||
|
/// <summary>Maximum telemetry for debugging.</summary>
|
||||||
|
public static IncidentTelemetrySettings Maximum() => new(
|
||||||
|
EnhancedTelemetryActive: true,
|
||||||
|
LogVerbosity: IncidentLogVerbosity.Debug,
|
||||||
|
TraceSamplingRate: 1.0,
|
||||||
|
CaptureEnvironment: true,
|
||||||
|
CaptureStepIo: true,
|
||||||
|
CaptureNetworkCalls: true,
|
||||||
|
MaxTraceSpansPerStep: 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log verbosity levels for incident mode.
|
||||||
|
/// </summary>
|
||||||
|
public enum IncidentLogVerbosity
|
||||||
|
{
|
||||||
|
/// <summary>Minimal logging (errors only).</summary>
|
||||||
|
Minimal,
|
||||||
|
|
||||||
|
/// <summary>Normal logging.</summary>
|
||||||
|
Normal,
|
||||||
|
|
||||||
|
/// <summary>Verbose logging.</summary>
|
||||||
|
Verbose,
|
||||||
|
|
||||||
|
/// <summary>Debug logging (maximum detail).</summary>
|
||||||
|
Debug
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Debug artifact capture settings.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IncidentDebugCaptureSettings(
|
||||||
|
/// <summary>Whether debug capture is active.</summary>
|
||||||
|
bool CaptureActive,
|
||||||
|
|
||||||
|
/// <summary>Whether to capture heap dumps.</summary>
|
||||||
|
bool CaptureHeapDumps,
|
||||||
|
|
||||||
|
/// <summary>Whether to capture thread dumps.</summary>
|
||||||
|
bool CaptureThreadDumps,
|
||||||
|
|
||||||
|
/// <summary>Whether to capture profiling data.</summary>
|
||||||
|
bool CaptureProfilingData,
|
||||||
|
|
||||||
|
/// <summary>Whether to capture system metrics.</summary>
|
||||||
|
bool CaptureSystemMetrics,
|
||||||
|
|
||||||
|
/// <summary>Maximum capture size in MB.</summary>
|
||||||
|
int MaxCaptureSizeMb,
|
||||||
|
|
||||||
|
/// <summary>Capture interval in seconds.</summary>
|
||||||
|
int CaptureIntervalSeconds)
|
||||||
|
{
|
||||||
|
/// <summary>Default capture settings (disabled).</summary>
|
||||||
|
public static IncidentDebugCaptureSettings Default() => new(
|
||||||
|
CaptureActive: false,
|
||||||
|
CaptureHeapDumps: false,
|
||||||
|
CaptureThreadDumps: false,
|
||||||
|
CaptureProfilingData: false,
|
||||||
|
CaptureSystemMetrics: false,
|
||||||
|
MaxCaptureSizeMb: 0,
|
||||||
|
CaptureIntervalSeconds: 0);
|
||||||
|
|
||||||
|
/// <summary>Basic debug capture.</summary>
|
||||||
|
public static IncidentDebugCaptureSettings Basic() => new(
|
||||||
|
CaptureActive: true,
|
||||||
|
CaptureHeapDumps: false,
|
||||||
|
CaptureThreadDumps: true,
|
||||||
|
CaptureProfilingData: false,
|
||||||
|
CaptureSystemMetrics: true,
|
||||||
|
MaxCaptureSizeMb: 100,
|
||||||
|
CaptureIntervalSeconds: 60);
|
||||||
|
|
||||||
|
/// <summary>Full debug capture.</summary>
|
||||||
|
public static IncidentDebugCaptureSettings Full() => new(
|
||||||
|
CaptureActive: true,
|
||||||
|
CaptureHeapDumps: true,
|
||||||
|
CaptureThreadDumps: true,
|
||||||
|
CaptureProfilingData: true,
|
||||||
|
CaptureSystemMetrics: true,
|
||||||
|
MaxCaptureSizeMb: 500,
|
||||||
|
CaptureIntervalSeconds: 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SLO breach notification payload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SloBreachNotification(
|
||||||
|
/// <summary>Breach identifier.</summary>
|
||||||
|
[property: JsonPropertyName("breachId")]
|
||||||
|
string BreachId,
|
||||||
|
|
||||||
|
/// <summary>SLO that was breached.</summary>
|
||||||
|
[property: JsonPropertyName("sloName")]
|
||||||
|
string SloName,
|
||||||
|
|
||||||
|
/// <summary>Breach severity.</summary>
|
||||||
|
[property: JsonPropertyName("severity")]
|
||||||
|
string Severity,
|
||||||
|
|
||||||
|
/// <summary>When the breach occurred.</summary>
|
||||||
|
[property: JsonPropertyName("occurredAt")]
|
||||||
|
DateTimeOffset OccurredAt,
|
||||||
|
|
||||||
|
/// <summary>Current metric value.</summary>
|
||||||
|
[property: JsonPropertyName("currentValue")]
|
||||||
|
double CurrentValue,
|
||||||
|
|
||||||
|
/// <summary>Threshold that was breached.</summary>
|
||||||
|
[property: JsonPropertyName("threshold")]
|
||||||
|
double Threshold,
|
||||||
|
|
||||||
|
/// <summary>Target metric value.</summary>
|
||||||
|
[property: JsonPropertyName("target")]
|
||||||
|
double Target,
|
||||||
|
|
||||||
|
/// <summary>Affected resource (run ID, step ID, etc.).</summary>
|
||||||
|
[property: JsonPropertyName("resourceId")]
|
||||||
|
string? ResourceId,
|
||||||
|
|
||||||
|
/// <summary>Affected tenant.</summary>
|
||||||
|
[property: JsonPropertyName("tenantId")]
|
||||||
|
string? TenantId,
|
||||||
|
|
||||||
|
/// <summary>Additional context.</summary>
|
||||||
|
[property: JsonPropertyName("context")]
|
||||||
|
IReadOnlyDictionary<string, string>? Context);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request to activate incident mode.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IncidentModeActivationRequest(
|
||||||
|
/// <summary>Run ID to activate incident mode for.</summary>
|
||||||
|
string RunId,
|
||||||
|
|
||||||
|
/// <summary>Tenant ID.</summary>
|
||||||
|
string TenantId,
|
||||||
|
|
||||||
|
/// <summary>Escalation level to activate.</summary>
|
||||||
|
IncidentEscalationLevel Level,
|
||||||
|
|
||||||
|
/// <summary>Activation source.</summary>
|
||||||
|
IncidentModeSource Source,
|
||||||
|
|
||||||
|
/// <summary>Reason for activation.</summary>
|
||||||
|
string Reason,
|
||||||
|
|
||||||
|
/// <summary>Duration in minutes (null for indefinite).</summary>
|
||||||
|
int? DurationMinutes,
|
||||||
|
|
||||||
|
/// <summary>Operator or system that requested activation.</summary>
|
||||||
|
string? RequestedBy);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of incident mode activation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record IncidentModeActivationResult(
|
||||||
|
/// <summary>Whether activation succeeded.</summary>
|
||||||
|
bool Success,
|
||||||
|
|
||||||
|
/// <summary>Current incident mode status.</summary>
|
||||||
|
PackRunIncidentModeStatus Status,
|
||||||
|
|
||||||
|
/// <summary>Error message if activation failed.</summary>
|
||||||
|
string? Error);
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using StellaOps.TaskRunner.Core.Events;
|
||||||
|
using StellaOps.TaskRunner.Core.IncidentMode;
|
||||||
|
|
||||||
|
namespace StellaOps.TaskRunner.Tests;
|
||||||
|
|
||||||
|
public sealed class PackRunIncidentModeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ActivateAsync_ActivatesIncidentModeSuccessfully()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var request = new IncidentModeActivationRequest(
|
||||||
|
RunId: "run-001",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Level: IncidentEscalationLevel.Medium,
|
||||||
|
Source: IncidentModeSource.Manual,
|
||||||
|
Reason: "Debugging production issue",
|
||||||
|
DurationMinutes: 60,
|
||||||
|
RequestedBy: "admin@example.com");
|
||||||
|
|
||||||
|
var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.True(result.Status.Active);
|
||||||
|
Assert.Equal(IncidentEscalationLevel.Medium, result.Status.Level);
|
||||||
|
Assert.Equal(IncidentModeSource.Manual, result.Status.Source);
|
||||||
|
Assert.NotNull(result.Status.ActivatedAt);
|
||||||
|
Assert.NotNull(result.Status.ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ActivateAsync_WithoutDuration_CreatesIndefiniteIncidentMode()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var request = new IncidentModeActivationRequest(
|
||||||
|
RunId: "run-002",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Level: IncidentEscalationLevel.High,
|
||||||
|
Source: IncidentModeSource.Manual,
|
||||||
|
Reason: "Critical investigation",
|
||||||
|
DurationMinutes: null,
|
||||||
|
RequestedBy: null);
|
||||||
|
|
||||||
|
var result = await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Null(result.Status.ExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ActivateAsync_EmitsTimelineEvent()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var timelineSink = new InMemoryPackRunTimelineEventSink();
|
||||||
|
var emitter = new PackRunTimelineEventEmitter(
|
||||||
|
timelineSink,
|
||||||
|
TimeProvider.System,
|
||||||
|
NullLogger<PackRunTimelineEventEmitter>.Instance);
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance,
|
||||||
|
null,
|
||||||
|
emitter);
|
||||||
|
|
||||||
|
var request = new IncidentModeActivationRequest(
|
||||||
|
RunId: "run-003",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Level: IncidentEscalationLevel.Low,
|
||||||
|
Source: IncidentModeSource.Manual,
|
||||||
|
Reason: "Test",
|
||||||
|
DurationMinutes: 30,
|
||||||
|
RequestedBy: null);
|
||||||
|
|
||||||
|
await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(1, timelineSink.Count);
|
||||||
|
var evt = timelineSink.GetEvents()[0];
|
||||||
|
Assert.Equal(PackRunIncidentEventTypes.IncidentModeActivated, evt.EventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_DeactivatesIncidentMode()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
// First activate
|
||||||
|
var activateRequest = new IncidentModeActivationRequest(
|
||||||
|
RunId: "run-004",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Level: IncidentEscalationLevel.Medium,
|
||||||
|
Source: IncidentModeSource.Manual,
|
||||||
|
Reason: "Test",
|
||||||
|
DurationMinutes: null,
|
||||||
|
RequestedBy: null);
|
||||||
|
|
||||||
|
await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Then deactivate
|
||||||
|
var result = await service.DeactivateAsync("run-004", "Issue resolved", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.False(result.Status.Active);
|
||||||
|
|
||||||
|
var status = await service.GetStatusAsync("run-004", TestContext.Current.CancellationToken);
|
||||||
|
Assert.False(status.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_ReturnsInactiveForUnknownRun()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var status = await service.GetStatusAsync("unknown-run", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(status.Active);
|
||||||
|
Assert.Equal(IncidentEscalationLevel.None, status.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_AutoDeactivatesExpiredIncidentMode()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var fakeTime = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance,
|
||||||
|
fakeTime);
|
||||||
|
|
||||||
|
var request = new IncidentModeActivationRequest(
|
||||||
|
RunId: "run-005",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Level: IncidentEscalationLevel.Medium,
|
||||||
|
Source: IncidentModeSource.Manual,
|
||||||
|
Reason: "Test",
|
||||||
|
DurationMinutes: 30,
|
||||||
|
RequestedBy: null);
|
||||||
|
|
||||||
|
await service.ActivateAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Advance time past expiration
|
||||||
|
fakeTime.Advance(TimeSpan.FromMinutes(31));
|
||||||
|
|
||||||
|
var status = await service.GetStatusAsync("run-005", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(status.Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleSloBreachAsync_ActivatesIncidentModeFromBreach()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var breach = new SloBreachNotification(
|
||||||
|
BreachId: "breach-001",
|
||||||
|
SloName: "error_rate_5m",
|
||||||
|
Severity: "HIGH",
|
||||||
|
OccurredAt: DateTimeOffset.UtcNow,
|
||||||
|
CurrentValue: 15.5,
|
||||||
|
Threshold: 5.0,
|
||||||
|
Target: 1.0,
|
||||||
|
ResourceId: "run-006",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Context: new Dictionary<string, string> { ["step"] = "scan" });
|
||||||
|
|
||||||
|
var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.True(result.Status.Active);
|
||||||
|
Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
|
||||||
|
Assert.Equal(IncidentModeSource.SloBreach, result.Status.Source);
|
||||||
|
Assert.Contains("error_rate_5m", result.Status.ActivationReason!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleSloBreachAsync_MapsSeverityToLevel()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var severityToLevel = new Dictionary<string, IncidentEscalationLevel>
|
||||||
|
{
|
||||||
|
["CRITICAL"] = IncidentEscalationLevel.Critical,
|
||||||
|
["HIGH"] = IncidentEscalationLevel.High,
|
||||||
|
["MEDIUM"] = IncidentEscalationLevel.Medium,
|
||||||
|
["LOW"] = IncidentEscalationLevel.Low
|
||||||
|
};
|
||||||
|
|
||||||
|
var runIndex = 0;
|
||||||
|
foreach (var (severity, expectedLevel) in severityToLevel)
|
||||||
|
{
|
||||||
|
var breach = new SloBreachNotification(
|
||||||
|
BreachId: $"breach-{runIndex}",
|
||||||
|
SloName: "test_slo",
|
||||||
|
Severity: severity,
|
||||||
|
OccurredAt: DateTimeOffset.UtcNow,
|
||||||
|
CurrentValue: 10.0,
|
||||||
|
Threshold: 5.0,
|
||||||
|
Target: 1.0,
|
||||||
|
ResourceId: $"run-severity-{runIndex++}",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Context: null);
|
||||||
|
|
||||||
|
var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(expectedLevel, result.Status.Level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleSloBreachAsync_ReturnsErrorForMissingResourceId()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var breach = new SloBreachNotification(
|
||||||
|
BreachId: "breach-no-resource",
|
||||||
|
SloName: "test_slo",
|
||||||
|
Severity: "HIGH",
|
||||||
|
OccurredAt: DateTimeOffset.UtcNow,
|
||||||
|
CurrentValue: 10.0,
|
||||||
|
Threshold: 5.0,
|
||||||
|
Target: 1.0,
|
||||||
|
ResourceId: null,
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Context: null);
|
||||||
|
|
||||||
|
var result = await service.HandleSloBreachAsync(breach, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("No resource ID", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EscalateAsync_IncreasesEscalationLevel()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
// First activate at Low level
|
||||||
|
var activateRequest = new IncidentModeActivationRequest(
|
||||||
|
RunId: "run-escalate",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Level: IncidentEscalationLevel.Low,
|
||||||
|
Source: IncidentModeSource.Manual,
|
||||||
|
Reason: "Initial activation",
|
||||||
|
DurationMinutes: null,
|
||||||
|
RequestedBy: null);
|
||||||
|
|
||||||
|
await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Escalate to High
|
||||||
|
var result = await service.EscalateAsync(
|
||||||
|
"run-escalate",
|
||||||
|
IncidentEscalationLevel.High,
|
||||||
|
"Issue is more severe than expected",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(IncidentEscalationLevel.High, result.Status.Level);
|
||||||
|
Assert.Contains("Escalated", result.Status.ActivationReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EscalateAsync_FailsWhenNotInIncidentMode()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var result = await service.EscalateAsync(
|
||||||
|
"unknown-run",
|
||||||
|
IncidentEscalationLevel.High,
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("not active", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EscalateAsync_FailsWhenNewLevelIsLowerOrEqual()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
var activateRequest = new IncidentModeActivationRequest(
|
||||||
|
RunId: "run-no-deescalate",
|
||||||
|
TenantId: "tenant-1",
|
||||||
|
Level: IncidentEscalationLevel.High,
|
||||||
|
Source: IncidentModeSource.Manual,
|
||||||
|
Reason: "Test",
|
||||||
|
DurationMinutes: null,
|
||||||
|
RequestedBy: null);
|
||||||
|
|
||||||
|
await service.ActivateAsync(activateRequest, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var result = await service.EscalateAsync(
|
||||||
|
"run-no-deescalate",
|
||||||
|
IncidentEscalationLevel.Medium, // Lower than High
|
||||||
|
null,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("Cannot escalate", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSettingsForLevel_ReturnsCorrectSettings()
|
||||||
|
{
|
||||||
|
var store = new InMemoryPackRunIncidentModeStore();
|
||||||
|
var service = new PackRunIncidentModeService(
|
||||||
|
store,
|
||||||
|
NullLogger<PackRunIncidentModeService>.Instance);
|
||||||
|
|
||||||
|
// Test None level
|
||||||
|
var noneSettings = service.GetSettingsForLevel(IncidentEscalationLevel.None);
|
||||||
|
Assert.False(noneSettings.TelemetrySettings.EnhancedTelemetryActive);
|
||||||
|
Assert.False(noneSettings.DebugCaptureSettings.CaptureActive);
|
||||||
|
|
||||||
|
// Test Critical level
|
||||||
|
var criticalSettings = service.GetSettingsForLevel(IncidentEscalationLevel.Critical);
|
||||||
|
Assert.True(criticalSettings.TelemetrySettings.EnhancedTelemetryActive);
|
||||||
|
Assert.Equal(IncidentLogVerbosity.Debug, criticalSettings.TelemetrySettings.LogVerbosity);
|
||||||
|
Assert.Equal(1.0, criticalSettings.TelemetrySettings.TraceSamplingRate);
|
||||||
|
Assert.True(criticalSettings.DebugCaptureSettings.CaptureActive);
|
||||||
|
Assert.True(criticalSettings.DebugCaptureSettings.CaptureHeapDumps);
|
||||||
|
Assert.Equal(365, criticalSettings.RetentionPolicy.LogRetentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackRunIncidentModeStatus_Inactive_ReturnsDefaultValues()
|
||||||
|
{
|
||||||
|
var inactive = PackRunIncidentModeStatus.Inactive();
|
||||||
|
|
||||||
|
Assert.False(inactive.Active);
|
||||||
|
Assert.Equal(IncidentEscalationLevel.None, inactive.Level);
|
||||||
|
Assert.Null(inactive.ActivatedAt);
|
||||||
|
Assert.Null(inactive.ActivationReason);
|
||||||
|
Assert.Equal(IncidentModeSource.None, inactive.Source);
|
||||||
|
Assert.False(inactive.RetentionPolicy.ExtendedRetentionActive);
|
||||||
|
Assert.False(inactive.TelemetrySettings.EnhancedTelemetryActive);
|
||||||
|
Assert.False(inactive.DebugCaptureSettings.CaptureActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IncidentRetentionPolicy_Extended_HasLongerRetention()
|
||||||
|
{
|
||||||
|
var defaultPolicy = IncidentRetentionPolicy.Default();
|
||||||
|
var extendedPolicy = IncidentRetentionPolicy.Extended();
|
||||||
|
|
||||||
|
Assert.True(extendedPolicy.ExtendedRetentionActive);
|
||||||
|
Assert.True(extendedPolicy.LogRetentionDays > defaultPolicy.LogRetentionDays);
|
||||||
|
Assert.True(extendedPolicy.ArtifactRetentionDays > defaultPolicy.ArtifactRetentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IncidentTelemetrySettings_Enhanced_HasHigherSampling()
|
||||||
|
{
|
||||||
|
var defaultSettings = IncidentTelemetrySettings.Default();
|
||||||
|
var enhancedSettings = IncidentTelemetrySettings.Enhanced();
|
||||||
|
|
||||||
|
Assert.True(enhancedSettings.EnhancedTelemetryActive);
|
||||||
|
Assert.True(enhancedSettings.TraceSamplingRate > defaultSettings.TraceSamplingRate);
|
||||||
|
Assert.True(enhancedSettings.CaptureEnvironment);
|
||||||
|
Assert.True(enhancedSettings.CaptureStepIo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ using StellaOps.AirGap.Policy;
|
|||||||
using StellaOps.TaskRunner.Core.AirGap;
|
using StellaOps.TaskRunner.Core.AirGap;
|
||||||
using StellaOps.TaskRunner.Core.Attestation;
|
using StellaOps.TaskRunner.Core.Attestation;
|
||||||
using StellaOps.TaskRunner.Core.Configuration;
|
using StellaOps.TaskRunner.Core.Configuration;
|
||||||
|
using StellaOps.TaskRunner.Core.IncidentMode;
|
||||||
using StellaOps.TaskRunner.Core.Events;
|
using StellaOps.TaskRunner.Core.Events;
|
||||||
using StellaOps.TaskRunner.Core.Execution;
|
using StellaOps.TaskRunner.Core.Execution;
|
||||||
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
using StellaOps.TaskRunner.Core.Execution.Simulation;
|
||||||
@@ -127,6 +128,10 @@ builder.Services.AddSingleton<IPackRunAttestationStore, InMemoryPackRunAttestati
|
|||||||
builder.Services.AddSingleton<IPackRunAttestationSigner, StubPackRunAttestationSigner>();
|
builder.Services.AddSingleton<IPackRunAttestationSigner, StubPackRunAttestationSigner>();
|
||||||
builder.Services.AddSingleton<IPackRunAttestationService, PackRunAttestationService>();
|
builder.Services.AddSingleton<IPackRunAttestationService, PackRunAttestationService>();
|
||||||
|
|
||||||
|
// Pack run incident mode (TASKRUN-OBS-55-001)
|
||||||
|
builder.Services.AddSingleton<IPackRunIncidentModeStore, InMemoryPackRunIncidentModeStore>();
|
||||||
|
builder.Services.AddSingleton<IPackRunIncidentModeService, PackRunIncidentModeService>();
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -230,6 +235,22 @@ app.MapGet("/api/attestations/{attestationId}/envelope", HandleGetAttestationEnv
|
|||||||
app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestation");
|
app.MapPost("/v1/task-runner/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestation");
|
||||||
app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestationApi");
|
app.MapPost("/api/attestations/{attestationId}/verify", HandleVerifyAttestation).WithName("VerifyAttestationApi");
|
||||||
|
|
||||||
|
// Incident mode endpoints (TASKRUN-OBS-55-001)
|
||||||
|
app.MapGet("/v1/task-runner/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatus");
|
||||||
|
app.MapGet("/api/runs/{runId}/incident-mode", HandleGetIncidentModeStatus).WithName("GetIncidentModeStatusApi");
|
||||||
|
|
||||||
|
app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentMode");
|
||||||
|
app.MapPost("/api/runs/{runId}/incident-mode/activate", HandleActivateIncidentMode).WithName("ActivateIncidentModeApi");
|
||||||
|
|
||||||
|
app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentMode");
|
||||||
|
app.MapPost("/api/runs/{runId}/incident-mode/deactivate", HandleDeactivateIncidentMode).WithName("DeactivateIncidentModeApi");
|
||||||
|
|
||||||
|
app.MapPost("/v1/task-runner/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentMode");
|
||||||
|
app.MapPost("/api/runs/{runId}/incident-mode/escalate", HandleEscalateIncidentMode).WithName("EscalateIncidentModeApi");
|
||||||
|
|
||||||
|
app.MapPost("/v1/task-runner/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhook");
|
||||||
|
app.MapPost("/api/webhooks/slo-breach", HandleSloBreachWebhook).WithName("SloBreachWebhookApi");
|
||||||
|
|
||||||
app.MapGet("/.well-known/openapi", (HttpResponse response) =>
|
app.MapGet("/.well-known/openapi", (HttpResponse response) =>
|
||||||
{
|
{
|
||||||
var metadata = OpenApiMetadataFactory.Create("/openapi");
|
var metadata = OpenApiMetadataFactory.Create("/openapi");
|
||||||
@@ -681,6 +702,175 @@ async Task<IResult> HandleVerifyAttestation(
|
|||||||
}, statusCode: statusCode);
|
}, statusCode: statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Incident mode handlers (TASKRUN-OBS-55-001)
|
||||||
|
async Task<IResult> HandleGetIncidentModeStatus(
|
||||||
|
string runId,
|
||||||
|
IPackRunIncidentModeService incidentModeService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(runId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "runId is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = await incidentModeService.GetStatusAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
runId,
|
||||||
|
active = status.Active,
|
||||||
|
level = status.Level.ToString().ToLowerInvariant(),
|
||||||
|
activatedAt = status.ActivatedAt?.ToString("O"),
|
||||||
|
activationReason = status.ActivationReason,
|
||||||
|
source = status.Source.ToString().ToLowerInvariant(),
|
||||||
|
expiresAt = status.ExpiresAt?.ToString("O"),
|
||||||
|
retentionPolicy = new
|
||||||
|
{
|
||||||
|
extendedRetentionActive = status.RetentionPolicy.ExtendedRetentionActive,
|
||||||
|
logRetentionDays = status.RetentionPolicy.LogRetentionDays,
|
||||||
|
artifactRetentionDays = status.RetentionPolicy.ArtifactRetentionDays
|
||||||
|
},
|
||||||
|
telemetrySettings = new
|
||||||
|
{
|
||||||
|
enhancedTelemetryActive = status.TelemetrySettings.EnhancedTelemetryActive,
|
||||||
|
logVerbosity = status.TelemetrySettings.LogVerbosity.ToString().ToLowerInvariant(),
|
||||||
|
traceSamplingRate = status.TelemetrySettings.TraceSamplingRate
|
||||||
|
},
|
||||||
|
debugCaptureSettings = new
|
||||||
|
{
|
||||||
|
captureActive = status.DebugCaptureSettings.CaptureActive,
|
||||||
|
captureHeapDumps = status.DebugCaptureSettings.CaptureHeapDumps,
|
||||||
|
captureThreadDumps = status.DebugCaptureSettings.CaptureThreadDumps
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<IResult> HandleActivateIncidentMode(
|
||||||
|
string runId,
|
||||||
|
[FromBody] ActivateIncidentModeRequest? request,
|
||||||
|
[FromHeader(Name = "X-Tenant-ID")] string? tenantId,
|
||||||
|
IPackRunIncidentModeService incidentModeService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(runId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "runId is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var level = Enum.TryParse<IncidentEscalationLevel>(request?.Level, ignoreCase: true, out var parsedLevel)
|
||||||
|
? parsedLevel
|
||||||
|
: IncidentEscalationLevel.Medium;
|
||||||
|
|
||||||
|
var activationRequest = new IncidentModeActivationRequest(
|
||||||
|
RunId: runId,
|
||||||
|
TenantId: tenantId ?? "default",
|
||||||
|
Level: level,
|
||||||
|
Source: StellaOps.TaskRunner.Core.IncidentMode.IncidentModeSource.Manual,
|
||||||
|
Reason: request?.Reason ?? "Manual activation via API",
|
||||||
|
DurationMinutes: request?.DurationMinutes,
|
||||||
|
RequestedBy: request?.RequestedBy);
|
||||||
|
|
||||||
|
var result = await incidentModeService.ActivateAsync(activationRequest, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = result.Error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
active = result.Status.Active,
|
||||||
|
level = result.Status.Level.ToString().ToLowerInvariant(),
|
||||||
|
activatedAt = result.Status.ActivatedAt?.ToString("O"),
|
||||||
|
expiresAt = result.Status.ExpiresAt?.ToString("O")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<IResult> HandleDeactivateIncidentMode(
|
||||||
|
string runId,
|
||||||
|
[FromBody] DeactivateIncidentModeRequest? request,
|
||||||
|
IPackRunIncidentModeService incidentModeService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(runId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "runId is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await incidentModeService.DeactivateAsync(runId, request?.Reason, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
active = result.Status.Active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<IResult> HandleEscalateIncidentMode(
|
||||||
|
string runId,
|
||||||
|
[FromBody] EscalateIncidentModeRequest? request,
|
||||||
|
IPackRunIncidentModeService incidentModeService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(runId))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "runId is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request is null || string.IsNullOrWhiteSpace(request.Level))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Level is required for escalation." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse<IncidentEscalationLevel>(request.Level, ignoreCase: true, out var newLevel))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = $"Invalid escalation level: {request.Level}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await incidentModeService.EscalateAsync(runId, newLevel, request.Reason, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = result.Error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
level = result.Status.Level.ToString().ToLowerInvariant()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<IResult> HandleSloBreachWebhook(
|
||||||
|
[FromBody] SloBreachNotification notification,
|
||||||
|
IPackRunIncidentModeService incidentModeService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (notification is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Notification body is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await incidentModeService.HandleSloBreachAsync(notification, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = result.Error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
runId = notification.ResourceId,
|
||||||
|
level = result.Status.Level.ToString().ToLowerInvariant(),
|
||||||
|
activatedAt = result.Status.ActivatedAt?.ToString("O")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
|
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
|
||||||
@@ -712,6 +902,17 @@ internal sealed record VerifyAttestationRequest(
|
|||||||
|
|
||||||
internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary<string, string>? Digest);
|
internal sealed record VerifyAttestationSubject(string Name, IReadOnlyDictionary<string, string>? Digest);
|
||||||
|
|
||||||
|
// Incident mode API request models (TASKRUN-OBS-55-001)
|
||||||
|
internal sealed record ActivateIncidentModeRequest(
|
||||||
|
string? Level,
|
||||||
|
string? Reason,
|
||||||
|
int? DurationMinutes,
|
||||||
|
string? RequestedBy);
|
||||||
|
|
||||||
|
internal sealed record DeactivateIncidentModeRequest(string? Reason);
|
||||||
|
|
||||||
|
internal sealed record EscalateIncidentModeRequest(string Level, string? Reason);
|
||||||
|
|
||||||
internal sealed record SimulationResponse(
|
internal sealed record SimulationResponse(
|
||||||
string PlanHash,
|
string PlanHash,
|
||||||
FailurePolicyResponse FailurePolicy,
|
FailurePolicyResponse FailurePolicy,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ interface ChecklistItem {
|
|||||||
imports: [CommonModule, RouterLink],
|
imports: [CommonModule, RouterLink],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<section class="policy-editor" aria-busy="{{ loadingPack }}">
|
<section class="policy-editor" [attr.aria-busy]="loadingPack">
|
||||||
<header class="policy-editor__header">
|
<header class="policy-editor__header">
|
||||||
<div class="policy-editor__title">
|
<div class="policy-editor__title">
|
||||||
<p class="policy-editor__eyebrow">Policy Studio · Authoring</p>
|
<p class="policy-editor__eyebrow">Policy Studio · Authoring</p>
|
||||||
@@ -640,7 +640,12 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
ariaLabel: 'Policy DSL editor',
|
ariaLabel: 'Policy DSL editor',
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentDisposable = this.editor.onDidChangeModelContent(() => {
|
const editor = this.editor;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentDisposable = editor.onDidChangeModelContent(() => {
|
||||||
const value = this.model?.getValue() ?? '';
|
const value = this.model?.getValue() ?? '';
|
||||||
this.content$.next(value);
|
this.content$.next(value);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user