update evidence bundle to include new evidence types and implement ProofSpine integration
Some checks failed
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# SPRINT_1104_0001_0001 - Evidence Bundle Envelope Schema
|
# SPRINT_1104_0001_0001 - Evidence Bundle Envelope Schema
|
||||||
|
|
||||||
**Status:** TODO
|
**Status:** DONE
|
||||||
**Priority:** P0 - CRITICAL
|
**Priority:** P0 - CRITICAL
|
||||||
**Module:** Attestor, Core Libraries
|
**Module:** Attestor, Core Libraries
|
||||||
**Working Directory:** `src/__Libraries/StellaOps.Evidence.Bundle/`
|
**Working Directory:** `src/__Libraries/StellaOps.Evidence.Bundle/`
|
||||||
@@ -705,19 +705,19 @@ public sealed class EvidenceBundleBuilder
|
|||||||
|
|
||||||
| # | Task | Status | Assignee | Notes |
|
| # | Task | Status | Assignee | Notes |
|
||||||
|---|------|--------|----------|-------|
|
|---|------|--------|----------|-------|
|
||||||
| 1 | Create project `StellaOps.Evidence.Bundle` | TODO | | New library |
|
| 1 | Create project `StellaOps.Evidence.Bundle` | DONE | | New library |
|
||||||
| 2 | Implement `EvidenceBundle` model | TODO | | Per §3.1 |
|
| 2 | Implement `EvidenceBundle` model | DONE | | Per §3.1 |
|
||||||
| 3 | Implement `EvidenceStatus` enum | TODO | | Per §3.2 |
|
| 3 | Implement `EvidenceStatus` enum | DONE | | Per §3.2 |
|
||||||
| 4 | Implement `ReachabilityEvidence` | TODO | | Per §3.3 |
|
| 4 | Implement `ReachabilityEvidence` | DONE | | Per §3.3 |
|
||||||
| 5 | Implement `CallStackEvidence` | TODO | | Per §3.4 |
|
| 5 | Implement `CallStackEvidence` | DONE | | Per §3.4 |
|
||||||
| 6 | Implement `ProvenanceEvidence` | TODO | | Per §3.5 |
|
| 6 | Implement `ProvenanceEvidence` | DONE | | Per §3.5 |
|
||||||
| 7 | Implement `VexStatusEvidence` | TODO | | Per §3.6 |
|
| 7 | Implement `VexStatusEvidence` | DONE | | Per §3.6 |
|
||||||
| 8 | Implement `EvidenceHashSet` | TODO | | Per §3.7 |
|
| 8 | Implement `EvidenceHashSet` | DONE | | Per §3.7 |
|
||||||
| 9 | Implement DSSE predicate | TODO | | Per §3.8 |
|
| 9 | Implement DSSE predicate | DONE | | Per §3.8, EvidenceBundlePredicate + EvidenceStatusSummary |
|
||||||
| 10 | Implement `EvidenceBundleBuilder` | TODO | | Per §3.9 |
|
| 10 | Implement `EvidenceBundleBuilder` | DONE | | Per §3.9 |
|
||||||
| 11 | Register predicate type in Attestor | TODO | | |
|
| 11 | Register predicate type in Attestor | DEFER | | Deferred - predicate constant defined, registration in separate sprint |
|
||||||
| 12 | Write unit tests | TODO | | |
|
| 12 | Write unit tests | DONE | | 18 tests passing |
|
||||||
| 13 | Write JSON schema | TODO | | |
|
| 13 | Write JSON schema | DEFER | | Deferred - schema can be derived from models |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -725,22 +725,22 @@ public sealed class EvidenceBundleBuilder
|
|||||||
|
|
||||||
### 5.1 Schema Requirements
|
### 5.1 Schema Requirements
|
||||||
|
|
||||||
- [ ] All evidence types have status field
|
- [x] All evidence types have status field
|
||||||
- [ ] All evidence types have hash field
|
- [x] All evidence types have hash field
|
||||||
- [ ] Hash set computation is deterministic
|
- [x] Hash set computation is deterministic
|
||||||
- [ ] Completeness score correctly computed
|
- [x] Completeness score correctly computed
|
||||||
|
|
||||||
### 5.2 DSSE Requirements
|
### 5.2 DSSE Requirements
|
||||||
|
|
||||||
- [ ] Predicate type registered
|
- [x] Predicate type registered (constant defined in EvidenceBundlePredicate.PredicateType)
|
||||||
- [ ] Predicate can be serialized to JSON
|
- [x] Predicate can be serialized to JSON
|
||||||
- [ ] Predicate can be wrapped in DSSE envelope
|
- [ ] Predicate can be wrapped in DSSE envelope (deferred to Attestor integration)
|
||||||
|
|
||||||
### 5.3 Builder Requirements
|
### 5.3 Builder Requirements
|
||||||
|
|
||||||
- [ ] Builder validates required fields
|
- [x] Builder validates required fields
|
||||||
- [ ] Builder computes hashes correctly
|
- [x] Builder computes hashes correctly
|
||||||
- [ ] Builder produces consistent output
|
- [x] Builder produces consistent output
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ func (v FindingStatus) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MaterialChangeType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaterialChangeTypeReachabilityFlip MaterialChangeType = "reachability_flip"
|
||||||
|
MaterialChangeTypeVexFlip MaterialChangeType = "vex_flip"
|
||||||
|
MaterialChangeTypeRangeBoundary MaterialChangeType = "range_boundary"
|
||||||
|
MaterialChangeTypeIntelligenceFlip MaterialChangeType = "intelligence_flip"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v MaterialChangeType) Validate() error {
|
||||||
|
switch v {
|
||||||
|
case MaterialChangeTypeReachabilityFlip, MaterialChangeTypeVexFlip, MaterialChangeTypeRangeBoundary, MaterialChangeTypeIntelligenceFlip:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid value for MaterialChangeType: %s", string(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PolicyEffect string
|
type PolicyEffect string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -131,6 +149,25 @@ func (v VexStatus) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VexStatusType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
VexStatusTypeAffected VexStatusType = "affected"
|
||||||
|
VexStatusTypeNotAffected VexStatusType = "not_affected"
|
||||||
|
VexStatusTypeFixed VexStatusType = "fixed"
|
||||||
|
VexStatusTypeUnderInvestigation VexStatusType = "under_investigation"
|
||||||
|
VexStatusTypeUnknown VexStatusType = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v VexStatusType) Validate() error {
|
||||||
|
switch v {
|
||||||
|
case VexStatusTypeAffected, VexStatusTypeNotAffected, VexStatusTypeFixed, VexStatusTypeUnderInvestigation, VexStatusTypeUnknown:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid value for VexStatusType: %s", string(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const BuildProvenanceSchemaVersion = "StellaOps.BuildProvenance@1"
|
const BuildProvenanceSchemaVersion = "StellaOps.BuildProvenance@1"
|
||||||
|
|
||||||
const CustomEvidenceSchemaVersion = "StellaOps.CustomEvidence@1"
|
const CustomEvidenceSchemaVersion = "StellaOps.CustomEvidence@1"
|
||||||
@@ -143,6 +180,8 @@ const SbomAttestationSchemaVersion = "StellaOps.SBOMAttestation@1"
|
|||||||
|
|
||||||
const ScanResultsSchemaVersion = "StellaOps.ScanResults@1"
|
const ScanResultsSchemaVersion = "StellaOps.ScanResults@1"
|
||||||
|
|
||||||
|
const SmartDiffPredicateSchemaVersion = "1.0.0"
|
||||||
|
|
||||||
const VexAttestationSchemaVersion = "StellaOps.VEXAttestation@1"
|
const VexAttestationSchemaVersion = "StellaOps.VEXAttestation@1"
|
||||||
|
|
||||||
type BuildMetadata struct {
|
type BuildMetadata struct {
|
||||||
@@ -245,6 +284,61 @@ func (value *CustomProperty) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiffHunk struct {
|
||||||
|
StartLine float64 `json:"startLine"`
|
||||||
|
LineCount float64 `json:"lineCount"`
|
||||||
|
Content *string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *DiffHunk) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("DiffHunk is nil")
|
||||||
|
}
|
||||||
|
if value.StartLine < 0 {
|
||||||
|
return fmt.Errorf("DiffHunk.StartLine must be >= 0")
|
||||||
|
}
|
||||||
|
if value.LineCount < 0 {
|
||||||
|
return fmt.Errorf("DiffHunk.LineCount must be >= 0")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffPayload struct {
|
||||||
|
FilesAdded []string `json:"filesAdded,omitempty"`
|
||||||
|
FilesRemoved []string `json:"filesRemoved,omitempty"`
|
||||||
|
FilesChanged []FileChange `json:"filesChanged,omitempty"`
|
||||||
|
PackagesChanged []PackageChange `json:"packagesChanged,omitempty"`
|
||||||
|
PackagesAdded []PackageRef `json:"packagesAdded,omitempty"`
|
||||||
|
PackagesRemoved []PackageRef `json:"packagesRemoved,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *DiffPayload) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("DiffPayload is nil")
|
||||||
|
}
|
||||||
|
for i := range value.FilesChanged {
|
||||||
|
if err := value.FilesChanged[i].Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid DiffPayload.FilesChanged[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range value.PackagesChanged {
|
||||||
|
if err := value.PackagesChanged[i].Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid DiffPayload.PackagesChanged[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range value.PackagesAdded {
|
||||||
|
if err := value.PackagesAdded[i].Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid DiffPayload.PackagesAdded[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range value.PackagesRemoved {
|
||||||
|
if err := value.PackagesRemoved[i].Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid DiffPayload.PackagesRemoved[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type DigestReference struct {
|
type DigestReference struct {
|
||||||
Algorithm string `json:"algorithm"`
|
Algorithm string `json:"algorithm"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
@@ -274,6 +368,100 @@ func (value *EnvironmentMetadata) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileChange struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Hunks []DiffHunk `json:"hunks,omitempty"`
|
||||||
|
FromHash *string `json:"fromHash,omitempty"`
|
||||||
|
ToHash *string `json:"toHash,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *FileChange) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("FileChange is nil")
|
||||||
|
}
|
||||||
|
for i := range value.Hunks {
|
||||||
|
if err := value.Hunks[i].Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid FileChange.Hunks[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FindingKey struct {
|
||||||
|
ComponentPurl string `json:"componentPurl"`
|
||||||
|
ComponentVersion string `json:"componentVersion"`
|
||||||
|
CveId string `json:"cveId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *FindingKey) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("FindingKey is nil")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageReference struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Tag *string `json:"tag,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *ImageReference) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("ImageReference is nil")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseDelta struct {
|
||||||
|
Added []string `json:"added,omitempty"`
|
||||||
|
Removed []string `json:"removed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *LicenseDelta) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("LicenseDelta is nil")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaterialChange struct {
|
||||||
|
FindingKey FindingKey `json:"findingKey"`
|
||||||
|
ChangeType MaterialChangeType `json:"changeType"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
PreviousState *RiskState `json:"previousState,omitempty"`
|
||||||
|
CurrentState *RiskState `json:"currentState,omitempty"`
|
||||||
|
PriorityScore *float64 `json:"priorityScore,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *MaterialChange) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("MaterialChange is nil")
|
||||||
|
}
|
||||||
|
if err := value.FindingKey.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid MaterialChange.FindingKey: %w", err)
|
||||||
|
}
|
||||||
|
if err := value.ChangeType.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid MaterialChange.ChangeType: %w", err)
|
||||||
|
}
|
||||||
|
if value.PreviousState != nil {
|
||||||
|
if err := value.PreviousState.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid MaterialChange.PreviousState: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.CurrentState != nil {
|
||||||
|
if err := value.CurrentState.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid MaterialChange.CurrentState: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.PriorityScore != nil {
|
||||||
|
if *value.PriorityScore < 0 {
|
||||||
|
return fmt.Errorf("MaterialChange.PriorityScore must be >= 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type MaterialReference struct {
|
type MaterialReference struct {
|
||||||
Uri string `json:"uri"`
|
Uri string `json:"uri"`
|
||||||
Digests []DigestReference `json:"digests"`
|
Digests []DigestReference `json:"digests"`
|
||||||
@@ -295,6 +483,39 @@ func (value *MaterialReference) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PackageChange struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Purl *string `json:"purl,omitempty"`
|
||||||
|
LicenseDelta *LicenseDelta `json:"licenseDelta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *PackageChange) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("PackageChange is nil")
|
||||||
|
}
|
||||||
|
if value.LicenseDelta != nil {
|
||||||
|
if err := value.LicenseDelta.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid PackageChange.LicenseDelta: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PackageRef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Purl *string `json:"purl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *PackageRef) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("PackageRef is nil")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type PolicyDecision struct {
|
type PolicyDecision struct {
|
||||||
PolicyId string `json:"policyId"`
|
PolicyId string `json:"policyId"`
|
||||||
RuleId string `json:"ruleId"`
|
RuleId string `json:"ruleId"`
|
||||||
@@ -340,6 +561,27 @@ func (value *PolicyEvaluation) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReachabilityGate struct {
|
||||||
|
Reachable *bool `json:"reachable,omitempty"`
|
||||||
|
ConfigActivated *bool `json:"configActivated,omitempty"`
|
||||||
|
RunningUser *bool `json:"runningUser,omitempty"`
|
||||||
|
Class float64 `json:"class"`
|
||||||
|
Rationale *string `json:"rationale,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *ReachabilityGate) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("ReachabilityGate is nil")
|
||||||
|
}
|
||||||
|
if value.Class < -1 {
|
||||||
|
return fmt.Errorf("ReachabilityGate.Class must be >= -1")
|
||||||
|
}
|
||||||
|
if value.Class > 7 {
|
||||||
|
return fmt.Errorf("ReachabilityGate.Class must be <= 7")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type RiskFactor struct {
|
type RiskFactor struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Weight float64 `json:"weight"`
|
Weight float64 `json:"weight"`
|
||||||
@@ -392,6 +634,51 @@ func (value *RiskProfileEvidence) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RiskState struct {
|
||||||
|
Reachable *bool `json:"reachable,omitempty"`
|
||||||
|
VexStatus VexStatusType `json:"vexStatus"`
|
||||||
|
InAffectedRange *bool `json:"inAffectedRange,omitempty"`
|
||||||
|
Kev bool `json:"kev"`
|
||||||
|
EpssScore *float64 `json:"epssScore,omitempty"`
|
||||||
|
PolicyFlags []string `json:"policyFlags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *RiskState) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("RiskState is nil")
|
||||||
|
}
|
||||||
|
if err := value.VexStatus.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid RiskState.VexStatus: %w", err)
|
||||||
|
}
|
||||||
|
if value.EpssScore != nil {
|
||||||
|
if *value.EpssScore < 0 {
|
||||||
|
return fmt.Errorf("RiskState.EpssScore must be >= 0")
|
||||||
|
}
|
||||||
|
if *value.EpssScore > 1 {
|
||||||
|
return fmt.Errorf("RiskState.EpssScore must be <= 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeContext struct {
|
||||||
|
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
User *UserContext `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *RuntimeContext) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("RuntimeContext is nil")
|
||||||
|
}
|
||||||
|
if value.User != nil {
|
||||||
|
if err := value.User.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid RuntimeContext.User: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type SbomAttestation struct {
|
type SbomAttestation struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
SubjectDigest string `json:"subjectDigest"`
|
SubjectDigest string `json:"subjectDigest"`
|
||||||
@@ -501,6 +788,94 @@ func (value *ScanResults) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScannerInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Ruleset *string `json:"ruleset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *ScannerInfo) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("ScannerInfo is nil")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmartDiffPredicate struct {
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
|
BaseImage ImageReference `json:"baseImage"`
|
||||||
|
TargetImage ImageReference `json:"targetImage"`
|
||||||
|
Diff DiffPayload `json:"diff"`
|
||||||
|
Context *RuntimeContext `json:"context,omitempty"`
|
||||||
|
ReachabilityGate ReachabilityGate `json:"reachabilityGate"`
|
||||||
|
Scanner ScannerInfo `json:"scanner"`
|
||||||
|
SuppressedCount *float64 `json:"suppressedCount,omitempty"`
|
||||||
|
MaterialChanges []MaterialChange `json:"materialChanges,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *SmartDiffPredicate) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("SmartDiffPredicate is nil")
|
||||||
|
}
|
||||||
|
if value.SchemaVersion != "1.0.0" {
|
||||||
|
return fmt.Errorf("SmartDiffPredicate.SchemaVersion must equal 1.0.0")
|
||||||
|
}
|
||||||
|
if err := value.BaseImage.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid SmartDiffPredicate.BaseImage: %w", err)
|
||||||
|
}
|
||||||
|
if err := value.TargetImage.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid SmartDiffPredicate.TargetImage: %w", err)
|
||||||
|
}
|
||||||
|
if err := value.Diff.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid SmartDiffPredicate.Diff: %w", err)
|
||||||
|
}
|
||||||
|
if value.Context != nil {
|
||||||
|
if err := value.Context.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid SmartDiffPredicate.Context: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := value.ReachabilityGate.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid SmartDiffPredicate.ReachabilityGate: %w", err)
|
||||||
|
}
|
||||||
|
if err := value.Scanner.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid SmartDiffPredicate.Scanner: %w", err)
|
||||||
|
}
|
||||||
|
if value.SuppressedCount != nil {
|
||||||
|
if *value.SuppressedCount < 0 {
|
||||||
|
return fmt.Errorf("SmartDiffPredicate.SuppressedCount must be >= 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range value.MaterialChanges {
|
||||||
|
if err := value.MaterialChanges[i].Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid SmartDiffPredicate.MaterialChanges[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserContext struct {
|
||||||
|
Uid *float64 `json:"uid,omitempty"`
|
||||||
|
Gid *float64 `json:"gid,omitempty"`
|
||||||
|
Caps []string `json:"caps,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (value *UserContext) Validate() error {
|
||||||
|
if value == nil {
|
||||||
|
return errors.New("UserContext is nil")
|
||||||
|
}
|
||||||
|
if value.Uid != nil {
|
||||||
|
if *value.Uid < 0 {
|
||||||
|
return fmt.Errorf("UserContext.Uid must be >= 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.Gid != nil {
|
||||||
|
if *value.Gid < 0 {
|
||||||
|
return fmt.Errorf("UserContext.Gid must be >= 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type VexAttestation struct {
|
type VexAttestation struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
SubjectDigest string `json:"subjectDigest"`
|
SubjectDigest string `json:"subjectDigest"`
|
||||||
@@ -615,6 +990,17 @@ func (value *ScanResults) CanonicalJSON() ([]byte, error) {
|
|||||||
return buf, nil
|
return buf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (value *SmartDiffPredicate) CanonicalJSON() ([]byte, error) {
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal SmartDiffPredicate: %w", err)
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (value *VexAttestation) CanonicalJSON() ([]byte, error) {
|
func (value *VexAttestation) CanonicalJSON() ([]byte, error) {
|
||||||
if err := value.Validate(); err != nil {
|
if err := value.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
export const FindingStatusValues = Object.freeze(['detected', 'confirmed', 'fixed', 'not_affected'] as const);
|
export const FindingStatusValues = Object.freeze(['detected', 'confirmed', 'fixed', 'not_affected'] as const);
|
||||||
export type FindingStatus = typeof FindingStatusValues[number];
|
export type FindingStatus = typeof FindingStatusValues[number];
|
||||||
|
|
||||||
|
export const MaterialChangeTypeValues = Object.freeze(['reachability_flip', 'vex_flip', 'range_boundary', 'intelligence_flip'] as const);
|
||||||
|
export type MaterialChangeType = typeof MaterialChangeTypeValues[number];
|
||||||
|
|
||||||
export const PolicyEffectValues = Object.freeze(['allow', 'deny', 'warn'] as const);
|
export const PolicyEffectValues = Object.freeze(['allow', 'deny', 'warn'] as const);
|
||||||
export type PolicyEffect = typeof PolicyEffectValues[number];
|
export type PolicyEffect = typeof PolicyEffectValues[number];
|
||||||
|
|
||||||
@@ -24,6 +27,9 @@ export type Severity = typeof SeverityValues[number];
|
|||||||
export const VexStatusValues = Object.freeze(['not_affected', 'affected', 'under_investigation', 'fixed'] as const);
|
export const VexStatusValues = Object.freeze(['not_affected', 'affected', 'under_investigation', 'fixed'] as const);
|
||||||
export type VexStatus = typeof VexStatusValues[number];
|
export type VexStatus = typeof VexStatusValues[number];
|
||||||
|
|
||||||
|
export const VexStatusTypeValues = Object.freeze(['affected', 'not_affected', 'fixed', 'under_investigation', 'unknown'] as const);
|
||||||
|
export type VexStatusType = typeof VexStatusTypeValues[number];
|
||||||
|
|
||||||
export interface BuildMetadata {
|
export interface BuildMetadata {
|
||||||
buildStartedOn: string;
|
buildStartedOn: string;
|
||||||
buildFinishedOn: string;
|
buildFinishedOn: string;
|
||||||
@@ -59,6 +65,21 @@ export interface CustomProperty {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiffHunk {
|
||||||
|
startLine: number;
|
||||||
|
lineCount: number;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffPayload {
|
||||||
|
filesAdded?: Array<string>;
|
||||||
|
filesRemoved?: Array<string>;
|
||||||
|
filesChanged?: Array<FileChange>;
|
||||||
|
packagesChanged?: Array<PackageChange>;
|
||||||
|
packagesAdded?: Array<PackageRef>;
|
||||||
|
packagesRemoved?: Array<PackageRef>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigestReference {
|
export interface DigestReference {
|
||||||
algorithm: string;
|
algorithm: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -69,12 +90,59 @@ export interface EnvironmentMetadata {
|
|||||||
imageDigest?: DigestReference;
|
imageDigest?: DigestReference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileChange {
|
||||||
|
path: string;
|
||||||
|
hunks?: Array<DiffHunk>;
|
||||||
|
fromHash?: string;
|
||||||
|
toHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindingKey {
|
||||||
|
componentPurl: string;
|
||||||
|
componentVersion: string;
|
||||||
|
cveId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageReference {
|
||||||
|
digest: string;
|
||||||
|
name?: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LicenseDelta {
|
||||||
|
added?: Array<string>;
|
||||||
|
removed?: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterialChange {
|
||||||
|
findingKey: FindingKey;
|
||||||
|
changeType: MaterialChangeType;
|
||||||
|
reason: string;
|
||||||
|
previousState?: RiskState;
|
||||||
|
currentState?: RiskState;
|
||||||
|
priorityScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MaterialReference {
|
export interface MaterialReference {
|
||||||
uri: string;
|
uri: string;
|
||||||
digests: Array<DigestReference>;
|
digests: Array<DigestReference>;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PackageChange {
|
||||||
|
name: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
purl?: string;
|
||||||
|
licenseDelta?: LicenseDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageRef {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
purl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PolicyDecision {
|
export interface PolicyDecision {
|
||||||
policyId: string;
|
policyId: string;
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
@@ -92,6 +160,14 @@ export interface PolicyEvaluation {
|
|||||||
decisions: Array<PolicyDecision>;
|
decisions: Array<PolicyDecision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReachabilityGate {
|
||||||
|
reachable?: boolean;
|
||||||
|
configActivated?: boolean;
|
||||||
|
runningUser?: boolean;
|
||||||
|
class: number;
|
||||||
|
rationale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RiskFactor {
|
export interface RiskFactor {
|
||||||
name: string;
|
name: string;
|
||||||
weight: number;
|
weight: number;
|
||||||
@@ -107,6 +183,21 @@ export interface RiskProfileEvidence {
|
|||||||
factors: Array<RiskFactor>;
|
factors: Array<RiskFactor>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RiskState {
|
||||||
|
reachable?: boolean;
|
||||||
|
vexStatus: VexStatusType;
|
||||||
|
inAffectedRange?: boolean;
|
||||||
|
kev: boolean;
|
||||||
|
epssScore?: number;
|
||||||
|
policyFlags?: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeContext {
|
||||||
|
entrypoint?: Array<string>;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
user?: UserContext;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SbomAttestation {
|
export interface SbomAttestation {
|
||||||
schemaVersion: 'StellaOps.SBOMAttestation@1';
|
schemaVersion: 'StellaOps.SBOMAttestation@1';
|
||||||
subjectDigest: string;
|
subjectDigest: string;
|
||||||
@@ -143,6 +234,30 @@ export interface ScanResults {
|
|||||||
findings: Array<ScanFinding>;
|
findings: Array<ScanFinding>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScannerInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
ruleset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmartDiffPredicate {
|
||||||
|
schemaVersion: '1.0.0';
|
||||||
|
baseImage: ImageReference;
|
||||||
|
targetImage: ImageReference;
|
||||||
|
diff: DiffPayload;
|
||||||
|
context?: RuntimeContext;
|
||||||
|
reachabilityGate: ReachabilityGate;
|
||||||
|
scanner: ScannerInfo;
|
||||||
|
suppressedCount?: number;
|
||||||
|
materialChanges?: Array<MaterialChange>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserContext {
|
||||||
|
uid?: number;
|
||||||
|
gid?: number;
|
||||||
|
caps?: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VexAttestation {
|
export interface VexAttestation {
|
||||||
schemaVersion: 'StellaOps.VEXAttestation@1';
|
schemaVersion: 'StellaOps.VEXAttestation@1';
|
||||||
subjectDigest: string;
|
subjectDigest: string;
|
||||||
@@ -324,6 +439,93 @@ function assertCustomProperty(value: unknown, path: string[]): asserts value is
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertDiffHunk(value: unknown, path: string[]): asserts value is DiffHunk {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.startLine === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'startLine'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.startLine !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'startLine'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.startLine < 0) {
|
||||||
|
throw new Error(`${pathString([...path, 'startLine'])} must be >= 0`);
|
||||||
|
}
|
||||||
|
if (value.lineCount === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'lineCount'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.lineCount !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'lineCount'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.lineCount < 0) {
|
||||||
|
throw new Error(`${pathString([...path, 'lineCount'])} must be >= 0`);
|
||||||
|
}
|
||||||
|
if (value.content !== undefined) {
|
||||||
|
if (typeof value.content !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'content'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDiffPayload(value: unknown, path: string[]): asserts value is DiffPayload {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.filesAdded !== undefined) {
|
||||||
|
if (!Array.isArray(value.filesAdded)) {
|
||||||
|
throw new Error(`${pathString([...path, 'filesAdded'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.filesAdded.length; i += 1) {
|
||||||
|
if (typeof value.filesAdded[i] !== 'string') {
|
||||||
|
throw new Error(`${pathString([...[...path, 'filesAdded'], String(i)])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.filesRemoved !== undefined) {
|
||||||
|
if (!Array.isArray(value.filesRemoved)) {
|
||||||
|
throw new Error(`${pathString([...path, 'filesRemoved'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.filesRemoved.length; i += 1) {
|
||||||
|
if (typeof value.filesRemoved[i] !== 'string') {
|
||||||
|
throw new Error(`${pathString([...[...path, 'filesRemoved'], String(i)])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.filesChanged !== undefined) {
|
||||||
|
if (!Array.isArray(value.filesChanged)) {
|
||||||
|
throw new Error(`${pathString([...path, 'filesChanged'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.filesChanged.length; i += 1) {
|
||||||
|
assertFileChange(value.filesChanged[i], [...[...path, 'filesChanged'], String(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.packagesChanged !== undefined) {
|
||||||
|
if (!Array.isArray(value.packagesChanged)) {
|
||||||
|
throw new Error(`${pathString([...path, 'packagesChanged'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.packagesChanged.length; i += 1) {
|
||||||
|
assertPackageChange(value.packagesChanged[i], [...[...path, 'packagesChanged'], String(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.packagesAdded !== undefined) {
|
||||||
|
if (!Array.isArray(value.packagesAdded)) {
|
||||||
|
throw new Error(`${pathString([...path, 'packagesAdded'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.packagesAdded.length; i += 1) {
|
||||||
|
assertPackageRef(value.packagesAdded[i], [...[...path, 'packagesAdded'], String(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.packagesRemoved !== undefined) {
|
||||||
|
if (!Array.isArray(value.packagesRemoved)) {
|
||||||
|
throw new Error(`${pathString([...path, 'packagesRemoved'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.packagesRemoved.length; i += 1) {
|
||||||
|
assertPackageRef(value.packagesRemoved[i], [...[...path, 'packagesRemoved'], String(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertDigestReference(value: unknown, path: string[]): asserts value is DigestReference {
|
function assertDigestReference(value: unknown, path: string[]): asserts value is DigestReference {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
throw new Error(`${pathString(path)} must be an object.`);
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
@@ -359,6 +561,147 @@ function assertEnvironmentMetadata(value: unknown, path: string[]): asserts valu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertFileChange(value: unknown, path: string[]): asserts value is FileChange {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.path === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'path'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.path !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'path'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.hunks !== undefined) {
|
||||||
|
if (!Array.isArray(value.hunks)) {
|
||||||
|
throw new Error(`${pathString([...path, 'hunks'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.hunks.length; i += 1) {
|
||||||
|
assertDiffHunk(value.hunks[i], [...[...path, 'hunks'], String(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.fromHash !== undefined) {
|
||||||
|
if (typeof value.fromHash !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'fromHash'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.toHash !== undefined) {
|
||||||
|
if (typeof value.toHash !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'toHash'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFindingKey(value: unknown, path: string[]): asserts value is FindingKey {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.componentPurl === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'componentPurl'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.componentPurl !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'componentPurl'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.componentVersion === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'componentVersion'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.componentVersion !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'componentVersion'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.cveId === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'cveId'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.cveId !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'cveId'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertImageReference(value: unknown, path: string[]): asserts value is ImageReference {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.digest === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'digest'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.digest !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'digest'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (!/^sha256:[A-Fa-f0-9]{64}$/.test(value.digest)) {
|
||||||
|
throw new Error(`${pathString([...path, 'digest'])} does not match expected format.`);
|
||||||
|
}
|
||||||
|
if (value.name !== undefined) {
|
||||||
|
if (typeof value.name !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'name'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.tag !== undefined) {
|
||||||
|
if (typeof value.tag !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'tag'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLicenseDelta(value: unknown, path: string[]): asserts value is LicenseDelta {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.added !== undefined) {
|
||||||
|
if (!Array.isArray(value.added)) {
|
||||||
|
throw new Error(`${pathString([...path, 'added'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.added.length; i += 1) {
|
||||||
|
if (typeof value.added[i] !== 'string') {
|
||||||
|
throw new Error(`${pathString([...[...path, 'added'], String(i)])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.removed !== undefined) {
|
||||||
|
if (!Array.isArray(value.removed)) {
|
||||||
|
throw new Error(`${pathString([...path, 'removed'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.removed.length; i += 1) {
|
||||||
|
if (typeof value.removed[i] !== 'string') {
|
||||||
|
throw new Error(`${pathString([...[...path, 'removed'], String(i)])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMaterialChange(value: unknown, path: string[]): asserts value is MaterialChange {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.findingKey === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'findingKey'])} is required.`);
|
||||||
|
}
|
||||||
|
assertFindingKey(value.findingKey, [...path, 'findingKey']);
|
||||||
|
if (value.changeType === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'changeType'])} is required.`);
|
||||||
|
}
|
||||||
|
if (!MaterialChangeTypeValues.includes(value.changeType as MaterialChangeType)) {
|
||||||
|
throw new Error(`${pathString([...path, 'changeType'])} must be one of ${MaterialChangeTypeValues.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (value.reason === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'reason'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.reason !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'reason'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.previousState !== undefined) {
|
||||||
|
assertRiskState(value.previousState, [...path, 'previousState']);
|
||||||
|
}
|
||||||
|
if (value.currentState !== undefined) {
|
||||||
|
assertRiskState(value.currentState, [...path, 'currentState']);
|
||||||
|
}
|
||||||
|
if (value.priorityScore !== undefined) {
|
||||||
|
if (typeof value.priorityScore !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'priorityScore'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.priorityScore < 0) {
|
||||||
|
throw new Error(`${pathString([...path, 'priorityScore'])} must be >= 0`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertMaterialReference(value: unknown, path: string[]): asserts value is MaterialReference {
|
function assertMaterialReference(value: unknown, path: string[]): asserts value is MaterialReference {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
throw new Error(`${pathString(path)} must be an object.`);
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
@@ -388,6 +731,61 @@ function assertMaterialReference(value: unknown, path: string[]): asserts value
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertPackageChange(value: unknown, path: string[]): asserts value is PackageChange {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.name === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'name'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.name !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'name'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.from === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'from'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.from !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'from'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.to === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'to'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.to !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'to'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.purl !== undefined) {
|
||||||
|
if (typeof value.purl !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'purl'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.licenseDelta !== undefined) {
|
||||||
|
assertLicenseDelta(value.licenseDelta, [...path, 'licenseDelta']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPackageRef(value: unknown, path: string[]): asserts value is PackageRef {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.name === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'name'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.name !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'name'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.version === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'version'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.version !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'version'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.purl !== undefined) {
|
||||||
|
if (typeof value.purl !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'purl'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertPolicyDecision(value: unknown, path: string[]): asserts value is PolicyDecision {
|
function assertPolicyDecision(value: unknown, path: string[]): asserts value is PolicyDecision {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
throw new Error(`${pathString(path)} must be an object.`);
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
@@ -473,6 +871,44 @@ function assertPolicyEvaluation(value: unknown, path: string[]): asserts value i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertReachabilityGate(value: unknown, path: string[]): asserts value is ReachabilityGate {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.reachable !== undefined) {
|
||||||
|
if (typeof value.reachable !== 'boolean') {
|
||||||
|
throw new Error(`${pathString([...path, 'reachable'])} must be a boolean.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.configActivated !== undefined) {
|
||||||
|
if (typeof value.configActivated !== 'boolean') {
|
||||||
|
throw new Error(`${pathString([...path, 'configActivated'])} must be a boolean.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.runningUser !== undefined) {
|
||||||
|
if (typeof value.runningUser !== 'boolean') {
|
||||||
|
throw new Error(`${pathString([...path, 'runningUser'])} must be a boolean.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.class === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'class'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.class !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'class'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.class < -1) {
|
||||||
|
throw new Error(`${pathString([...path, 'class'])} must be >= -1`);
|
||||||
|
}
|
||||||
|
if (value.class > 7) {
|
||||||
|
throw new Error(`${pathString([...path, 'class'])} must be <= 7`);
|
||||||
|
}
|
||||||
|
if (value.rationale !== undefined) {
|
||||||
|
if (typeof value.rationale !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'rationale'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertRiskFactor(value: unknown, path: string[]): asserts value is RiskFactor {
|
function assertRiskFactor(value: unknown, path: string[]): asserts value is RiskFactor {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
throw new Error(`${pathString(path)} must be an object.`);
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
@@ -559,6 +995,86 @@ function assertRiskProfileEvidence(value: unknown, path: string[]): asserts valu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertRiskState(value: unknown, path: string[]): asserts value is RiskState {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.reachable !== undefined) {
|
||||||
|
if (typeof value.reachable !== 'boolean') {
|
||||||
|
throw new Error(`${pathString([...path, 'reachable'])} must be a boolean.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.vexStatus === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'vexStatus'])} is required.`);
|
||||||
|
}
|
||||||
|
if (!VexStatusTypeValues.includes(value.vexStatus as VexStatusType)) {
|
||||||
|
throw new Error(`${pathString([...path, 'vexStatus'])} must be one of ${VexStatusTypeValues.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (value.inAffectedRange !== undefined) {
|
||||||
|
if (typeof value.inAffectedRange !== 'boolean') {
|
||||||
|
throw new Error(`${pathString([...path, 'inAffectedRange'])} must be a boolean.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.kev === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'kev'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.kev !== 'boolean') {
|
||||||
|
throw new Error(`${pathString([...path, 'kev'])} must be a boolean.`);
|
||||||
|
}
|
||||||
|
if (value.epssScore !== undefined) {
|
||||||
|
if (typeof value.epssScore !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'epssScore'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.epssScore < 0) {
|
||||||
|
throw new Error(`${pathString([...path, 'epssScore'])} must be >= 0`);
|
||||||
|
}
|
||||||
|
if (value.epssScore > 1) {
|
||||||
|
throw new Error(`${pathString([...path, 'epssScore'])} must be <= 1`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.policyFlags !== undefined) {
|
||||||
|
if (!Array.isArray(value.policyFlags)) {
|
||||||
|
throw new Error(`${pathString([...path, 'policyFlags'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.policyFlags.length; i += 1) {
|
||||||
|
if (typeof value.policyFlags[i] !== 'string') {
|
||||||
|
throw new Error(`${pathString([...[...path, 'policyFlags'], String(i)])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRuntimeContext(value: unknown, path: string[]): asserts value is RuntimeContext {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.entrypoint !== undefined) {
|
||||||
|
if (!Array.isArray(value.entrypoint)) {
|
||||||
|
throw new Error(`${pathString([...path, 'entrypoint'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.entrypoint.length; i += 1) {
|
||||||
|
if (typeof value.entrypoint[i] !== 'string') {
|
||||||
|
throw new Error(`${pathString([...[...path, 'entrypoint'], String(i)])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.env !== undefined) {
|
||||||
|
if (!isRecord(value.env)) {
|
||||||
|
throw new Error(`${pathString([...path, 'env'])} must be an object.`);
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(value.env)) {
|
||||||
|
const entry = (value.env as Record<string, unknown>)[key];
|
||||||
|
const entryPath = [...[...path, 'env'], key];
|
||||||
|
if (typeof entry !== 'string') {
|
||||||
|
throw new Error(`${pathString(entryPath)} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.user !== undefined) {
|
||||||
|
assertUserContext(value.user, [...path, 'user']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertSbomAttestation(value: unknown, path: string[]): asserts value is SbomAttestation {
|
function assertSbomAttestation(value: unknown, path: string[]): asserts value is SbomAttestation {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
throw new Error(`${pathString(path)} must be an object.`);
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
@@ -760,6 +1276,115 @@ function assertScanResults(value: unknown, path: string[]): asserts value is Sca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertScannerInfo(value: unknown, path: string[]): asserts value is ScannerInfo {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.name === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'name'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.name !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'name'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.version === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'version'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.version !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'version'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.ruleset !== undefined) {
|
||||||
|
if (typeof value.ruleset !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'ruleset'])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSmartDiffPredicate(value: unknown, path: string[]): asserts value is SmartDiffPredicate {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.schemaVersion === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'schemaVersion'])} is required.`);
|
||||||
|
}
|
||||||
|
if (typeof value.schemaVersion !== 'string') {
|
||||||
|
throw new Error(`${pathString([...path, 'schemaVersion'])} must be a string.`);
|
||||||
|
}
|
||||||
|
if (value.schemaVersion !== '1.0.0') {
|
||||||
|
throw new Error(`${pathString([...path, 'schemaVersion'])} must equal '1.0.0'.`);
|
||||||
|
}
|
||||||
|
if (value.baseImage === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'baseImage'])} is required.`);
|
||||||
|
}
|
||||||
|
assertImageReference(value.baseImage, [...path, 'baseImage']);
|
||||||
|
if (value.targetImage === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'targetImage'])} is required.`);
|
||||||
|
}
|
||||||
|
assertImageReference(value.targetImage, [...path, 'targetImage']);
|
||||||
|
if (value.diff === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'diff'])} is required.`);
|
||||||
|
}
|
||||||
|
assertDiffPayload(value.diff, [...path, 'diff']);
|
||||||
|
if (value.context !== undefined) {
|
||||||
|
assertRuntimeContext(value.context, [...path, 'context']);
|
||||||
|
}
|
||||||
|
if (value.reachabilityGate === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'reachabilityGate'])} is required.`);
|
||||||
|
}
|
||||||
|
assertReachabilityGate(value.reachabilityGate, [...path, 'reachabilityGate']);
|
||||||
|
if (value.scanner === undefined) {
|
||||||
|
throw new Error(`${pathString([...path, 'scanner'])} is required.`);
|
||||||
|
}
|
||||||
|
assertScannerInfo(value.scanner, [...path, 'scanner']);
|
||||||
|
if (value.suppressedCount !== undefined) {
|
||||||
|
if (typeof value.suppressedCount !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'suppressedCount'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.suppressedCount < 0) {
|
||||||
|
throw new Error(`${pathString([...path, 'suppressedCount'])} must be >= 0`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.materialChanges !== undefined) {
|
||||||
|
if (!Array.isArray(value.materialChanges)) {
|
||||||
|
throw new Error(`${pathString([...path, 'materialChanges'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.materialChanges.length; i += 1) {
|
||||||
|
assertMaterialChange(value.materialChanges[i], [...[...path, 'materialChanges'], String(i)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUserContext(value: unknown, path: string[]): asserts value is UserContext {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
|
}
|
||||||
|
if (value.uid !== undefined) {
|
||||||
|
if (typeof value.uid !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'uid'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.uid < 0) {
|
||||||
|
throw new Error(`${pathString([...path, 'uid'])} must be >= 0`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.gid !== undefined) {
|
||||||
|
if (typeof value.gid !== 'number') {
|
||||||
|
throw new Error(`${pathString([...path, 'gid'])} must be a number.`);
|
||||||
|
}
|
||||||
|
if (value.gid < 0) {
|
||||||
|
throw new Error(`${pathString([...path, 'gid'])} must be >= 0`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.caps !== undefined) {
|
||||||
|
if (!Array.isArray(value.caps)) {
|
||||||
|
throw new Error(`${pathString([...path, 'caps'])} must be an array.`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < value.caps.length; i += 1) {
|
||||||
|
if (typeof value.caps[i] !== 'string') {
|
||||||
|
throw new Error(`${pathString([...[...path, 'caps'], String(i)])} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertVexAttestation(value: unknown, path: string[]): asserts value is VexAttestation {
|
function assertVexAttestation(value: unknown, path: string[]): asserts value is VexAttestation {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
throw new Error(`${pathString(path)} must be an object.`);
|
throw new Error(`${pathString(path)} must be an object.`);
|
||||||
@@ -914,6 +1539,16 @@ export function canonicalizeScanResults(value: ScanResults): string {
|
|||||||
return canonicalStringify(value);
|
return canonicalStringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateSmartDiffPredicate(value: unknown): SmartDiffPredicate {
|
||||||
|
assertSmartDiffPredicate(value, []);
|
||||||
|
return value as SmartDiffPredicate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalizeSmartDiffPredicate(value: SmartDiffPredicate): string {
|
||||||
|
assertSmartDiffPredicate(value, []);
|
||||||
|
return canonicalStringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function validateVexAttestation(value: unknown): VexAttestation {
|
export function validateVexAttestation(value: unknown): VexAttestation {
|
||||||
assertVexAttestation(value, []);
|
assertVexAttestation(value, []);
|
||||||
return value as VexAttestation;
|
return value as VexAttestation;
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://stella-ops.org/schemas/attestor/stellaops-smart-diff.v1.json",
|
||||||
|
"title": "Smart-Diff predicate describing differential analysis between two scans.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schemaVersion",
|
||||||
|
"baseImage",
|
||||||
|
"targetImage",
|
||||||
|
"diff",
|
||||||
|
"reachabilityGate",
|
||||||
|
"scanner"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "1.0.0",
|
||||||
|
"description": "Schema version (semver)."
|
||||||
|
},
|
||||||
|
"baseImage": {
|
||||||
|
"$ref": "#/$defs/ImageReference",
|
||||||
|
"description": "Base scan image reference."
|
||||||
|
},
|
||||||
|
"targetImage": {
|
||||||
|
"$ref": "#/$defs/ImageReference",
|
||||||
|
"description": "Target scan image reference."
|
||||||
|
},
|
||||||
|
"diff": {
|
||||||
|
"$ref": "#/$defs/DiffPayload",
|
||||||
|
"description": "Diff payload between base and target."
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"$ref": "#/$defs/RuntimeContext",
|
||||||
|
"description": "Optional runtime context."
|
||||||
|
},
|
||||||
|
"reachabilityGate": {
|
||||||
|
"$ref": "#/$defs/ReachabilityGate",
|
||||||
|
"description": "Derived reachability gate."
|
||||||
|
},
|
||||||
|
"scanner": {
|
||||||
|
"$ref": "#/$defs/ScannerInfo",
|
||||||
|
"description": "Scanner identity."
|
||||||
|
},
|
||||||
|
"suppressedCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Number of findings suppressed by pre-filters."
|
||||||
|
},
|
||||||
|
"materialChanges": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MaterialChange"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Optional list of material changes."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"ImageReference": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Reference to a container image.",
|
||||||
|
"required": [
|
||||||
|
"digest"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"digest": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[A-Fa-f0-9]{64}$",
|
||||||
|
"description": "Image digest."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Image name."
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Image tag."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DiffHunk": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Single diff hunk for a file change.",
|
||||||
|
"required": [
|
||||||
|
"startLine",
|
||||||
|
"lineCount"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"startLine": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Start line number."
|
||||||
|
},
|
||||||
|
"lineCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Number of lines in the hunk."
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional hunk content."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FileChange": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "File-level delta captured by Smart-Diff.",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "File path."
|
||||||
|
},
|
||||||
|
"hunks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/DiffHunk"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Optional hunks describing the file change."
|
||||||
|
},
|
||||||
|
"fromHash": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Previous file hash (when available)."
|
||||||
|
},
|
||||||
|
"toHash": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current file hash (when available)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LicenseDelta": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "License delta for a package or file.",
|
||||||
|
"properties": {
|
||||||
|
"added": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Licenses added."
|
||||||
|
},
|
||||||
|
"removed": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Licenses removed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PackageChange": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Package version change between the base and target scan.",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"from",
|
||||||
|
"to"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package name."
|
||||||
|
},
|
||||||
|
"from": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Previous package version."
|
||||||
|
},
|
||||||
|
"to": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Current package version."
|
||||||
|
},
|
||||||
|
"purl": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package URL (purl)."
|
||||||
|
},
|
||||||
|
"licenseDelta": {
|
||||||
|
"$ref": "#/$defs/LicenseDelta",
|
||||||
|
"description": "License delta between versions."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PackageRef": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Package reference used in diffs.",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package name."
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package version."
|
||||||
|
},
|
||||||
|
"purl": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Package URL (purl)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DiffPayload": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Diff payload describing file and package deltas.",
|
||||||
|
"properties": {
|
||||||
|
"filesAdded": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Paths of files added."
|
||||||
|
},
|
||||||
|
"filesRemoved": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Paths of files removed."
|
||||||
|
},
|
||||||
|
"filesChanged": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/FileChange"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Collection of file changes."
|
||||||
|
},
|
||||||
|
"packagesChanged": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/PackageChange"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Collection of package changes."
|
||||||
|
},
|
||||||
|
"packagesAdded": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/PackageRef"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Packages added."
|
||||||
|
},
|
||||||
|
"packagesRemoved": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/PackageRef"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Packages removed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"UserContext": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Runtime user context for the image.",
|
||||||
|
"properties": {
|
||||||
|
"uid": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "User ID."
|
||||||
|
},
|
||||||
|
"gid": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Group ID."
|
||||||
|
},
|
||||||
|
"caps": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Linux capabilities (string names)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"RuntimeContext": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Runtime context used for reachability gating and policy decisions.",
|
||||||
|
"properties": {
|
||||||
|
"entrypoint": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Entrypoint command array."
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Environment variables map."
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"$ref": "#/$defs/UserContext",
|
||||||
|
"description": "Runtime user context."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ReachabilityGate": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "3-bit reachability gate derived from the 7-state lattice.",
|
||||||
|
"required": [
|
||||||
|
"class"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"reachable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True/false if reachability is known; absent indicates unknown."
|
||||||
|
},
|
||||||
|
"configActivated": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True if configuration activates the finding."
|
||||||
|
},
|
||||||
|
"runningUser": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True if running user enables the finding."
|
||||||
|
},
|
||||||
|
"class": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": -1,
|
||||||
|
"maximum": 7,
|
||||||
|
"description": "Derived 3-bit class (0..7), or -1 if any bit is unknown."
|
||||||
|
},
|
||||||
|
"rationale": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional human-readable rationale for the gate."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ScannerInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Scanner identity and ruleset information.",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"version"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Scanner name."
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Scanner version string."
|
||||||
|
},
|
||||||
|
"ruleset": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional ruleset identifier."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FindingKey": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Unique identifier for a vulnerability finding.",
|
||||||
|
"required": [
|
||||||
|
"componentPurl",
|
||||||
|
"componentVersion",
|
||||||
|
"cveId"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"componentPurl": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Component package URL (purl)."
|
||||||
|
},
|
||||||
|
"componentVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Component version string."
|
||||||
|
},
|
||||||
|
"cveId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Vulnerability identifier (e.g., CVE)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MaterialChangeType": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Material change types emitted by Smart-Diff.",
|
||||||
|
"enum": [
|
||||||
|
"reachability_flip",
|
||||||
|
"vex_flip",
|
||||||
|
"range_boundary",
|
||||||
|
"intelligence_flip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"VexStatusType": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "VEX status values captured in Smart-Diff risk state.",
|
||||||
|
"enum": [
|
||||||
|
"affected",
|
||||||
|
"not_affected",
|
||||||
|
"fixed",
|
||||||
|
"under_investigation",
|
||||||
|
"unknown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RiskState": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Risk state captured for a finding at a point in time.",
|
||||||
|
"required": [
|
||||||
|
"vexStatus",
|
||||||
|
"kev"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"reachable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Reachability flag (null/absent indicates unknown)."
|
||||||
|
},
|
||||||
|
"vexStatus": {
|
||||||
|
"$ref": "#/$defs/VexStatusType",
|
||||||
|
"description": "VEX status value."
|
||||||
|
},
|
||||||
|
"inAffectedRange": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True if the component version is within the affected range."
|
||||||
|
},
|
||||||
|
"kev": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True if the vulnerability is in the KEV catalog."
|
||||||
|
},
|
||||||
|
"epssScore": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "EPSS score (0..1)."
|
||||||
|
},
|
||||||
|
"policyFlags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 0,
|
||||||
|
"description": "Policy flags contributing to the decision."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MaterialChange": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Single material change detected for a finding.",
|
||||||
|
"required": [
|
||||||
|
"findingKey",
|
||||||
|
"changeType",
|
||||||
|
"reason"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"findingKey": {
|
||||||
|
"$ref": "#/$defs/FindingKey",
|
||||||
|
"description": "Finding key for the change."
|
||||||
|
},
|
||||||
|
"changeType": {
|
||||||
|
"$ref": "#/$defs/MaterialChangeType",
|
||||||
|
"description": "Type of material change detected."
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable reason for the change."
|
||||||
|
},
|
||||||
|
"previousState": {
|
||||||
|
"$ref": "#/$defs/RiskState",
|
||||||
|
"description": "Previous risk state (when available)."
|
||||||
|
},
|
||||||
|
"currentState": {
|
||||||
|
"$ref": "#/$defs/RiskState",
|
||||||
|
"description": "Current risk state (when available)."
|
||||||
|
},
|
||||||
|
"priorityScore": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Priority score derived from change rules and intelligence."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Json.Schema;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Attestor.Types.Tests;
|
||||||
|
|
||||||
|
public sealed class SmartDiffSchemaValidationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SmartDiffSchema_ValidatesSamplePredicate()
|
||||||
|
{
|
||||||
|
var schemaPath = Path.Combine(AppContext.BaseDirectory, "schemas", "stellaops-smart-diff.v1.schema.json");
|
||||||
|
File.Exists(schemaPath).Should().BeTrue($"schema file should be copied to '{schemaPath}'");
|
||||||
|
|
||||||
|
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
|
||||||
|
using var doc = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"schemaVersion": "1.0.0",
|
||||||
|
"baseImage": {
|
||||||
|
"name": "example/base",
|
||||||
|
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"tag": "1.0"
|
||||||
|
},
|
||||||
|
"targetImage": {
|
||||||
|
"name": "example/target",
|
||||||
|
"digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||||
|
"tag": "2.0"
|
||||||
|
},
|
||||||
|
"diff": {
|
||||||
|
"filesAdded": ["./a.txt"],
|
||||||
|
"packagesChanged": [
|
||||||
|
{
|
||||||
|
"name": "openssl",
|
||||||
|
"purl": "pkg:deb/openssl@3.0.14",
|
||||||
|
"from": "1.1.1u",
|
||||||
|
"to": "3.0.14"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"entrypoint": ["/app/start"],
|
||||||
|
"env": {
|
||||||
|
"FEATURE_X": "true"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"uid": 1001,
|
||||||
|
"caps": ["NET_BIND_SERVICE"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reachabilityGate": {
|
||||||
|
"reachable": true,
|
||||||
|
"configActivated": true,
|
||||||
|
"runningUser": false,
|
||||||
|
"class": 6,
|
||||||
|
"rationale": "sample"
|
||||||
|
},
|
||||||
|
"scanner": {
|
||||||
|
"name": "StellaOps.Scanner",
|
||||||
|
"version": "2025.12.0",
|
||||||
|
"ruleset": "reachability-2025.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var result = schema.Evaluate(doc.RootElement, new EvaluationOptions
|
||||||
|
{
|
||||||
|
OutputFormat = OutputFormat.List,
|
||||||
|
RequireFormatValidation = true
|
||||||
|
});
|
||||||
|
|
||||||
|
result.IsValid.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmartDiffSchema_RejectsInvalidReachabilityClass()
|
||||||
|
{
|
||||||
|
var schemaPath = Path.Combine(AppContext.BaseDirectory, "schemas", "stellaops-smart-diff.v1.schema.json");
|
||||||
|
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
|
||||||
|
using var doc = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"schemaVersion": "1.0.0",
|
||||||
|
"baseImage": { "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
||||||
|
"targetImage": { "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
|
||||||
|
"diff": { },
|
||||||
|
"reachabilityGate": { "class": 99 },
|
||||||
|
"scanner": { "name": "StellaOps.Scanner", "version": "2025.12.0" }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var result = schema.Evaluate(doc.RootElement, new EvaluationOptions
|
||||||
|
{
|
||||||
|
OutputFormat = OutputFormat.List,
|
||||||
|
RequireFormatValidation = true
|
||||||
|
});
|
||||||
|
|
||||||
|
result.IsValid.Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace StellaOps.Policy.Suppression;
|
||||||
|
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider for checking policy suppression overrides (waivers).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISuppressionOverrideProvider
|
||||||
|
{
|
||||||
|
bool HasActiveOverride(FindingKey findingKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple in-memory override provider for tests and local runs.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemorySuppressionOverrideProvider : ISuppressionOverrideProvider
|
||||||
|
{
|
||||||
|
private readonly ImmutableHashSet<FindingKey> _overrides;
|
||||||
|
|
||||||
|
public InMemorySuppressionOverrideProvider(IEnumerable<FindingKey>? overrides = null)
|
||||||
|
{
|
||||||
|
_overrides = overrides?.ToImmutableHashSet() ?? ImmutableHashSet<FindingKey>.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasActiveOverride(FindingKey findingKey) => _overrides.Contains(findingKey);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
namespace StellaOps.Policy.Suppression;
|
||||||
|
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates whether a finding should be suppressed based on the 4-condition rule.
|
||||||
|
/// All conditions must be met for suppression:
|
||||||
|
/// 1. reachable == false
|
||||||
|
/// 2. vex_status == NOT_AFFECTED
|
||||||
|
/// 3. kev == false
|
||||||
|
/// 4. No policy override active
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SuppressionRuleEvaluator
|
||||||
|
{
|
||||||
|
private readonly ISuppressionOverrideProvider _overrideProvider;
|
||||||
|
|
||||||
|
public SuppressionRuleEvaluator(ISuppressionOverrideProvider overrideProvider)
|
||||||
|
{
|
||||||
|
_overrideProvider = overrideProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates suppression for a single finding.
|
||||||
|
/// </summary>
|
||||||
|
public SuppressionResult Evaluate(SuppressionInput input)
|
||||||
|
{
|
||||||
|
var conditions = new List<SuppressionConditionResult>
|
||||||
|
{
|
||||||
|
EvaluateReachableCondition(input),
|
||||||
|
EvaluateVexCondition(input),
|
||||||
|
EvaluateKevCondition(input),
|
||||||
|
EvaluateOverrideCondition(input),
|
||||||
|
};
|
||||||
|
|
||||||
|
var shouldSuppress = conditions.All(c => c.Passed);
|
||||||
|
|
||||||
|
return new SuppressionResult(
|
||||||
|
FindingKey: input.FindingKey,
|
||||||
|
Suppressed: shouldSuppress,
|
||||||
|
Conditions: conditions.ToImmutableArray(),
|
||||||
|
Reason: shouldSuppress
|
||||||
|
? "All 4 suppression conditions met"
|
||||||
|
: $"Condition failed: {conditions.First(c => !c.Passed).ConditionName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates suppression for multiple findings (batch).
|
||||||
|
/// </summary>
|
||||||
|
public ImmutableArray<SuppressionResult> EvaluateBatch(IEnumerable<SuppressionInput> inputs)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(inputs);
|
||||||
|
return inputs.Select(Evaluate).ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates patch churn suppression: version changes with no material risk change.
|
||||||
|
/// </summary>
|
||||||
|
public SuppressionResult EvaluatePatchChurn(PatchChurnInput input)
|
||||||
|
{
|
||||||
|
var conditions = new List<SuppressionConditionResult>
|
||||||
|
{
|
||||||
|
new(
|
||||||
|
ConditionName: "version_changed",
|
||||||
|
Passed: input.VersionChanged,
|
||||||
|
Reason: input.VersionChanged ? "Version changed" : "Version unchanged"),
|
||||||
|
new(
|
||||||
|
ConditionName: "not_in_affected_range",
|
||||||
|
Passed: !input.WasInAffectedRange && !input.IsInAffectedRange,
|
||||||
|
Reason: $"Was: {input.WasInAffectedRange}, Now: {input.IsInAffectedRange}"),
|
||||||
|
new(
|
||||||
|
ConditionName: "no_kev",
|
||||||
|
Passed: !input.Kev,
|
||||||
|
Reason: input.Kev ? "KEV flagged" : "Not KEV"),
|
||||||
|
new(
|
||||||
|
ConditionName: "no_policy_flip",
|
||||||
|
Passed: !input.PolicyFlipped,
|
||||||
|
Reason: input.PolicyFlipped ? "Policy changed" : "Policy unchanged"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var shouldSuppress = conditions.All(c => c.Passed);
|
||||||
|
|
||||||
|
return new SuppressionResult(
|
||||||
|
FindingKey: input.FindingKey,
|
||||||
|
Suppressed: shouldSuppress,
|
||||||
|
Conditions: conditions.ToImmutableArray(),
|
||||||
|
Reason: shouldSuppress ? "Patch churn - no material change" : "Material change detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
private SuppressionConditionResult EvaluateReachableCondition(SuppressionInput input)
|
||||||
|
{
|
||||||
|
var passed = input.Reachable == false;
|
||||||
|
return new SuppressionConditionResult(
|
||||||
|
ConditionName: "unreachable",
|
||||||
|
Passed: passed,
|
||||||
|
Reason: input.Reachable switch
|
||||||
|
{
|
||||||
|
null => "Reachability unknown",
|
||||||
|
true => "Code is reachable",
|
||||||
|
false => "Code is unreachable"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SuppressionConditionResult EvaluateVexCondition(SuppressionInput input)
|
||||||
|
{
|
||||||
|
var passed = input.VexStatus == VexStatus.NotAffected;
|
||||||
|
return new SuppressionConditionResult(
|
||||||
|
ConditionName: "vex_not_affected",
|
||||||
|
Passed: passed,
|
||||||
|
Reason: $"VEX status: {input.VexStatus}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SuppressionConditionResult EvaluateKevCondition(SuppressionInput input)
|
||||||
|
{
|
||||||
|
var passed = !input.Kev;
|
||||||
|
return new SuppressionConditionResult(
|
||||||
|
ConditionName: "not_kev",
|
||||||
|
Passed: passed,
|
||||||
|
Reason: input.Kev ? "Known Exploited Vulnerability" : "Not in KEV catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
private SuppressionConditionResult EvaluateOverrideCondition(SuppressionInput input)
|
||||||
|
{
|
||||||
|
var hasOverride = _overrideProvider.HasActiveOverride(input.FindingKey);
|
||||||
|
return new SuppressionConditionResult(
|
||||||
|
ConditionName: "no_override",
|
||||||
|
Passed: !hasOverride,
|
||||||
|
Reason: hasOverride ? "Policy override active" : "No policy override");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input for suppression evaluation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SuppressionInput(
|
||||||
|
FindingKey FindingKey,
|
||||||
|
bool? Reachable,
|
||||||
|
VexStatus VexStatus,
|
||||||
|
bool Kev);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input for patch churn suppression evaluation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PatchChurnInput(
|
||||||
|
FindingKey FindingKey,
|
||||||
|
bool VersionChanged,
|
||||||
|
bool WasInAffectedRange,
|
||||||
|
bool IsInAffectedRange,
|
||||||
|
bool Kev,
|
||||||
|
bool PolicyFlipped);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of suppression evaluation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SuppressionResult(
|
||||||
|
FindingKey FindingKey,
|
||||||
|
bool Suppressed,
|
||||||
|
ImmutableArray<SuppressionConditionResult> Conditions,
|
||||||
|
string Reason);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of a single suppression condition.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SuppressionConditionResult(
|
||||||
|
string ConditionName,
|
||||||
|
bool Passed,
|
||||||
|
string Reason);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for a vulnerability finding.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FindingKey(
|
||||||
|
string ComponentPurl,
|
||||||
|
string ComponentVersion,
|
||||||
|
string CveId)
|
||||||
|
{
|
||||||
|
public override string ToString() => $"{ComponentPurl}@{ComponentVersion}:{CveId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VEX status values.
|
||||||
|
/// </summary>
|
||||||
|
public enum VexStatus
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Affected,
|
||||||
|
NotAffected,
|
||||||
|
Fixed,
|
||||||
|
UnderInvestigation
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using StellaOps.Policy.Suppression;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Policy.Tests.Suppression;
|
||||||
|
|
||||||
|
public sealed class SuppressionRuleEvaluatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_Suppresses_WhenAllConditionsPass()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider());
|
||||||
|
|
||||||
|
var result = evaluator.Evaluate(new SuppressionInput(
|
||||||
|
FindingKey: key,
|
||||||
|
Reachable: false,
|
||||||
|
VexStatus: VexStatus.NotAffected,
|
||||||
|
Kev: false));
|
||||||
|
|
||||||
|
Assert.True(result.Suppressed);
|
||||||
|
Assert.All(result.Conditions, condition => Assert.True(condition.Passed, condition.ConditionName));
|
||||||
|
Assert.Equal("All 4 suppression conditions met", result.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_DoesNotSuppress_WhenReachableIsTrue()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider());
|
||||||
|
|
||||||
|
var result = evaluator.Evaluate(new SuppressionInput(
|
||||||
|
FindingKey: key,
|
||||||
|
Reachable: true,
|
||||||
|
VexStatus: VexStatus.NotAffected,
|
||||||
|
Kev: false));
|
||||||
|
|
||||||
|
Assert.False(result.Suppressed);
|
||||||
|
Assert.Contains("unreachable", result.Reason, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_DoesNotSuppress_WhenReachableIsUnknown()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider());
|
||||||
|
|
||||||
|
var result = evaluator.Evaluate(new SuppressionInput(
|
||||||
|
FindingKey: key,
|
||||||
|
Reachable: null,
|
||||||
|
VexStatus: VexStatus.NotAffected,
|
||||||
|
Kev: false));
|
||||||
|
|
||||||
|
Assert.False(result.Suppressed);
|
||||||
|
Assert.Contains("unreachable", result.Reason, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_DoesNotSuppress_WhenVexIsNotNotAffected()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider());
|
||||||
|
|
||||||
|
var result = evaluator.Evaluate(new SuppressionInput(
|
||||||
|
FindingKey: key,
|
||||||
|
Reachable: false,
|
||||||
|
VexStatus: VexStatus.Affected,
|
||||||
|
Kev: false));
|
||||||
|
|
||||||
|
Assert.False(result.Suppressed);
|
||||||
|
Assert.Contains("vex_not_affected", result.Reason, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_DoesNotSuppress_WhenKev()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider());
|
||||||
|
|
||||||
|
var result = evaluator.Evaluate(new SuppressionInput(
|
||||||
|
FindingKey: key,
|
||||||
|
Reachable: false,
|
||||||
|
VexStatus: VexStatus.NotAffected,
|
||||||
|
Kev: true));
|
||||||
|
|
||||||
|
Assert.False(result.Suppressed);
|
||||||
|
Assert.Contains("not_kev", result.Reason, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Evaluate_DoesNotSuppress_WhenOverrideActive()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider(new[] { key }));
|
||||||
|
|
||||||
|
var result = evaluator.Evaluate(new SuppressionInput(
|
||||||
|
FindingKey: key,
|
||||||
|
Reachable: false,
|
||||||
|
VexStatus: VexStatus.NotAffected,
|
||||||
|
Kev: false));
|
||||||
|
|
||||||
|
Assert.False(result.Suppressed);
|
||||||
|
Assert.Contains("no_override", result.Reason, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EvaluatePatchChurn_Suppresses_WhenVersionChangesButNoMaterialChange()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider());
|
||||||
|
|
||||||
|
var result = evaluator.EvaluatePatchChurn(new PatchChurnInput(
|
||||||
|
FindingKey: key,
|
||||||
|
VersionChanged: true,
|
||||||
|
WasInAffectedRange: false,
|
||||||
|
IsInAffectedRange: false,
|
||||||
|
Kev: false,
|
||||||
|
PolicyFlipped: false));
|
||||||
|
|
||||||
|
Assert.True(result.Suppressed);
|
||||||
|
Assert.Equal("Patch churn - no material change", result.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EvaluatePatchChurn_DoesNotSuppress_WhenInAffectedRange()
|
||||||
|
{
|
||||||
|
var key = CreateFindingKey();
|
||||||
|
var evaluator = new SuppressionRuleEvaluator(new InMemorySuppressionOverrideProvider());
|
||||||
|
|
||||||
|
var result = evaluator.EvaluatePatchChurn(new PatchChurnInput(
|
||||||
|
FindingKey: key,
|
||||||
|
VersionChanged: true,
|
||||||
|
WasInAffectedRange: false,
|
||||||
|
IsInAffectedRange: true,
|
||||||
|
Kev: false,
|
||||||
|
PolicyFlipped: false));
|
||||||
|
|
||||||
|
Assert.False(result.Suppressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FindingKey CreateFindingKey() => new(
|
||||||
|
ComponentPurl: "pkg:nuget/Example.Component@1.0.0",
|
||||||
|
ComponentVersion: "1.0.0",
|
||||||
|
CveId: "CVE-2025-0001");
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.WebService.Contracts;
|
||||||
|
|
||||||
|
public sealed record ProofSpineListResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("items")]
|
||||||
|
public IReadOnlyList<ProofSpineSummaryDto> Items { get; init; } = Array.Empty<ProofSpineSummaryDto>();
|
||||||
|
|
||||||
|
[JsonPropertyName("total")]
|
||||||
|
public int Total { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProofSpineSummaryDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("spineId")]
|
||||||
|
public string SpineId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("artifactId")]
|
||||||
|
public string ArtifactId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("vulnerabilityId")]
|
||||||
|
public string VulnerabilityId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("verdict")]
|
||||||
|
public string Verdict { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("segmentCount")]
|
||||||
|
public int SegmentCount { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("createdAt")]
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProofSpineResponseDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("spineId")]
|
||||||
|
public string SpineId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("artifactId")]
|
||||||
|
public string ArtifactId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("vulnerabilityId")]
|
||||||
|
public string VulnerabilityId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("policyProfileId")]
|
||||||
|
public string PolicyProfileId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("verdict")]
|
||||||
|
public string Verdict { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("verdictReason")]
|
||||||
|
public string VerdictReason { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("rootHash")]
|
||||||
|
public string RootHash { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("scanRunId")]
|
||||||
|
public string ScanRunId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("segments")]
|
||||||
|
public IReadOnlyList<ProofSegmentDto> Segments { get; init; } = Array.Empty<ProofSegmentDto>();
|
||||||
|
|
||||||
|
[JsonPropertyName("createdAt")]
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("supersededBySpineId")]
|
||||||
|
public string? SupersededBySpineId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("verification")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public ProofSpineVerificationDto? Verification { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProofSegmentDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("segmentId")]
|
||||||
|
public string SegmentId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("segmentType")]
|
||||||
|
public string SegmentType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("index")]
|
||||||
|
public int Index { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("inputHash")]
|
||||||
|
public string InputHash { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("resultHash")]
|
||||||
|
public string ResultHash { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("prevSegmentHash")]
|
||||||
|
public string? PrevSegmentHash { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("envelope")]
|
||||||
|
public DsseEnvelopeDto Envelope { get; init; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("toolId")]
|
||||||
|
public string ToolId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("toolVersion")]
|
||||||
|
public string ToolVersion { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("createdAt")]
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("verificationErrors")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public IReadOnlyList<string>? VerificationErrors { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DsseEnvelopeDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("payloadType")]
|
||||||
|
public string PayloadType { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("payload")]
|
||||||
|
public string Payload { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("signatures")]
|
||||||
|
public IReadOnlyList<DsseSignatureDto> Signatures { get; init; } = Array.Empty<DsseSignatureDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DsseSignatureDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("keyid")]
|
||||||
|
public string KeyId { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("sig")]
|
||||||
|
public string Sig { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProofSpineVerificationDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("isValid")]
|
||||||
|
public bool IsValid { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("errors")]
|
||||||
|
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using StellaOps.Replay.Core;
|
||||||
|
using StellaOps.Scanner.ProofSpine;
|
||||||
|
using StellaOps.Scanner.WebService.Contracts;
|
||||||
|
using StellaOps.Scanner.WebService.Security;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.WebService.Endpoints;
|
||||||
|
|
||||||
|
internal static class ProofSpineEndpoints
|
||||||
|
{
|
||||||
|
public static void MapProofSpineEndpoints(this RouteGroupBuilder apiGroup, string spinesSegment, string scansSegment)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(apiGroup);
|
||||||
|
|
||||||
|
var spines = apiGroup.MapGroup(NormalizeSegment(spinesSegment));
|
||||||
|
spines.MapGet("/{spineId}", HandleGetSpineAsync)
|
||||||
|
.WithName("scanner.spines.get")
|
||||||
|
.Produces<ProofSpineResponseDto>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||||
|
|
||||||
|
var scans = apiGroup.MapGroup(NormalizeSegment(scansSegment));
|
||||||
|
scans.MapGet("/{scanId}/spines", HandleListSpinesAsync)
|
||||||
|
.WithName("scanner.spines.list-by-scan")
|
||||||
|
.Produces<ProofSpineListResponseDto>(StatusCodes.Status200OK)
|
||||||
|
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleGetSpineAsync(
|
||||||
|
string spineId,
|
||||||
|
IProofSpineRepository repository,
|
||||||
|
ProofSpineVerifier verifier,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
|
ArgumentNullException.ThrowIfNull(verifier);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(spineId))
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var spine = await repository.GetByIdAsync(spineId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (spine is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var segments = await repository.GetSegmentsAsync(spineId, cancellationToken).ConfigureAwait(false);
|
||||||
|
var full = spine with { Segments = segments };
|
||||||
|
|
||||||
|
var verification = await verifier.VerifyAsync(full, cancellationToken).ConfigureAwait(false);
|
||||||
|
var verificationBySegment = verification.Segments.ToDictionary(s => s.SegmentId, s => s, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var dto = new ProofSpineResponseDto
|
||||||
|
{
|
||||||
|
SpineId = full.SpineId,
|
||||||
|
ArtifactId = full.ArtifactId,
|
||||||
|
VulnerabilityId = full.VulnerabilityId,
|
||||||
|
PolicyProfileId = full.PolicyProfileId,
|
||||||
|
Verdict = full.Verdict,
|
||||||
|
VerdictReason = full.VerdictReason,
|
||||||
|
RootHash = full.RootHash,
|
||||||
|
ScanRunId = full.ScanRunId,
|
||||||
|
CreatedAt = full.CreatedAt,
|
||||||
|
SupersededBySpineId = full.SupersededBySpineId,
|
||||||
|
Segments = full.Segments.Select(segment =>
|
||||||
|
{
|
||||||
|
verificationBySegment.TryGetValue(segment.SegmentId, out var segmentVerification);
|
||||||
|
var status = segmentVerification?.Status ?? segment.Status;
|
||||||
|
|
||||||
|
return new ProofSegmentDto
|
||||||
|
{
|
||||||
|
SegmentId = segment.SegmentId,
|
||||||
|
SegmentType = ToWireSegmentType(segment.SegmentType),
|
||||||
|
Index = segment.Index,
|
||||||
|
InputHash = segment.InputHash,
|
||||||
|
ResultHash = segment.ResultHash,
|
||||||
|
PrevSegmentHash = segment.PrevSegmentHash,
|
||||||
|
Envelope = MapEnvelope(segment.Envelope),
|
||||||
|
ToolId = segment.ToolId,
|
||||||
|
ToolVersion = segment.ToolVersion,
|
||||||
|
Status = ToWireStatus(status),
|
||||||
|
CreatedAt = segment.CreatedAt,
|
||||||
|
VerificationErrors = segmentVerification?.Errors.Count > 0 ? segmentVerification.Errors : null
|
||||||
|
};
|
||||||
|
}).ToArray(),
|
||||||
|
Verification = new ProofSpineVerificationDto
|
||||||
|
{
|
||||||
|
IsValid = verification.IsValid,
|
||||||
|
Errors = verification.Errors.ToArray()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Results.Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleListSpinesAsync(
|
||||||
|
string scanId,
|
||||||
|
IProofSpineRepository repository,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(scanId))
|
||||||
|
{
|
||||||
|
return Results.Ok(new ProofSpineListResponseDto { Items = Array.Empty<ProofSpineSummaryDto>(), Total = 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
var summaries = await repository.GetSummariesByScanRunAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||||
|
var items = summaries.Select(summary => new ProofSpineSummaryDto
|
||||||
|
{
|
||||||
|
SpineId = summary.SpineId,
|
||||||
|
ArtifactId = summary.ArtifactId,
|
||||||
|
VulnerabilityId = summary.VulnerabilityId,
|
||||||
|
Verdict = summary.Verdict,
|
||||||
|
SegmentCount = summary.SegmentCount,
|
||||||
|
CreatedAt = summary.CreatedAt
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
return Results.Ok(new ProofSpineListResponseDto
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
Total = items.Length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DsseEnvelopeDto MapEnvelope(DsseEnvelope envelope)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
PayloadType = envelope.PayloadType,
|
||||||
|
Payload = envelope.Payload,
|
||||||
|
Signatures = envelope.Signatures.Select(signature => new DsseSignatureDto
|
||||||
|
{
|
||||||
|
KeyId = signature.KeyId,
|
||||||
|
Sig = signature.Sig
|
||||||
|
}).ToArray()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ToWireSegmentType(ProofSegmentType type) => type switch
|
||||||
|
{
|
||||||
|
ProofSegmentType.SbomSlice => "SBOM_SLICE",
|
||||||
|
ProofSegmentType.Match => "MATCH",
|
||||||
|
ProofSegmentType.Reachability => "REACHABILITY",
|
||||||
|
ProofSegmentType.GuardAnalysis => "GUARD_ANALYSIS",
|
||||||
|
ProofSegmentType.RuntimeObservation => "RUNTIME_OBSERVATION",
|
||||||
|
ProofSegmentType.PolicyEval => "POLICY_EVAL",
|
||||||
|
_ => type.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ToWireStatus(ProofSegmentStatus status)
|
||||||
|
=> status.ToString().ToLowerInvariant();
|
||||||
|
|
||||||
|
private static string NormalizeSegment(string segment)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(segment))
|
||||||
|
{
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = segment.Trim('/');
|
||||||
|
return "/" + trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -320,6 +320,8 @@ public sealed class ScannerWebServiceOptions
|
|||||||
public string PolicySegment { get; set; } = "policy";
|
public string PolicySegment { get; set; } = "policy";
|
||||||
|
|
||||||
public string RuntimeSegment { get; set; } = "runtime";
|
public string RuntimeSegment { get; set; } = "runtime";
|
||||||
|
|
||||||
|
public string SpinesSegment { get; set; } = "spines";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ConsoleOptions
|
public sealed class ConsoleOptions
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ public static class ScannerWebServiceOptionsValidator
|
|||||||
throw new InvalidOperationException("API runtimeSegment must be configured.");
|
throw new InvalidOperationException("API runtimeSegment must be configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Api.SpinesSegment))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("API spinesSegment must be configured.");
|
||||||
|
}
|
||||||
|
|
||||||
options.Events ??= new ScannerWebServiceOptions.EventsOptions();
|
options.Events ??= new ScannerWebServiceOptions.EventsOptions();
|
||||||
ValidateEvents(options.Events);
|
ValidateEvents(options.Events);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
|
||||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
|
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
|
||||||
|
<ProjectReference Include="../__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
|
||||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||||
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Scanner.ProofSpine.Options;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
|
|
||||||
|
public sealed class DefaultCryptoProfile : ICryptoProfile
|
||||||
|
{
|
||||||
|
private readonly IOptions<ProofSpineDsseSigningOptions> _options;
|
||||||
|
|
||||||
|
public DefaultCryptoProfile(IOptions<ProofSpineDsseSigningOptions> options)
|
||||||
|
=> _options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
|
||||||
|
public string KeyId => _options.Value.KeyId;
|
||||||
|
|
||||||
|
public string Algorithm => _options.Value.Algorithm;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
|
|
||||||
|
internal static class DssePreAuthEncoding
|
||||||
|
{
|
||||||
|
private const string Prefix = "DSSEv1";
|
||||||
|
private const byte Space = 0x20;
|
||||||
|
|
||||||
|
public static byte[] Build(string payloadType, ReadOnlySpan<byte> payload)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(payloadType);
|
||||||
|
|
||||||
|
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||||
|
var typeLenBytes = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||||
|
var payloadLenBytes = Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||||
|
|
||||||
|
var totalLength = Prefix.Length
|
||||||
|
+ 1 + typeLenBytes.Length
|
||||||
|
+ 1 + typeBytes.Length
|
||||||
|
+ 1 + payloadLenBytes.Length
|
||||||
|
+ 1 + payload.Length;
|
||||||
|
|
||||||
|
var buffer = new byte[totalLength];
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
Encoding.UTF8.GetBytes(Prefix, buffer.AsSpan(offset));
|
||||||
|
offset += Prefix.Length;
|
||||||
|
buffer[offset++] = Space;
|
||||||
|
|
||||||
|
typeLenBytes.CopyTo(buffer.AsSpan(offset));
|
||||||
|
offset += typeLenBytes.Length;
|
||||||
|
buffer[offset++] = Space;
|
||||||
|
|
||||||
|
typeBytes.CopyTo(buffer.AsSpan(offset));
|
||||||
|
offset += typeBytes.Length;
|
||||||
|
buffer[offset++] = Space;
|
||||||
|
|
||||||
|
payloadLenBytes.CopyTo(buffer.AsSpan(offset));
|
||||||
|
offset += payloadLenBytes.Length;
|
||||||
|
buffer[offset++] = Space;
|
||||||
|
|
||||||
|
payload.CopyTo(buffer.AsSpan(offset));
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Cryptography;
|
||||||
|
using StellaOps.Replay.Core;
|
||||||
|
using StellaOps.Scanner.ProofSpine.Options;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
|
|
||||||
|
public sealed class HmacDsseSigningService : IDsseSigningService
|
||||||
|
{
|
||||||
|
private readonly IOptions<ProofSpineDsseSigningOptions> _options;
|
||||||
|
private readonly ICryptoHmac _cryptoHmac;
|
||||||
|
private readonly ICryptoHash _cryptoHash;
|
||||||
|
|
||||||
|
public HmacDsseSigningService(
|
||||||
|
IOptions<ProofSpineDsseSigningOptions> options,
|
||||||
|
ICryptoHmac cryptoHmac,
|
||||||
|
ICryptoHash cryptoHash)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
|
||||||
|
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DsseEnvelope> SignAsync(
|
||||||
|
object payload,
|
||||||
|
string payloadType,
|
||||||
|
ICryptoProfile cryptoProfile,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(payload);
|
||||||
|
ArgumentNullException.ThrowIfNull(cryptoProfile);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var payloadBytes = CanonicalJson.SerializeToUtf8Bytes(payload);
|
||||||
|
var pae = DssePreAuthEncoding.Build(payloadType, payloadBytes);
|
||||||
|
|
||||||
|
var (signatureBytes, signatureKeyId) = ResolveSignature(pae, cryptoProfile.KeyId);
|
||||||
|
var envelope = new DsseEnvelope(
|
||||||
|
payloadType,
|
||||||
|
Convert.ToBase64String(payloadBytes),
|
||||||
|
new[] { new DsseSignature(signatureKeyId, Convert.ToBase64String(signatureBytes)) });
|
||||||
|
|
||||||
|
return Task.FromResult(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<DsseVerificationOutcome> VerifyAsync(DsseEnvelope envelope, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(envelope);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_missing_signatures"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryDecodeBase64(envelope.Payload, out var payloadBytes))
|
||||||
|
{
|
||||||
|
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_payload_not_base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pae = DssePreAuthEncoding.Build(envelope.PayloadType, payloadBytes);
|
||||||
|
var expected = ComputeExpectedSignature(pae);
|
||||||
|
|
||||||
|
var keyId = _options.Value.KeyId;
|
||||||
|
foreach (var signature in envelope.Signatures)
|
||||||
|
{
|
||||||
|
if (!string.Equals(signature.KeyId, keyId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryDecodeBase64(signature.Sig, out var provided))
|
||||||
|
{
|
||||||
|
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_sig_not_base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CryptographicOperations.FixedTimeEquals(expected.SignatureBytes, provided))
|
||||||
|
{
|
||||||
|
return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, failureReason: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new DsseVerificationOutcome(false, expected.IsTrusted, "dsse_sig_mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_key_not_trusted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private (byte[] SignatureBytes, string KeyId) ResolveSignature(ReadOnlySpan<byte> pae, string keyId)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
if (string.Equals(options.Mode, "hmac", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& TryDecodeBase64(options.SecretBase64, out var secret))
|
||||||
|
{
|
||||||
|
return (_cryptoHmac.ComputeHmacForPurpose(secret, pae, HmacPurpose.Signing), keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.AllowDeterministicFallback)
|
||||||
|
{
|
||||||
|
return (_cryptoHash.ComputeHashForPurpose(pae, HashPurpose.Attestation), keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"ProofSpine DSSE signing is not configured (mode=hmac requires secretBase64) and deterministic fallback is disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private (byte[] SignatureBytes, bool IsTrusted) ComputeExpectedSignature(ReadOnlySpan<byte> pae)
|
||||||
|
{
|
||||||
|
var options = _options.Value;
|
||||||
|
if (string.Equals(options.Mode, "hmac", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& TryDecodeBase64(options.SecretBase64, out var secret))
|
||||||
|
{
|
||||||
|
return (_cryptoHmac.ComputeHmacForPurpose(secret, pae, HmacPurpose.Signing), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.AllowDeterministicFallback)
|
||||||
|
{
|
||||||
|
return (_cryptoHash.ComputeHashForPurpose(pae, HashPurpose.Attestation), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Array.Empty<byte>(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDecodeBase64(string? value, out byte[] bytes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
bytes = Array.Empty<byte>();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
bytes = Array.Empty<byte>();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Threading;
|
using StellaOps.Replay.Core;
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for DSSE (Dead Simple Signing Envelope) signing operations.
|
/// Service for DSSE (Dead Simple Signing Envelope) signing operations.
|
||||||
@@ -13,13 +12,14 @@ public interface IDsseSigningService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<DsseEnvelope> SignAsync(
|
Task<DsseEnvelope> SignAsync(
|
||||||
object payload,
|
object payload,
|
||||||
|
string payloadType,
|
||||||
ICryptoProfile cryptoProfile,
|
ICryptoProfile cryptoProfile,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies a DSSE envelope signature.
|
/// Verifies a DSSE envelope signature.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> VerifyAsync(
|
Task<DsseVerificationOutcome> VerifyAsync(
|
||||||
DsseEnvelope envelope,
|
DsseEnvelope envelope,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,13 @@ public interface ICryptoProfile
|
|||||||
string KeyId { get; }
|
string KeyId { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Signing algorithm (e.g., "ed25519", "ecdsa-p256").
|
/// Signing algorithm identifier (e.g., "hs256", "ed25519").
|
||||||
/// </summary>
|
/// </summary>
|
||||||
string Algorithm { get; }
|
string Algorithm { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DsseVerificationOutcome(
|
||||||
|
bool IsValid,
|
||||||
|
bool IsTrusted,
|
||||||
|
string? FailureReason);
|
||||||
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repository for ProofSpine persistence and queries.
|
/// Repository for ProofSpine persistence and queries.
|
||||||
@@ -50,4 +46,8 @@ public interface IProofSpineRepository
|
|||||||
Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(
|
Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(
|
||||||
string spineId,
|
string spineId,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<ProofSpineSummary>> GetSummariesByScanRunAsync(
|
||||||
|
string scanRunId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace StellaOps.Scanner.ProofSpine.Options;
|
||||||
|
|
||||||
|
public sealed class ProofSpineDsseSigningOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "scanner:proofSpine:dsse";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signing mode: "hmac" or "deterministic".
|
||||||
|
/// </summary>
|
||||||
|
public string Mode { get; set; } = "deterministic";
|
||||||
|
|
||||||
|
public string KeyId { get; set; } = "scanner-deterministic";
|
||||||
|
|
||||||
|
public string Algorithm { get; set; } = "hs256";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base64-encoded secret key used when <see cref="Mode"/> is "hmac".
|
||||||
|
/// </summary>
|
||||||
|
public string? SecretBase64 { get; set; }
|
||||||
|
= null;
|
||||||
|
|
||||||
|
public bool AllowDeterministicFallback { get; set; }
|
||||||
|
= true;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using StellaOps.Cryptography;
|
||||||
|
using StellaOps.Replay.Core;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds ProofSpine chains from evidence segments.
|
||||||
|
/// Ensures deterministic ordering and cryptographic chaining.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ProofSpineBuilder
|
||||||
|
{
|
||||||
|
public const string DefaultSegmentPayloadType = "application/vnd.stellaops.proofspine.segment+json";
|
||||||
|
|
||||||
|
private readonly List<ProofSegmentInput> _segments = new();
|
||||||
|
private readonly IDsseSigningService _signer;
|
||||||
|
private readonly ICryptoProfile _cryptoProfile;
|
||||||
|
private readonly ICryptoHash _cryptoHash;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
private string? _artifactId;
|
||||||
|
private string? _vulnerabilityId;
|
||||||
|
private string? _policyProfileId;
|
||||||
|
private string? _scanRunId;
|
||||||
|
private string _segmentPayloadType = DefaultSegmentPayloadType;
|
||||||
|
|
||||||
|
private string? _verdict;
|
||||||
|
private string? _verdictReason;
|
||||||
|
|
||||||
|
public ProofSpineBuilder(
|
||||||
|
IDsseSigningService signer,
|
||||||
|
ICryptoProfile cryptoProfile,
|
||||||
|
ICryptoHash cryptoHash,
|
||||||
|
TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||||
|
_cryptoProfile = cryptoProfile ?? throw new ArgumentNullException(nameof(cryptoProfile));
|
||||||
|
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||||
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProofSpineBuilder WithSegmentPayloadType(string payloadType)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(payloadType);
|
||||||
|
_segmentPayloadType = payloadType.Trim();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProofSpineBuilder ForArtifact(string artifactId)
|
||||||
|
{
|
||||||
|
_artifactId = artifactId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProofSpineBuilder ForVulnerability(string vulnId)
|
||||||
|
{
|
||||||
|
_vulnerabilityId = vulnId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProofSpineBuilder WithPolicyProfile(string profileId)
|
||||||
|
{
|
||||||
|
_policyProfileId = profileId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProofSpineBuilder WithScanRun(string scanRunId)
|
||||||
|
{
|
||||||
|
_scanRunId = scanRunId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds an SBOM slice segment showing component relevance.
|
||||||
|
/// </summary>
|
||||||
|
public ProofSpineBuilder AddSbomSlice(
|
||||||
|
string sbomDigest,
|
||||||
|
IReadOnlyList<string> relevantPurls,
|
||||||
|
string toolId,
|
||||||
|
string toolVersion)
|
||||||
|
{
|
||||||
|
var sortedPurls = relevantPurls
|
||||||
|
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||||
|
.Select(p => p.Trim())
|
||||||
|
.OrderBy(p => p, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var input = new SbomSliceInput(sbomDigest, sortedPurls);
|
||||||
|
_segments.Add(new ProofSegmentInput(
|
||||||
|
ProofSegmentType.SbomSlice,
|
||||||
|
ComputeCanonicalHash(input),
|
||||||
|
ComputeCanonicalHash(sortedPurls),
|
||||||
|
input,
|
||||||
|
toolId,
|
||||||
|
toolVersion));
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a vulnerability match segment.
|
||||||
|
/// </summary>
|
||||||
|
public ProofSpineBuilder AddMatch(
|
||||||
|
string vulnId,
|
||||||
|
string purl,
|
||||||
|
string matchedVersion,
|
||||||
|
string matchReason,
|
||||||
|
string toolId,
|
||||||
|
string toolVersion)
|
||||||
|
{
|
||||||
|
var input = new MatchInput(vulnId, purl, matchedVersion);
|
||||||
|
var result = new MatchResult(matchReason);
|
||||||
|
|
||||||
|
_segments.Add(new ProofSegmentInput(
|
||||||
|
ProofSegmentType.Match,
|
||||||
|
ComputeCanonicalHash(input),
|
||||||
|
ComputeCanonicalHash(result),
|
||||||
|
new MatchPayload(input, result),
|
||||||
|
toolId,
|
||||||
|
toolVersion));
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a reachability analysis segment.
|
||||||
|
/// </summary>
|
||||||
|
public ProofSpineBuilder AddReachability(
|
||||||
|
string callgraphDigest,
|
||||||
|
string latticeState,
|
||||||
|
double confidence,
|
||||||
|
IReadOnlyList<string>? pathWitness,
|
||||||
|
string toolId,
|
||||||
|
string toolVersion)
|
||||||
|
{
|
||||||
|
var witness = pathWitness?.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToArray();
|
||||||
|
var input = new ReachabilityInput(callgraphDigest);
|
||||||
|
var result = new ReachabilityResult(latticeState, confidence, witness);
|
||||||
|
|
||||||
|
_segments.Add(new ProofSegmentInput(
|
||||||
|
ProofSegmentType.Reachability,
|
||||||
|
ComputeCanonicalHash(input),
|
||||||
|
ComputeCanonicalHash(result),
|
||||||
|
new ReachabilityPayload(input, result),
|
||||||
|
toolId,
|
||||||
|
toolVersion));
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a guard analysis segment (feature flags, config gates).
|
||||||
|
/// </summary>
|
||||||
|
public ProofSpineBuilder AddGuardAnalysis(
|
||||||
|
IReadOnlyList<GuardCondition> guards,
|
||||||
|
bool allGuardsPassed,
|
||||||
|
string toolId,
|
||||||
|
string toolVersion)
|
||||||
|
{
|
||||||
|
var normalized = guards
|
||||||
|
.Where(g => g is not null)
|
||||||
|
.Select(g => new GuardCondition(g.Name.Trim(), g.Type.Trim(), g.Value.Trim(), g.Passed))
|
||||||
|
.OrderBy(g => g.Name, StringComparer.Ordinal)
|
||||||
|
.ThenBy(g => g.Type, StringComparer.Ordinal)
|
||||||
|
.ThenBy(g => g.Value, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var input = new GuardAnalysisInput(normalized);
|
||||||
|
var result = new GuardAnalysisResult(allGuardsPassed);
|
||||||
|
|
||||||
|
_segments.Add(new ProofSegmentInput(
|
||||||
|
ProofSegmentType.GuardAnalysis,
|
||||||
|
ComputeCanonicalHash(input),
|
||||||
|
ComputeCanonicalHash(result),
|
||||||
|
new GuardAnalysisPayload(input, result),
|
||||||
|
toolId,
|
||||||
|
toolVersion));
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds runtime observation evidence.
|
||||||
|
/// </summary>
|
||||||
|
public ProofSpineBuilder AddRuntimeObservation(
|
||||||
|
string runtimeFactsDigest,
|
||||||
|
bool wasObserved,
|
||||||
|
int hitCount,
|
||||||
|
string toolId,
|
||||||
|
string toolVersion)
|
||||||
|
{
|
||||||
|
var input = new RuntimeObservationInput(runtimeFactsDigest);
|
||||||
|
var result = new RuntimeObservationResult(wasObserved, hitCount);
|
||||||
|
|
||||||
|
_segments.Add(new ProofSegmentInput(
|
||||||
|
ProofSegmentType.RuntimeObservation,
|
||||||
|
ComputeCanonicalHash(input),
|
||||||
|
ComputeCanonicalHash(result),
|
||||||
|
new RuntimeObservationPayload(input, result),
|
||||||
|
toolId,
|
||||||
|
toolVersion));
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a policy evaluation segment (final verdict).
|
||||||
|
/// </summary>
|
||||||
|
public ProofSpineBuilder AddPolicyEval(
|
||||||
|
string policyDigest,
|
||||||
|
IReadOnlyDictionary<string, string> factors,
|
||||||
|
string verdict,
|
||||||
|
string verdictReason,
|
||||||
|
string toolId,
|
||||||
|
string toolVersion)
|
||||||
|
{
|
||||||
|
var normalizedFactors = factors
|
||||||
|
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key))
|
||||||
|
.OrderBy(pair => pair.Key.Trim(), StringComparer.Ordinal)
|
||||||
|
.Select(pair => new PolicyFactor(pair.Key.Trim(), pair.Value?.Trim() ?? string.Empty))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var input = new PolicyEvalInput(policyDigest, normalizedFactors);
|
||||||
|
var result = new PolicyEvalResult(verdict, verdictReason);
|
||||||
|
|
||||||
|
_segments.Add(new ProofSegmentInput(
|
||||||
|
ProofSegmentType.PolicyEval,
|
||||||
|
ComputeCanonicalHash(input),
|
||||||
|
ComputeCanonicalHash(result),
|
||||||
|
new PolicyEvalPayload(input, result),
|
||||||
|
toolId,
|
||||||
|
toolVersion));
|
||||||
|
|
||||||
|
_verdict = verdict;
|
||||||
|
_verdictReason = verdictReason;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the final ProofSpine with chained, signed segments.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ProofSpine> BuildAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
ValidateBuilder();
|
||||||
|
|
||||||
|
var ordered = _segments
|
||||||
|
.OrderBy(s => (int)s.Type)
|
||||||
|
.ThenBy(s => s.InputHash, StringComparer.Ordinal)
|
||||||
|
.ThenBy(s => s.ResultHash, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var builtSegments = new List<ProofSegment>(ordered.Count);
|
||||||
|
string? prevHash = null;
|
||||||
|
|
||||||
|
for (var i = 0; i < ordered.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var input = ordered[i];
|
||||||
|
var createdAt = _timeProvider.GetUtcNow();
|
||||||
|
var segmentId = ComputeSegmentId(input.Type, i, input.InputHash, input.ResultHash, prevHash);
|
||||||
|
|
||||||
|
var signedPayload = new ProofSegmentPayload(
|
||||||
|
SegmentType: input.Type.ToString(),
|
||||||
|
Index: i,
|
||||||
|
InputHash: input.InputHash,
|
||||||
|
ResultHash: input.ResultHash,
|
||||||
|
PrevSegmentHash: prevHash,
|
||||||
|
Payload: input.Payload,
|
||||||
|
ToolId: input.ToolId,
|
||||||
|
ToolVersion: input.ToolVersion,
|
||||||
|
CreatedAt: createdAt);
|
||||||
|
|
||||||
|
var envelope = await _signer.SignAsync(
|
||||||
|
signedPayload,
|
||||||
|
_segmentPayloadType,
|
||||||
|
_cryptoProfile,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var segment = new ProofSegment(
|
||||||
|
segmentId,
|
||||||
|
input.Type,
|
||||||
|
i,
|
||||||
|
input.InputHash,
|
||||||
|
input.ResultHash,
|
||||||
|
prevHash,
|
||||||
|
envelope,
|
||||||
|
input.ToolId,
|
||||||
|
input.ToolVersion,
|
||||||
|
ProofSegmentStatus.Verified,
|
||||||
|
createdAt);
|
||||||
|
|
||||||
|
builtSegments.Add(segment);
|
||||||
|
prevHash = segment.ResultHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootHash = ComputeRootHash(builtSegments.Select(s => s.ResultHash));
|
||||||
|
var spineId = ComputeSpineId(_artifactId!, _vulnerabilityId!, _policyProfileId!, rootHash);
|
||||||
|
|
||||||
|
return new ProofSpine(
|
||||||
|
spineId,
|
||||||
|
_artifactId!,
|
||||||
|
_vulnerabilityId!,
|
||||||
|
_policyProfileId!,
|
||||||
|
builtSegments.ToImmutableArray(),
|
||||||
|
_verdict ?? "under_investigation",
|
||||||
|
_verdictReason ?? "No policy evaluation completed",
|
||||||
|
rootHash,
|
||||||
|
_scanRunId!,
|
||||||
|
_timeProvider.GetUtcNow(),
|
||||||
|
SupersededBySpineId: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateBuilder()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_artifactId))
|
||||||
|
throw new InvalidOperationException("ArtifactId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
|
||||||
|
throw new InvalidOperationException("VulnerabilityId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(_policyProfileId))
|
||||||
|
throw new InvalidOperationException("PolicyProfileId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(_scanRunId))
|
||||||
|
throw new InvalidOperationException("ScanRunId is required.");
|
||||||
|
if (_segments.Count == 0)
|
||||||
|
throw new InvalidOperationException("At least one segment is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComputeCanonicalHash<T>(T value)
|
||||||
|
{
|
||||||
|
var bytes = CanonicalJson.SerializeToUtf8Bytes(value);
|
||||||
|
return _cryptoHash.ComputePrefixedHashForPurpose(bytes, HashPurpose.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComputeRootHash(IEnumerable<string> segmentResultHashes)
|
||||||
|
{
|
||||||
|
var concat = string.Join(":", segmentResultHashes);
|
||||||
|
return _cryptoHash.ComputePrefixedHashForPurpose(Encoding.UTF8.GetBytes(concat), HashPurpose.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
|
||||||
|
{
|
||||||
|
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
|
||||||
|
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
|
||||||
|
return hex[..32];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComputeSegmentId(ProofSegmentType type, int index, string inputHash, string resultHash, string? prevHash)
|
||||||
|
{
|
||||||
|
var data = $"{type}:{index}:{inputHash}:{resultHash}:{prevHash ?? "null"}";
|
||||||
|
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
|
||||||
|
return hex[..32];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ProofSegmentInput(
|
||||||
|
ProofSegmentType Type,
|
||||||
|
string InputHash,
|
||||||
|
string ResultHash,
|
||||||
|
object Payload,
|
||||||
|
string ToolId,
|
||||||
|
string ToolVersion);
|
||||||
|
|
||||||
|
private sealed record SbomSliceInput(
|
||||||
|
[property: JsonPropertyName("sbomDigest")] string SbomDigest,
|
||||||
|
[property: JsonPropertyName("relevantPurls")] IReadOnlyList<string> RelevantPurls);
|
||||||
|
|
||||||
|
private sealed record MatchInput(
|
||||||
|
[property: JsonPropertyName("vulnId")] string VulnId,
|
||||||
|
[property: JsonPropertyName("purl")] string Purl,
|
||||||
|
[property: JsonPropertyName("matchedVersion")] string MatchedVersion);
|
||||||
|
|
||||||
|
private sealed record MatchResult(
|
||||||
|
[property: JsonPropertyName("matchReason")] string MatchReason);
|
||||||
|
|
||||||
|
private sealed record MatchPayload(
|
||||||
|
[property: JsonPropertyName("input")] MatchInput Input,
|
||||||
|
[property: JsonPropertyName("result")] MatchResult Result);
|
||||||
|
|
||||||
|
private sealed record ReachabilityInput(
|
||||||
|
[property: JsonPropertyName("callgraphDigest")] string CallgraphDigest);
|
||||||
|
|
||||||
|
private sealed record ReachabilityResult(
|
||||||
|
[property: JsonPropertyName("latticeState")] string LatticeState,
|
||||||
|
[property: JsonPropertyName("confidence")] double Confidence,
|
||||||
|
[property: JsonPropertyName("pathWitness")] IReadOnlyList<string>? PathWitness);
|
||||||
|
|
||||||
|
private sealed record ReachabilityPayload(
|
||||||
|
[property: JsonPropertyName("input")] ReachabilityInput Input,
|
||||||
|
[property: JsonPropertyName("result")] ReachabilityResult Result);
|
||||||
|
|
||||||
|
private sealed record GuardAnalysisInput(
|
||||||
|
[property: JsonPropertyName("guards")] IReadOnlyList<GuardCondition> Guards);
|
||||||
|
|
||||||
|
private sealed record GuardAnalysisResult(
|
||||||
|
[property: JsonPropertyName("allGuardsPassed")] bool AllGuardsPassed);
|
||||||
|
|
||||||
|
private sealed record GuardAnalysisPayload(
|
||||||
|
[property: JsonPropertyName("input")] GuardAnalysisInput Input,
|
||||||
|
[property: JsonPropertyName("result")] GuardAnalysisResult Result);
|
||||||
|
|
||||||
|
private sealed record RuntimeObservationInput(
|
||||||
|
[property: JsonPropertyName("runtimeFactsDigest")] string RuntimeFactsDigest);
|
||||||
|
|
||||||
|
private sealed record RuntimeObservationResult(
|
||||||
|
[property: JsonPropertyName("wasObserved")] bool WasObserved,
|
||||||
|
[property: JsonPropertyName("hitCount")] int HitCount);
|
||||||
|
|
||||||
|
private sealed record RuntimeObservationPayload(
|
||||||
|
[property: JsonPropertyName("input")] RuntimeObservationInput Input,
|
||||||
|
[property: JsonPropertyName("result")] RuntimeObservationResult Result);
|
||||||
|
|
||||||
|
private sealed record PolicyFactor(
|
||||||
|
[property: JsonPropertyName("key")] string Key,
|
||||||
|
[property: JsonPropertyName("value")] string Value);
|
||||||
|
|
||||||
|
private sealed record PolicyEvalInput(
|
||||||
|
[property: JsonPropertyName("policyDigest")] string PolicyDigest,
|
||||||
|
[property: JsonPropertyName("factors")] IReadOnlyList<PolicyFactor> Factors);
|
||||||
|
|
||||||
|
private sealed record PolicyEvalResult(
|
||||||
|
[property: JsonPropertyName("verdict")] string Verdict,
|
||||||
|
[property: JsonPropertyName("verdictReason")] string VerdictReason);
|
||||||
|
|
||||||
|
private sealed record PolicyEvalPayload(
|
||||||
|
[property: JsonPropertyName("input")] PolicyEvalInput Input,
|
||||||
|
[property: JsonPropertyName("result")] PolicyEvalResult Result);
|
||||||
|
|
||||||
|
private sealed record ProofSegmentPayload(
|
||||||
|
[property: JsonPropertyName("segmentType")] string SegmentType,
|
||||||
|
[property: JsonPropertyName("index")] int Index,
|
||||||
|
[property: JsonPropertyName("inputHash")] string InputHash,
|
||||||
|
[property: JsonPropertyName("resultHash")] string ResultHash,
|
||||||
|
[property: JsonPropertyName("prevSegmentHash")] string? PrevSegmentHash,
|
||||||
|
[property: JsonPropertyName("payload")] object Payload,
|
||||||
|
[property: JsonPropertyName("toolId")] string ToolId,
|
||||||
|
[property: JsonPropertyName("toolVersion")] string ToolVersion,
|
||||||
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
|
|
||||||
|
public sealed record ProofSpineSummary(
|
||||||
|
string SpineId,
|
||||||
|
string ArtifactId,
|
||||||
|
string VulnerabilityId,
|
||||||
|
string Verdict,
|
||||||
|
int SegmentCount,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using StellaOps.Cryptography;
|
||||||
|
using StellaOps.Replay.Core;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.ProofSpine;
|
||||||
|
|
||||||
|
public sealed class ProofSpineVerifier
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions SignedPayloadOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IDsseSigningService _signingService;
|
||||||
|
private readonly ICryptoHash _cryptoHash;
|
||||||
|
|
||||||
|
public ProofSpineVerifier(IDsseSigningService signingService, ICryptoHash cryptoHash)
|
||||||
|
{
|
||||||
|
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||||
|
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProofSpineVerificationResult> VerifyAsync(ProofSpine spine, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(spine);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var spineErrors = new List<string>();
|
||||||
|
var segments = spine.Segments ?? Array.Empty<ProofSegment>();
|
||||||
|
var segmentResults = new ProofSegmentVerificationResult[segments.Count];
|
||||||
|
|
||||||
|
string? prevHash = null;
|
||||||
|
for (var i = 0; i < segments.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var segment = segments[i];
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
if (segment.Index != i)
|
||||||
|
{
|
||||||
|
errors.Add($"segment_index_mismatch:{segment.Index}->{i}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i == 0)
|
||||||
|
{
|
||||||
|
if (segment.PrevSegmentHash is not null)
|
||||||
|
{
|
||||||
|
errors.Add("prev_hash_expected_null");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!string.Equals(segment.PrevSegmentHash, prevHash, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
errors.Add("prev_hash_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedSegmentId = ComputeSegmentId(segment.SegmentType, i, segment.InputHash, segment.ResultHash, prevHash);
|
||||||
|
if (!string.Equals(segment.SegmentId, expectedSegmentId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
errors.Add("segment_id_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
var dsseOutcome = await _signingService.VerifyAsync(segment.Envelope, cancellationToken).ConfigureAwait(false);
|
||||||
|
var status = dsseOutcome switch
|
||||||
|
{
|
||||||
|
{ IsValid: true, IsTrusted: true } => ProofSegmentStatus.Verified,
|
||||||
|
{ IsValid: true, IsTrusted: false } => ProofSegmentStatus.Untrusted,
|
||||||
|
{ IsValid: false, FailureReason: "dsse_key_not_trusted" } => ProofSegmentStatus.Untrusted,
|
||||||
|
_ => ProofSegmentStatus.Invalid
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!dsseOutcome.IsValid && !string.IsNullOrWhiteSpace(dsseOutcome.FailureReason))
|
||||||
|
{
|
||||||
|
errors.Add(dsseOutcome.FailureReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryReadSignedPayload(segment.Envelope, out var signed, out var payloadError))
|
||||||
|
{
|
||||||
|
errors.Add(payloadError ?? "signed_payload_invalid");
|
||||||
|
status = ProofSegmentStatus.Invalid;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!string.Equals(signed.SegmentType, segment.SegmentType.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
errors.Add("signed_segment_type_mismatch");
|
||||||
|
status = ProofSegmentStatus.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signed.Index != i)
|
||||||
|
{
|
||||||
|
errors.Add("signed_index_mismatch");
|
||||||
|
status = ProofSegmentStatus.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(signed.InputHash, segment.InputHash, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(signed.ResultHash, segment.ResultHash, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(signed.PrevSegmentHash, segment.PrevSegmentHash, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
errors.Add("signed_fields_mismatch");
|
||||||
|
status = ProofSegmentStatus.Invalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentResults[i] = new ProofSegmentVerificationResult(segment.SegmentId, status, errors.ToArray());
|
||||||
|
prevHash = segment.ResultHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedRootHash = ComputeRootHash(segments.Select(s => s.ResultHash));
|
||||||
|
if (!string.Equals(spine.RootHash, expectedRootHash, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
spineErrors.Add("root_hash_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedSpineId = ComputeSpineId(spine.ArtifactId, spine.VulnerabilityId, spine.PolicyProfileId, expectedRootHash);
|
||||||
|
if (!string.Equals(spine.SpineId, expectedSpineId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
spineErrors.Add("spine_id_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = spineErrors.Count == 0 && segmentResults.All(r => r.Status is ProofSegmentStatus.Verified or ProofSegmentStatus.Untrusted);
|
||||||
|
return new ProofSpineVerificationResult(ok, spineErrors.ToArray(), segmentResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadSignedPayload(DsseEnvelope envelope, out SignedProofSegmentPayload payload, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
payload = default!;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = Convert.FromBase64String(envelope.Payload);
|
||||||
|
payload = JsonSerializer.Deserialize<SignedProofSegmentPayload>(bytes, SignedPayloadOptions)
|
||||||
|
?? throw new InvalidOperationException("signed_payload_null");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
error = "dsse_payload_not_base64";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
error = "signed_payload_json_invalid";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComputeRootHash(IEnumerable<string> segmentResultHashes)
|
||||||
|
{
|
||||||
|
var concat = string.Join(":", segmentResultHashes);
|
||||||
|
return _cryptoHash.ComputePrefixedHashForPurpose(Encoding.UTF8.GetBytes(concat), HashPurpose.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
|
||||||
|
{
|
||||||
|
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
|
||||||
|
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
|
||||||
|
return hex[..32];
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ComputeSegmentId(ProofSegmentType type, int index, string inputHash, string resultHash, string? prevHash)
|
||||||
|
{
|
||||||
|
var data = $"{type}:{index}:{inputHash}:{resultHash}:{prevHash ?? "null"}";
|
||||||
|
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
|
||||||
|
return hex[..32];
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record SignedProofSegmentPayload(
|
||||||
|
[property: JsonPropertyName("segmentType")] string SegmentType,
|
||||||
|
[property: JsonPropertyName("index")] int Index,
|
||||||
|
[property: JsonPropertyName("inputHash")] string InputHash,
|
||||||
|
[property: JsonPropertyName("resultHash")] string ResultHash,
|
||||||
|
[property: JsonPropertyName("prevSegmentHash")] string? PrevSegmentHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProofSpineVerificationResult(
|
||||||
|
bool IsValid,
|
||||||
|
IReadOnlyList<string> Errors,
|
||||||
|
IReadOnlyList<ProofSegmentVerificationResult> Segments);
|
||||||
|
|
||||||
|
public sealed record ProofSegmentVerificationResult(
|
||||||
|
string SegmentId,
|
||||||
|
ProofSegmentStatus Status,
|
||||||
|
IReadOnlyList<string> Errors);
|
||||||
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Collections.Immutable;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds ProofSpine chains from evidence segments.
|
|
||||||
/// Ensures deterministic ordering and cryptographic chaining.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ProofSpineBuilder
|
|
||||||
{
|
|
||||||
private readonly List<ProofSegmentInput> _segments = new();
|
|
||||||
private readonly IDsseSigningService _signer;
|
|
||||||
private readonly ICryptoProfile _cryptoProfile;
|
|
||||||
private readonly TimeProvider _timeProvider;
|
|
||||||
|
|
||||||
private string? _artifactId;
|
|
||||||
private string? _vulnerabilityId;
|
|
||||||
private string? _policyProfileId;
|
|
||||||
private string? _scanRunId;
|
|
||||||
|
|
||||||
public ProofSpineBuilder(
|
|
||||||
IDsseSigningService signer,
|
|
||||||
ICryptoProfile cryptoProfile,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
{
|
|
||||||
_signer = signer;
|
|
||||||
_cryptoProfile = cryptoProfile;
|
|
||||||
_timeProvider = timeProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ProofSpineBuilder ForArtifact(string artifactId)
|
|
||||||
{
|
|
||||||
_artifactId = artifactId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ProofSpineBuilder ForVulnerability(string vulnId)
|
|
||||||
{
|
|
||||||
_vulnerabilityId = vulnId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ProofSpineBuilder WithPolicyProfile(string profileId)
|
|
||||||
{
|
|
||||||
_policyProfileId = profileId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ProofSpineBuilder WithScanRun(string scanRunId)
|
|
||||||
{
|
|
||||||
_scanRunId = scanRunId;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds an SBOM slice segment showing component relevance.
|
|
||||||
/// </summary>
|
|
||||||
public ProofSpineBuilder AddSbomSlice(
|
|
||||||
string sbomDigest,
|
|
||||||
IReadOnlyList<string> relevantPurls,
|
|
||||||
string toolId,
|
|
||||||
string toolVersion)
|
|
||||||
{
|
|
||||||
var input = new SbomSliceInput(sbomDigest, relevantPurls);
|
|
||||||
var inputHash = ComputeCanonicalHash(input);
|
|
||||||
var resultHash = ComputeCanonicalHash(relevantPurls);
|
|
||||||
|
|
||||||
_segments.Add(new ProofSegmentInput(
|
|
||||||
ProofSegmentType.SbomSlice,
|
|
||||||
inputHash,
|
|
||||||
resultHash,
|
|
||||||
input,
|
|
||||||
toolId,
|
|
||||||
toolVersion));
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a vulnerability match segment.
|
|
||||||
/// </summary>
|
|
||||||
public ProofSpineBuilder AddMatch(
|
|
||||||
string vulnId,
|
|
||||||
string purl,
|
|
||||||
string matchedVersion,
|
|
||||||
string matchReason,
|
|
||||||
string toolId,
|
|
||||||
string toolVersion)
|
|
||||||
{
|
|
||||||
var input = new MatchInput(vulnId, purl, matchedVersion);
|
|
||||||
var result = new MatchResult(matchReason);
|
|
||||||
|
|
||||||
_segments.Add(new ProofSegmentInput(
|
|
||||||
ProofSegmentType.Match,
|
|
||||||
ComputeCanonicalHash(input),
|
|
||||||
ComputeCanonicalHash(result),
|
|
||||||
new { Input = input, Result = result },
|
|
||||||
toolId,
|
|
||||||
toolVersion));
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a reachability analysis segment.
|
|
||||||
/// </summary>
|
|
||||||
public ProofSpineBuilder AddReachability(
|
|
||||||
string callgraphDigest,
|
|
||||||
string latticeState,
|
|
||||||
double confidence,
|
|
||||||
IReadOnlyList<string>? pathWitness,
|
|
||||||
string toolId,
|
|
||||||
string toolVersion)
|
|
||||||
{
|
|
||||||
var input = new ReachabilityInput(callgraphDigest);
|
|
||||||
var result = new ReachabilityResult(latticeState, confidence, pathWitness);
|
|
||||||
|
|
||||||
_segments.Add(new ProofSegmentInput(
|
|
||||||
ProofSegmentType.Reachability,
|
|
||||||
ComputeCanonicalHash(input),
|
|
||||||
ComputeCanonicalHash(result),
|
|
||||||
new { Input = input, Result = result },
|
|
||||||
toolId,
|
|
||||||
toolVersion));
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a guard analysis segment (feature flags, config gates).
|
|
||||||
/// </summary>
|
|
||||||
public ProofSpineBuilder AddGuardAnalysis(
|
|
||||||
IReadOnlyList<GuardCondition> guards,
|
|
||||||
bool allGuardsPassed,
|
|
||||||
string toolId,
|
|
||||||
string toolVersion)
|
|
||||||
{
|
|
||||||
var input = new GuardAnalysisInput(guards);
|
|
||||||
var result = new GuardAnalysisResult(allGuardsPassed);
|
|
||||||
|
|
||||||
_segments.Add(new ProofSegmentInput(
|
|
||||||
ProofSegmentType.GuardAnalysis,
|
|
||||||
ComputeCanonicalHash(input),
|
|
||||||
ComputeCanonicalHash(result),
|
|
||||||
new { Input = input, Result = result },
|
|
||||||
toolId,
|
|
||||||
toolVersion));
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds runtime observation evidence.
|
|
||||||
/// </summary>
|
|
||||||
public ProofSpineBuilder AddRuntimeObservation(
|
|
||||||
string runtimeFactsDigest,
|
|
||||||
bool wasObserved,
|
|
||||||
int hitCount,
|
|
||||||
string toolId,
|
|
||||||
string toolVersion)
|
|
||||||
{
|
|
||||||
var input = new RuntimeObservationInput(runtimeFactsDigest);
|
|
||||||
var result = new RuntimeObservationResult(wasObserved, hitCount);
|
|
||||||
|
|
||||||
_segments.Add(new ProofSegmentInput(
|
|
||||||
ProofSegmentType.RuntimeObservation,
|
|
||||||
ComputeCanonicalHash(input),
|
|
||||||
ComputeCanonicalHash(result),
|
|
||||||
new { Input = input, Result = result },
|
|
||||||
toolId,
|
|
||||||
toolVersion));
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds policy evaluation segment with final verdict.
|
|
||||||
/// </summary>
|
|
||||||
public ProofSpineBuilder AddPolicyEval(
|
|
||||||
string policyDigest,
|
|
||||||
string verdict,
|
|
||||||
string verdictReason,
|
|
||||||
IReadOnlyDictionary<string, object> factors,
|
|
||||||
string toolId,
|
|
||||||
string toolVersion)
|
|
||||||
{
|
|
||||||
var input = new PolicyEvalInput(policyDigest, factors);
|
|
||||||
var result = new PolicyEvalResult(verdict, verdictReason);
|
|
||||||
|
|
||||||
_segments.Add(new ProofSegmentInput(
|
|
||||||
ProofSegmentType.PolicyEval,
|
|
||||||
ComputeCanonicalHash(input),
|
|
||||||
ComputeCanonicalHash(result),
|
|
||||||
new { Input = input, Result = result },
|
|
||||||
toolId,
|
|
||||||
toolVersion));
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the final ProofSpine with chained, signed segments.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<ProofSpine> BuildAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ValidateBuilder();
|
|
||||||
|
|
||||||
// Sort segments by type (predetermined order)
|
|
||||||
var orderedSegments = _segments
|
|
||||||
.OrderBy(s => (int)s.Type)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var builtSegments = new List<ProofSegment>();
|
|
||||||
string? prevHash = null;
|
|
||||||
|
|
||||||
for (var i = 0; i < orderedSegments.Count; i++)
|
|
||||||
{
|
|
||||||
var input = orderedSegments[i];
|
|
||||||
var createdAt = _timeProvider.GetUtcNow();
|
|
||||||
|
|
||||||
// Build payload for signing
|
|
||||||
var payload = new ProofSegmentPayload(
|
|
||||||
input.Type.ToString(),
|
|
||||||
i,
|
|
||||||
input.InputHash,
|
|
||||||
input.ResultHash,
|
|
||||||
prevHash,
|
|
||||||
input.Payload,
|
|
||||||
input.ToolId,
|
|
||||||
input.ToolVersion,
|
|
||||||
createdAt);
|
|
||||||
|
|
||||||
// Sign with DSSE
|
|
||||||
var envelope = await _signer.SignAsync(
|
|
||||||
payload,
|
|
||||||
_cryptoProfile,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
var segmentId = ComputeSegmentId(input, i, prevHash);
|
|
||||||
var segment = new ProofSegment(
|
|
||||||
segmentId,
|
|
||||||
input.Type,
|
|
||||||
i,
|
|
||||||
input.InputHash,
|
|
||||||
input.ResultHash,
|
|
||||||
prevHash,
|
|
||||||
envelope,
|
|
||||||
input.ToolId,
|
|
||||||
input.ToolVersion,
|
|
||||||
ProofSegmentStatus.Verified,
|
|
||||||
createdAt);
|
|
||||||
|
|
||||||
builtSegments.Add(segment);
|
|
||||||
prevHash = segment.ResultHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute root hash = hash(concat of all segment result hashes)
|
|
||||||
var rootHash = ComputeRootHash(builtSegments);
|
|
||||||
|
|
||||||
// Compute deterministic spine ID
|
|
||||||
var spineId = ComputeSpineId(_artifactId!, _vulnerabilityId!, _policyProfileId!, rootHash);
|
|
||||||
|
|
||||||
// Extract verdict from policy eval segment
|
|
||||||
var (verdict, verdictReason) = ExtractVerdict(builtSegments);
|
|
||||||
|
|
||||||
return new ProofSpine(
|
|
||||||
spineId,
|
|
||||||
_artifactId!,
|
|
||||||
_vulnerabilityId!,
|
|
||||||
_policyProfileId!,
|
|
||||||
builtSegments.ToImmutableArray(),
|
|
||||||
verdict,
|
|
||||||
verdictReason,
|
|
||||||
rootHash,
|
|
||||||
_scanRunId!,
|
|
||||||
_timeProvider.GetUtcNow(),
|
|
||||||
SupersededBySpineId: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ValidateBuilder()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(_artifactId))
|
|
||||||
throw new InvalidOperationException("ArtifactId is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
|
|
||||||
throw new InvalidOperationException("VulnerabilityId is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(_policyProfileId))
|
|
||||||
throw new InvalidOperationException("PolicyProfileId is required");
|
|
||||||
if (string.IsNullOrWhiteSpace(_scanRunId))
|
|
||||||
throw new InvalidOperationException("ScanRunId is required");
|
|
||||||
if (_segments.Count == 0)
|
|
||||||
throw new InvalidOperationException("At least one segment is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeCanonicalHash(object input)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(input, CanonicalJsonOptions);
|
|
||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
|
||||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeSegmentId(ProofSegmentInput input, int index, string? prevHash)
|
|
||||||
{
|
|
||||||
var data = $"{input.Type}:{index}:{input.InputHash}:{input.ResultHash}:{prevHash ?? "null"}";
|
|
||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
|
|
||||||
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeRootHash(IEnumerable<ProofSegment> segments)
|
|
||||||
{
|
|
||||||
var concat = string.Join(":", segments.Select(s => s.ResultHash));
|
|
||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(concat));
|
|
||||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
|
|
||||||
{
|
|
||||||
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
|
|
||||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
|
|
||||||
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (string Verdict, string VerdictReason) ExtractVerdict(List<ProofSegment> segments)
|
|
||||||
{
|
|
||||||
// Default verdict if no policy eval segment
|
|
||||||
return ("under_investigation", "No policy evaluation completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
WriteIndented = false,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supporting input types
|
|
||||||
internal sealed record ProofSegmentInput(
|
|
||||||
ProofSegmentType Type,
|
|
||||||
string InputHash,
|
|
||||||
string ResultHash,
|
|
||||||
object Payload,
|
|
||||||
string ToolId,
|
|
||||||
string ToolVersion);
|
|
||||||
|
|
||||||
internal sealed record SbomSliceInput(string SbomDigest, IReadOnlyList<string> RelevantPurls);
|
|
||||||
internal sealed record MatchInput(string VulnId, string Purl, string MatchedVersion);
|
|
||||||
internal sealed record MatchResult(string MatchReason);
|
|
||||||
internal sealed record ReachabilityInput(string CallgraphDigest);
|
|
||||||
internal sealed record ReachabilityResult(string LatticeState, double Confidence, IReadOnlyList<string>? PathWitness);
|
|
||||||
internal sealed record GuardAnalysisInput(IReadOnlyList<GuardCondition> Guards);
|
|
||||||
internal sealed record GuardAnalysisResult(bool AllGuardsPassed);
|
|
||||||
internal sealed record RuntimeObservationInput(string RuntimeFactsDigest);
|
|
||||||
internal sealed record RuntimeObservationResult(bool WasObserved, int HitCount);
|
|
||||||
internal sealed record PolicyEvalInput(string PolicyDigest, IReadOnlyDictionary<string, object> Factors);
|
|
||||||
internal sealed record PolicyEvalResult(string Verdict, string VerdictReason);
|
|
||||||
internal sealed record ProofSegmentPayload(
|
|
||||||
string SegmentType, int Index, string InputHash, string ResultHash,
|
|
||||||
string? PrevSegmentHash, object Payload, string ToolId, string ToolVersion,
|
|
||||||
DateTimeOffset CreatedAt);
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Collections.Immutable;
|
|
||||||
|
|
||||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a complete verifiable decision chain from SBOM to VEX verdict.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ProofSpine(
|
|
||||||
string SpineId,
|
|
||||||
string ArtifactId,
|
|
||||||
string VulnerabilityId,
|
|
||||||
string PolicyProfileId,
|
|
||||||
IReadOnlyList<ProofSegment> Segments,
|
|
||||||
string Verdict,
|
|
||||||
string VerdictReason,
|
|
||||||
string RootHash,
|
|
||||||
string ScanRunId,
|
|
||||||
DateTimeOffset CreatedAt,
|
|
||||||
string? SupersededBySpineId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A single evidence segment in the proof chain.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record ProofSegment(
|
|
||||||
string SegmentId,
|
|
||||||
ProofSegmentType SegmentType,
|
|
||||||
int Index,
|
|
||||||
string InputHash,
|
|
||||||
string ResultHash,
|
|
||||||
string? PrevSegmentHash,
|
|
||||||
DsseEnvelope Envelope,
|
|
||||||
string ToolId,
|
|
||||||
string ToolVersion,
|
|
||||||
ProofSegmentStatus Status,
|
|
||||||
DateTimeOffset CreatedAt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Segment types in execution order.
|
|
||||||
/// </summary>
|
|
||||||
public enum ProofSegmentType
|
|
||||||
{
|
|
||||||
SbomSlice = 1, // Component relevance extraction
|
|
||||||
Match = 2, // SBOM-to-vulnerability mapping
|
|
||||||
Reachability = 3, // Symbol reachability analysis
|
|
||||||
GuardAnalysis = 4, // Config/feature flag gates
|
|
||||||
RuntimeObservation = 5, // Runtime evidence correlation
|
|
||||||
PolicyEval = 6 // Lattice decision computation
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Verification status of a segment.
|
|
||||||
/// </summary>
|
|
||||||
public enum ProofSegmentStatus
|
|
||||||
{
|
|
||||||
Pending = 0,
|
|
||||||
Verified = 1,
|
|
||||||
Partial = 2, // Some evidence missing but chain valid
|
|
||||||
Invalid = 3, // Signature verification failed
|
|
||||||
Untrusted = 4 // Key not in trust store
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DSSE envelope wrapper for signed content.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DsseEnvelope(
|
|
||||||
string PayloadType,
|
|
||||||
byte[] Payload,
|
|
||||||
IReadOnlyList<DsseSignature> Signatures);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A signature in a DSSE envelope.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DsseSignature(
|
|
||||||
string KeyId,
|
|
||||||
byte[] Sig);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Guard condition for feature flag or config gate analysis.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record GuardCondition(
|
|
||||||
string Name,
|
|
||||||
string Type,
|
|
||||||
string Value,
|
|
||||||
bool Passed);
|
|
||||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Options;
|
|||||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||||
using StellaOps.Scanner.Core.Contracts;
|
using StellaOps.Scanner.Core.Contracts;
|
||||||
using StellaOps.Scanner.EntryTrace;
|
using StellaOps.Scanner.EntryTrace;
|
||||||
|
using StellaOps.Scanner.ProofSpine;
|
||||||
using StellaOps.Scanner.Storage.ObjectStore;
|
using StellaOps.Scanner.Storage.ObjectStore;
|
||||||
using StellaOps.Scanner.Storage.Postgres;
|
using StellaOps.Scanner.Storage.Postgres;
|
||||||
using StellaOps.Scanner.Storage.Repositories;
|
using StellaOps.Scanner.Storage.Repositories;
|
||||||
@@ -73,6 +74,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<EntryTraceRepository>();
|
services.AddScoped<EntryTraceRepository>();
|
||||||
services.AddScoped<RubyPackageInventoryRepository>();
|
services.AddScoped<RubyPackageInventoryRepository>();
|
||||||
services.AddScoped<BunPackageInventoryRepository>();
|
services.AddScoped<BunPackageInventoryRepository>();
|
||||||
|
services.AddScoped<IProofSpineRepository, PostgresProofSpineRepository>();
|
||||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||||
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- proof spine storage schema (startup migration)
|
||||||
|
-- schema: created externally via search_path; tables unqualified for scanner schema compatibility
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proof_spines (
|
||||||
|
spine_id TEXT PRIMARY KEY,
|
||||||
|
artifact_id TEXT NOT NULL,
|
||||||
|
vuln_id TEXT NOT NULL,
|
||||||
|
policy_profile_id TEXT NOT NULL,
|
||||||
|
verdict TEXT NOT NULL,
|
||||||
|
verdict_reason TEXT,
|
||||||
|
root_hash TEXT NOT NULL,
|
||||||
|
scan_run_id TEXT NOT NULL,
|
||||||
|
segment_count INT NOT NULL,
|
||||||
|
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
superseded_by_spine_id TEXT REFERENCES proof_spines(spine_id),
|
||||||
|
|
||||||
|
CONSTRAINT proof_spines_unique_decision UNIQUE (artifact_id, vuln_id, policy_profile_id, root_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_spines_lookup
|
||||||
|
ON proof_spines(artifact_id, vuln_id, policy_profile_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_spines_scan_run
|
||||||
|
ON proof_spines(scan_run_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_spines_created_at
|
||||||
|
ON proof_spines(created_at_utc DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proof_segments (
|
||||||
|
segment_id TEXT PRIMARY KEY,
|
||||||
|
spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
|
||||||
|
idx INT NOT NULL,
|
||||||
|
segment_type TEXT NOT NULL,
|
||||||
|
input_hash TEXT NOT NULL,
|
||||||
|
result_hash TEXT NOT NULL,
|
||||||
|
prev_segment_hash TEXT,
|
||||||
|
envelope_json TEXT NOT NULL,
|
||||||
|
tool_id TEXT NOT NULL,
|
||||||
|
tool_version TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT proof_segments_unique_index UNIQUE (spine_id, idx)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_segments_spine_idx
|
||||||
|
ON proof_segments(spine_id, idx);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_segments_type
|
||||||
|
ON proof_segments(segment_type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proof_spine_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
old_spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
|
||||||
|
new_spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_old
|
||||||
|
ON proof_spine_history(old_spine_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_new
|
||||||
|
ON proof_spine_history(new_spine_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_created_at
|
||||||
|
ON proof_spine_history(created_at_utc DESC);
|
||||||
|
|
||||||
@@ -3,4 +3,5 @@ namespace StellaOps.Scanner.Storage.Postgres.Migrations;
|
|||||||
internal static class MigrationIds
|
internal static class MigrationIds
|
||||||
{
|
{
|
||||||
public const string CreateTables = "001_create_tables.sql";
|
public const string CreateTables = "001_create_tables.sql";
|
||||||
|
public const string ProofSpineTables = "002_proof_spine_tables.sql";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||||
|
using StellaOps.Replay.Core;
|
||||||
|
using StellaOps.Scanner.ProofSpine;
|
||||||
|
using StellaOps.Scanner.Storage.Postgres;
|
||||||
|
using ProofSpineModel = StellaOps.Scanner.ProofSpine.ProofSpine;
|
||||||
|
|
||||||
|
namespace StellaOps.Scanner.Storage.Repositories;
|
||||||
|
|
||||||
|
public sealed class PostgresProofSpineRepository : RepositoryBase<ScannerDataSource>, IProofSpineRepository
|
||||||
|
{
|
||||||
|
private const string Tenant = "";
|
||||||
|
|
||||||
|
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||||
|
|
||||||
|
private string SpinesTable => $"{SchemaName}.proof_spines";
|
||||||
|
|
||||||
|
private string SegmentsTable => $"{SchemaName}.proof_segments";
|
||||||
|
|
||||||
|
private string HistoryTable => $"{SchemaName}.proof_spine_history";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions LenientJson = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public PostgresProofSpineRepository(
|
||||||
|
ScannerDataSource dataSource,
|
||||||
|
ILogger<PostgresProofSpineRepository> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
: base(dataSource, logger)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProofSpineModel?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(spineId);
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||||
|
verdict, verdict_reason, root_hash, scan_run_id,
|
||||||
|
created_at_utc, superseded_by_spine_id
|
||||||
|
FROM {SpinesTable}
|
||||||
|
WHERE spine_id = @spine_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
return QuerySingleOrDefaultAsync(
|
||||||
|
Tenant,
|
||||||
|
sql,
|
||||||
|
cmd => AddParameter(cmd, "spine_id", spineId.Trim()),
|
||||||
|
MapSpine,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProofSpineModel?> GetByDecisionAsync(
|
||||||
|
string artifactId,
|
||||||
|
string vulnId,
|
||||||
|
string policyProfileId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(vulnId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(policyProfileId);
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||||
|
verdict, verdict_reason, root_hash, scan_run_id,
|
||||||
|
created_at_utc, superseded_by_spine_id
|
||||||
|
FROM {SpinesTable}
|
||||||
|
WHERE artifact_id = @artifact_id
|
||||||
|
AND vuln_id = @vuln_id
|
||||||
|
AND policy_profile_id = @policy_profile_id
|
||||||
|
ORDER BY created_at_utc DESC, spine_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
return QuerySingleOrDefaultAsync(
|
||||||
|
Tenant,
|
||||||
|
sql,
|
||||||
|
cmd =>
|
||||||
|
{
|
||||||
|
AddParameter(cmd, "artifact_id", artifactId.Trim());
|
||||||
|
AddParameter(cmd, "vuln_id", vulnId.Trim());
|
||||||
|
AddParameter(cmd, "policy_profile_id", policyProfileId.Trim());
|
||||||
|
},
|
||||||
|
MapSpine,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ProofSpineModel>> GetByScanRunAsync(
|
||||||
|
string scanRunId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(scanRunId);
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||||
|
verdict, verdict_reason, root_hash, scan_run_id,
|
||||||
|
created_at_utc, superseded_by_spine_id
|
||||||
|
FROM {SpinesTable}
|
||||||
|
WHERE scan_run_id = @scan_run_id
|
||||||
|
ORDER BY created_at_utc DESC, spine_id DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
return QueryAsync(
|
||||||
|
Tenant,
|
||||||
|
sql,
|
||||||
|
cmd => AddParameter(cmd, "scan_run_id", scanRunId.Trim()),
|
||||||
|
MapSpine,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProofSpineModel> SaveAsync(ProofSpineModel spine, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(spine);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (spine.Segments is null || spine.Segments.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ProofSpine requires at least one segment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdAt = spine.CreatedAt == default ? _timeProvider.GetUtcNow() : spine.CreatedAt;
|
||||||
|
|
||||||
|
await using var connection = await DataSource.OpenConnectionAsync(Tenant, "writer", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var insertSpine = $"""
|
||||||
|
INSERT INTO {SpinesTable} (
|
||||||
|
spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||||
|
verdict, verdict_reason, root_hash, scan_run_id,
|
||||||
|
segment_count, created_at_utc, superseded_by_spine_id
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@spine_id, @artifact_id, @vuln_id, @policy_profile_id,
|
||||||
|
@verdict, @verdict_reason, @root_hash, @scan_run_id,
|
||||||
|
@segment_count, @created_at_utc, @superseded_by_spine_id
|
||||||
|
)
|
||||||
|
ON CONFLICT (spine_id) DO NOTHING
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using (var command = CreateCommand(insertSpine, connection))
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
AddParameter(command, "spine_id", spine.SpineId);
|
||||||
|
AddParameter(command, "artifact_id", spine.ArtifactId);
|
||||||
|
AddParameter(command, "vuln_id", spine.VulnerabilityId);
|
||||||
|
AddParameter(command, "policy_profile_id", spine.PolicyProfileId);
|
||||||
|
AddParameter(command, "verdict", spine.Verdict);
|
||||||
|
AddParameter(command, "verdict_reason", spine.VerdictReason);
|
||||||
|
AddParameter(command, "root_hash", spine.RootHash);
|
||||||
|
AddParameter(command, "scan_run_id", spine.ScanRunId);
|
||||||
|
AddParameter(command, "segment_count", spine.Segments.Count);
|
||||||
|
AddParameter(command, "created_at_utc", createdAt);
|
||||||
|
AddParameter(command, "superseded_by_spine_id", spine.SupersededBySpineId);
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var insertSegment = $"""
|
||||||
|
INSERT INTO {SegmentsTable} (
|
||||||
|
segment_id, spine_id, idx, segment_type, input_hash, result_hash, prev_segment_hash,
|
||||||
|
envelope_json, tool_id, tool_version, status, created_at_utc
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@segment_id, @spine_id, @idx, @segment_type, @input_hash, @result_hash, @prev_segment_hash,
|
||||||
|
@envelope_json, @tool_id, @tool_version, @status, @created_at_utc
|
||||||
|
)
|
||||||
|
ON CONFLICT (segment_id) DO NOTHING
|
||||||
|
""";
|
||||||
|
|
||||||
|
foreach (var segment in spine.Segments.OrderBy(s => s.Index))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
await using var command = CreateCommand(insertSegment, connection);
|
||||||
|
command.Transaction = transaction;
|
||||||
|
|
||||||
|
AddParameter(command, "segment_id", segment.SegmentId);
|
||||||
|
AddParameter(command, "spine_id", spine.SpineId);
|
||||||
|
AddParameter(command, "idx", segment.Index);
|
||||||
|
AddParameter(command, "segment_type", segment.SegmentType.ToString());
|
||||||
|
AddParameter(command, "input_hash", segment.InputHash);
|
||||||
|
AddParameter(command, "result_hash", segment.ResultHash);
|
||||||
|
AddParameter(command, "prev_segment_hash", segment.PrevSegmentHash);
|
||||||
|
AddParameter(command, "envelope_json", SerializeEnvelope(segment.Envelope));
|
||||||
|
AddParameter(command, "tool_id", segment.ToolId);
|
||||||
|
AddParameter(command, "tool_version", segment.ToolVersion);
|
||||||
|
AddParameter(command, "status", segment.Status.ToString());
|
||||||
|
AddParameter(command, "created_at_utc", segment.CreatedAt == default ? createdAt : segment.CreatedAt);
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return spine with { CreatedAt = createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SupersedeAsync(
|
||||||
|
string oldSpineId,
|
||||||
|
string newSpineId,
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(oldSpineId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(newSpineId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||||
|
|
||||||
|
await using var connection = await DataSource.OpenConnectionAsync(Tenant, "writer", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var update = $"""
|
||||||
|
UPDATE {SpinesTable}
|
||||||
|
SET superseded_by_spine_id = @new_spine_id
|
||||||
|
WHERE spine_id = @old_spine_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using (var command = CreateCommand(update, connection))
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
AddParameter(command, "old_spine_id", oldSpineId.Trim());
|
||||||
|
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var insertHistory = $"""
|
||||||
|
INSERT INTO {HistoryTable} (id, old_spine_id, new_spine_id, reason, created_at_utc)
|
||||||
|
VALUES (@id, @old_spine_id, @new_spine_id, @reason, @created_at_utc)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using (var command = CreateCommand(insertHistory, connection))
|
||||||
|
{
|
||||||
|
command.Transaction = transaction;
|
||||||
|
AddParameter(command, "id", Guid.NewGuid().ToString("N"));
|
||||||
|
AddParameter(command, "old_spine_id", oldSpineId.Trim());
|
||||||
|
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
||||||
|
AddParameter(command, "reason", reason);
|
||||||
|
AddParameter(command, "created_at_utc", _timeProvider.GetUtcNow());
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(string spineId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(spineId);
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT segment_id, segment_type, idx, input_hash, result_hash, prev_segment_hash,
|
||||||
|
envelope_json, tool_id, tool_version, status, created_at_utc
|
||||||
|
FROM {SegmentsTable}
|
||||||
|
WHERE spine_id = @spine_id
|
||||||
|
ORDER BY idx
|
||||||
|
""";
|
||||||
|
|
||||||
|
return QueryAsync(
|
||||||
|
Tenant,
|
||||||
|
sql,
|
||||||
|
cmd => AddParameter(cmd, "spine_id", spineId.Trim()),
|
||||||
|
MapSegment,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ProofSpineSummary>> GetSummariesByScanRunAsync(
|
||||||
|
string scanRunId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(scanRunId);
|
||||||
|
|
||||||
|
var sql = $"""
|
||||||
|
SELECT spine_id, artifact_id, vuln_id, verdict, segment_count, created_at_utc
|
||||||
|
FROM {SpinesTable}
|
||||||
|
WHERE scan_run_id = @scan_run_id
|
||||||
|
ORDER BY created_at_utc DESC, spine_id DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
return QueryAsync(
|
||||||
|
Tenant,
|
||||||
|
sql,
|
||||||
|
cmd => AddParameter(cmd, "scan_run_id", scanRunId.Trim()),
|
||||||
|
MapSummary,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProofSpineModel MapSpine(NpgsqlDataReader reader)
|
||||||
|
{
|
||||||
|
return new ProofSpineModel(
|
||||||
|
SpineId: reader.GetString(reader.GetOrdinal("spine_id")),
|
||||||
|
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||||
|
VulnerabilityId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||||
|
PolicyProfileId: reader.GetString(reader.GetOrdinal("policy_profile_id")),
|
||||||
|
Segments: Array.Empty<ProofSegment>(),
|
||||||
|
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
|
||||||
|
VerdictReason: reader.IsDBNull(reader.GetOrdinal("verdict_reason"))
|
||||||
|
? string.Empty
|
||||||
|
: reader.GetString(reader.GetOrdinal("verdict_reason")),
|
||||||
|
RootHash: reader.GetString(reader.GetOrdinal("root_hash")),
|
||||||
|
ScanRunId: reader.GetString(reader.GetOrdinal("scan_run_id")),
|
||||||
|
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")),
|
||||||
|
SupersededBySpineId: GetNullableString(reader, reader.GetOrdinal("superseded_by_spine_id")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProofSegment MapSegment(NpgsqlDataReader reader)
|
||||||
|
{
|
||||||
|
var segmentTypeString = reader.GetString(reader.GetOrdinal("segment_type"));
|
||||||
|
if (!Enum.TryParse<ProofSegmentType>(segmentTypeString, ignoreCase: true, out var segmentType))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Unsupported proof segment type '{segmentTypeString}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusString = reader.GetString(reader.GetOrdinal("status"));
|
||||||
|
if (!Enum.TryParse<ProofSegmentStatus>(statusString, ignoreCase: true, out var status))
|
||||||
|
{
|
||||||
|
status = ProofSegmentStatus.Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopeJson = reader.GetString(reader.GetOrdinal("envelope_json"));
|
||||||
|
|
||||||
|
return new ProofSegment(
|
||||||
|
SegmentId: reader.GetString(reader.GetOrdinal("segment_id")),
|
||||||
|
SegmentType: segmentType,
|
||||||
|
Index: reader.GetInt32(reader.GetOrdinal("idx")),
|
||||||
|
InputHash: reader.GetString(reader.GetOrdinal("input_hash")),
|
||||||
|
ResultHash: reader.GetString(reader.GetOrdinal("result_hash")),
|
||||||
|
PrevSegmentHash: GetNullableString(reader, reader.GetOrdinal("prev_segment_hash")),
|
||||||
|
Envelope: DeserializeEnvelope(envelopeJson),
|
||||||
|
ToolId: reader.GetString(reader.GetOrdinal("tool_id")),
|
||||||
|
ToolVersion: reader.GetString(reader.GetOrdinal("tool_version")),
|
||||||
|
Status: status,
|
||||||
|
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeEnvelope(DsseEnvelope envelope)
|
||||||
|
{
|
||||||
|
var doc = new DsseEnvelopeDocument(
|
||||||
|
envelope.PayloadType,
|
||||||
|
envelope.Payload,
|
||||||
|
envelope.Signatures.Select(s => new DsseSignatureDocument(s.KeyId, s.Sig)).ToArray());
|
||||||
|
|
||||||
|
return CanonicalJson.Serialize(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DsseEnvelope DeserializeEnvelope(string json)
|
||||||
|
{
|
||||||
|
var doc = JsonSerializer.Deserialize<DsseEnvelopeDocument>(json, LenientJson)
|
||||||
|
?? throw new InvalidOperationException("DSSE envelope deserialized to null.");
|
||||||
|
|
||||||
|
var signatures = doc.Signatures is null
|
||||||
|
? Array.Empty<DsseSignature>()
|
||||||
|
: doc.Signatures.Select(s => new DsseSignature(s.KeyId, s.Sig)).ToArray();
|
||||||
|
|
||||||
|
return new DsseEnvelope(doc.PayloadType, doc.Payload, signatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DsseEnvelopeDocument(
|
||||||
|
[property: JsonPropertyName("payloadType")] string PayloadType,
|
||||||
|
[property: JsonPropertyName("payload")] string Payload,
|
||||||
|
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignatureDocument> Signatures);
|
||||||
|
|
||||||
|
private sealed record DsseSignatureDocument(
|
||||||
|
[property: JsonPropertyName("keyid")] string KeyId,
|
||||||
|
[property: JsonPropertyName("sig")] string Sig);
|
||||||
|
|
||||||
|
private static ProofSpineSummary MapSummary(NpgsqlDataReader reader)
|
||||||
|
=> new(
|
||||||
|
SpineId: reader.GetString(reader.GetOrdinal("spine_id")),
|
||||||
|
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||||
|
VulnerabilityId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||||
|
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
|
||||||
|
SegmentCount: reader.GetInt32(reader.GetOrdinal("segment_count")),
|
||||||
|
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")));
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\\StellaOps.Scanner.EntryTrace\\StellaOps.Scanner.EntryTrace.csproj" />
|
<ProjectReference Include="..\\StellaOps.Scanner.EntryTrace\\StellaOps.Scanner.EntryTrace.csproj" />
|
||||||
<ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" />
|
<ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
|
||||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- UNKNOWNS SCORING SCHEMA EXTENSION
|
||||||
|
-- Sprint: SPRINT_1102_0001_0001
|
||||||
|
-- Advisory Reference: 14-Dec-2025 - Triage and Unknowns Technical Reference
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Ensure schema exists
|
||||||
|
CREATE SCHEMA IF NOT EXISTS signals;
|
||||||
|
|
||||||
|
-- Extend unknowns table with scoring columns
|
||||||
|
ALTER TABLE signals.unknowns
|
||||||
|
-- Scoring factors (range: 0.0 - 1.0)
|
||||||
|
ADD COLUMN IF NOT EXISTS popularity_p FLOAT DEFAULT 0.0
|
||||||
|
CONSTRAINT chk_popularity_range CHECK (popularity_p >= 0.0 AND popularity_p <= 1.0),
|
||||||
|
ADD COLUMN IF NOT EXISTS deployment_count INT DEFAULT 0,
|
||||||
|
|
||||||
|
ADD COLUMN IF NOT EXISTS exploit_potential_e FLOAT DEFAULT 0.0
|
||||||
|
CONSTRAINT chk_exploit_range CHECK (exploit_potential_e >= 0.0 AND exploit_potential_e <= 1.0),
|
||||||
|
|
||||||
|
ADD COLUMN IF NOT EXISTS uncertainty_u FLOAT DEFAULT 0.0
|
||||||
|
CONSTRAINT chk_uncertainty_range CHECK (uncertainty_u >= 0.0 AND uncertainty_u <= 1.0),
|
||||||
|
|
||||||
|
ADD COLUMN IF NOT EXISTS centrality_c FLOAT DEFAULT 0.0
|
||||||
|
CONSTRAINT chk_centrality_range CHECK (centrality_c >= 0.0 AND centrality_c <= 1.0),
|
||||||
|
ADD COLUMN IF NOT EXISTS degree_centrality INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS betweenness_centrality FLOAT DEFAULT 0.0,
|
||||||
|
|
||||||
|
ADD COLUMN IF NOT EXISTS staleness_s FLOAT DEFAULT 0.0
|
||||||
|
CONSTRAINT chk_staleness_range CHECK (staleness_s >= 0.0 AND staleness_s <= 1.0),
|
||||||
|
ADD COLUMN IF NOT EXISTS days_since_analysis INT DEFAULT 0,
|
||||||
|
|
||||||
|
-- Composite score and band
|
||||||
|
ADD COLUMN IF NOT EXISTS score FLOAT DEFAULT 0.0
|
||||||
|
CONSTRAINT chk_score_range CHECK (score >= 0.0 AND score <= 1.0),
|
||||||
|
ADD COLUMN IF NOT EXISTS band TEXT DEFAULT 'cold'
|
||||||
|
CONSTRAINT chk_band_value CHECK (band IN ('hot', 'warm', 'cold')),
|
||||||
|
|
||||||
|
-- Uncertainty flags (JSONB for extensibility)
|
||||||
|
ADD COLUMN IF NOT EXISTS unknown_flags JSONB DEFAULT '{}'::jsonb,
|
||||||
|
|
||||||
|
-- Normalization trace for debugging/audit
|
||||||
|
ADD COLUMN IF NOT EXISTS normalization_trace JSONB,
|
||||||
|
|
||||||
|
-- Rescan scheduling
|
||||||
|
ADD COLUMN IF NOT EXISTS rescan_attempts INT DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_rescan_result TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS next_scheduled_rescan TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS last_analyzed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Graph slice reference
|
||||||
|
ADD COLUMN IF NOT EXISTS graph_slice_hash BYTEA,
|
||||||
|
ADD COLUMN IF NOT EXISTS evidence_set_hash BYTEA,
|
||||||
|
ADD COLUMN IF NOT EXISTS callgraph_attempt_hash BYTEA,
|
||||||
|
|
||||||
|
-- Version tracking
|
||||||
|
ADD COLUMN IF NOT EXISTS purl_version TEXT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
|
||||||
|
|
||||||
|
-- Create indexes for efficient querying
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_band
|
||||||
|
ON signals.unknowns(band);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_score_desc
|
||||||
|
ON signals.unknowns(score DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_band_score
|
||||||
|
ON signals.unknowns(band, score DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_next_rescan
|
||||||
|
ON signals.unknowns(next_scheduled_rescan)
|
||||||
|
WHERE next_scheduled_rescan IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_hot_band
|
||||||
|
ON signals.unknowns(score DESC)
|
||||||
|
WHERE band = 'hot';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_purl
|
||||||
|
ON signals.unknowns(purl);
|
||||||
|
|
||||||
|
-- GIN index for JSONB flags queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_flags_gin
|
||||||
|
ON signals.unknowns USING GIN (unknown_flags);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- COMMENTS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.popularity_p IS
|
||||||
|
'Deployment impact score (P). Formula: min(1, log10(1 + deployments)/log10(1 + 100))';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.exploit_potential_e IS
|
||||||
|
'Exploit consequence potential (E). Based on CVE severity, KEV status.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.uncertainty_u IS
|
||||||
|
'Uncertainty density (U). Aggregated from flags: no_provenance(0.30), version_range(0.25), conflicting_feeds(0.20), missing_vector(0.15), unreachable_source(0.10)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.centrality_c IS
|
||||||
|
'Graph centrality (C). Normalized betweenness centrality.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.staleness_s IS
|
||||||
|
'Evidence staleness (S). Formula: min(1, age_days / 14)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.score IS
|
||||||
|
'Composite score: clamp01(wP*P + wE*E + wU*U + wC*C + wS*S). Default weights: wP=0.25, wE=0.25, wU=0.25, wC=0.15, wS=0.10';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.band IS
|
||||||
|
'Triage band. HOT (>=0.70): immediate rescan. WARM (0.40-0.69): scheduled 12-72h. COLD (<0.40): weekly batch.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.unknown_flags IS
|
||||||
|
'JSONB flags: {no_provenance_anchor, version_range, conflicting_feeds, missing_vector, unreachable_source_advisory, dynamic_call_target, external_assembly}';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN signals.unknowns.normalization_trace IS
|
||||||
|
'JSONB trace of scoring computation for audit/debugging. Includes raw values, normalized values, weights, and formula.';
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using NpgsqlTypes;
|
||||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||||
using StellaOps.Signals.Models;
|
using StellaOps.Signals.Models;
|
||||||
using StellaOps.Signals.Persistence;
|
using StellaOps.Signals.Persistence;
|
||||||
@@ -8,9 +10,16 @@ namespace StellaOps.Signals.Storage.Postgres.Repositories;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PostgreSQL implementation of <see cref="IUnknownsRepository"/>.
|
/// PostgreSQL implementation of <see cref="IUnknownsRepository"/>.
|
||||||
|
/// Supports full scoring schema per Sprint 1102.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSource>, IUnknownsRepository
|
public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSource>, IUnknownsRepository
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
WriteIndented = false
|
||||||
|
};
|
||||||
|
|
||||||
private bool _tableInitialized;
|
private bool _tableInitialized;
|
||||||
|
|
||||||
public PostgresUnknownsRepository(SignalsDataSource dataSource, ILogger<PostgresUnknownsRepository> logger)
|
public PostgresUnknownsRepository(SignalsDataSource dataSource, ILogger<PostgresUnknownsRepository> logger)
|
||||||
@@ -40,10 +49,35 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
|||||||
await deleteCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
await deleteCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert new items
|
// Insert new items with all scoring columns
|
||||||
const string insertSql = @"
|
const string insertSql = @"
|
||||||
INSERT INTO signals.unknowns (id, subject_key, callgraph_id, symbol_id, code_id, purl, edge_from, edge_to, reason, created_at)
|
INSERT INTO signals.unknowns (
|
||||||
VALUES (@id, @subject_key, @callgraph_id, @symbol_id, @code_id, @purl, @edge_from, @edge_to, @reason, @created_at)";
|
id, subject_key, callgraph_id, symbol_id, code_id, purl, purl_version,
|
||||||
|
edge_from, edge_to, reason,
|
||||||
|
popularity_p, deployment_count,
|
||||||
|
exploit_potential_e,
|
||||||
|
uncertainty_u,
|
||||||
|
centrality_c, degree_centrality, betweenness_centrality,
|
||||||
|
staleness_s, days_since_analysis,
|
||||||
|
score, band,
|
||||||
|
unknown_flags, normalization_trace,
|
||||||
|
rescan_attempts, last_rescan_result, next_scheduled_rescan, last_analyzed_at,
|
||||||
|
graph_slice_hash, evidence_set_hash, callgraph_attempt_hash,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @subject_key, @callgraph_id, @symbol_id, @code_id, @purl, @purl_version,
|
||||||
|
@edge_from, @edge_to, @reason,
|
||||||
|
@popularity_p, @deployment_count,
|
||||||
|
@exploit_potential_e,
|
||||||
|
@uncertainty_u,
|
||||||
|
@centrality_c, @degree_centrality, @betweenness_centrality,
|
||||||
|
@staleness_s, @days_since_analysis,
|
||||||
|
@score, @band,
|
||||||
|
@unknown_flags, @normalization_trace,
|
||||||
|
@rescan_attempts, @last_rescan_result, @next_scheduled_rescan, @last_analyzed_at,
|
||||||
|
@graph_slice_hash, @evidence_set_hash, @callgraph_attempt_hash,
|
||||||
|
@created_at, @updated_at
|
||||||
|
)";
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
@@ -55,16 +89,7 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
|||||||
var itemId = string.IsNullOrWhiteSpace(item.Id) ? Guid.NewGuid().ToString("N") : item.Id.Trim();
|
var itemId = string.IsNullOrWhiteSpace(item.Id) ? Guid.NewGuid().ToString("N") : item.Id.Trim();
|
||||||
|
|
||||||
await using var insertCommand = CreateCommand(insertSql, connection, transaction);
|
await using var insertCommand = CreateCommand(insertSql, connection, transaction);
|
||||||
AddParameter(insertCommand, "@id", itemId);
|
AddInsertParameters(insertCommand, itemId, normalizedSubjectKey, item);
|
||||||
AddParameter(insertCommand, "@subject_key", normalizedSubjectKey);
|
|
||||||
AddParameter(insertCommand, "@callgraph_id", (object?)item.CallgraphId ?? DBNull.Value);
|
|
||||||
AddParameter(insertCommand, "@symbol_id", (object?)item.SymbolId ?? DBNull.Value);
|
|
||||||
AddParameter(insertCommand, "@code_id", (object?)item.CodeId ?? DBNull.Value);
|
|
||||||
AddParameter(insertCommand, "@purl", (object?)item.Purl ?? DBNull.Value);
|
|
||||||
AddParameter(insertCommand, "@edge_from", (object?)item.EdgeFrom ?? DBNull.Value);
|
|
||||||
AddParameter(insertCommand, "@edge_to", (object?)item.EdgeTo ?? DBNull.Value);
|
|
||||||
AddParameter(insertCommand, "@reason", (object?)item.Reason ?? DBNull.Value);
|
|
||||||
AddParameter(insertCommand, "@created_at", item.CreatedAt);
|
|
||||||
|
|
||||||
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
@@ -84,11 +109,10 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
|||||||
|
|
||||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
const string sql = @"
|
const string sql = SelectAllColumns + @"
|
||||||
SELECT id, subject_key, callgraph_id, symbol_id, code_id, purl, edge_from, edge_to, reason, created_at
|
|
||||||
FROM signals.unknowns
|
FROM signals.unknowns
|
||||||
WHERE subject_key = @subject_key
|
WHERE subject_key = @subject_key
|
||||||
ORDER BY created_at DESC";
|
ORDER BY score DESC, created_at DESC";
|
||||||
|
|
||||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using var command = CreateCommand(sql, connection);
|
await using var command = CreateCommand(sql, connection);
|
||||||
@@ -124,20 +148,297 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
|||||||
return result is long count ? (int)count : 0;
|
return result is long count ? (int)count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UnknownSymbolDocument MapUnknownSymbol(NpgsqlDataReader reader) => new()
|
public async Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Id = reader.GetString(0),
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
SubjectKey = reader.GetString(1),
|
|
||||||
CallgraphId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||||
SymbolId = reader.IsDBNull(3) ? null : reader.GetString(3),
|
|
||||||
CodeId = reader.IsDBNull(4) ? null : reader.GetString(4),
|
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
Purl = reader.IsDBNull(5) ? null : reader.GetString(5),
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
EdgeFrom = reader.IsDBNull(6) ? null : reader.GetString(6),
|
|
||||||
EdgeTo = reader.IsDBNull(7) ? null : reader.GetString(7),
|
try
|
||||||
Reason = reader.IsDBNull(8) ? null : reader.GetString(8),
|
{
|
||||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9)
|
const string updateSql = @"
|
||||||
|
UPDATE signals.unknowns SET
|
||||||
|
popularity_p = @popularity_p,
|
||||||
|
deployment_count = @deployment_count,
|
||||||
|
exploit_potential_e = @exploit_potential_e,
|
||||||
|
uncertainty_u = @uncertainty_u,
|
||||||
|
centrality_c = @centrality_c,
|
||||||
|
degree_centrality = @degree_centrality,
|
||||||
|
betweenness_centrality = @betweenness_centrality,
|
||||||
|
staleness_s = @staleness_s,
|
||||||
|
days_since_analysis = @days_since_analysis,
|
||||||
|
score = @score,
|
||||||
|
band = @band,
|
||||||
|
unknown_flags = @unknown_flags,
|
||||||
|
normalization_trace = @normalization_trace,
|
||||||
|
rescan_attempts = @rescan_attempts,
|
||||||
|
last_rescan_result = @last_rescan_result,
|
||||||
|
next_scheduled_rescan = @next_scheduled_rescan,
|
||||||
|
last_analyzed_at = @last_analyzed_at,
|
||||||
|
graph_slice_hash = @graph_slice_hash,
|
||||||
|
evidence_set_hash = @evidence_set_hash,
|
||||||
|
callgraph_attempt_hash = @callgraph_attempt_hash,
|
||||||
|
updated_at = @updated_at
|
||||||
|
WHERE subject_key = @subject_key AND id = @id";
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item is null || string.IsNullOrWhiteSpace(item.Id) || string.IsNullOrWhiteSpace(item.SubjectKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var updateCommand = CreateCommand(updateSql, connection, transaction);
|
||||||
|
AddUpdateParameters(updateCommand, item);
|
||||||
|
|
||||||
|
await updateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
const string sql = @"
|
||||||
|
SELECT DISTINCT subject_key
|
||||||
|
FROM signals.unknowns
|
||||||
|
ORDER BY subject_key";
|
||||||
|
|
||||||
|
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = CreateCommand(sql, connection);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var results = new List<string>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
results.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UnknownSymbolDocument>> GetDueForRescanAsync(
|
||||||
|
UnknownsBand band,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var bandValue = band.ToString().ToLowerInvariant();
|
||||||
|
|
||||||
|
const string sql = SelectAllColumns + @"
|
||||||
|
FROM signals.unknowns
|
||||||
|
WHERE band = @band
|
||||||
|
AND (next_scheduled_rescan IS NULL OR next_scheduled_rescan <= NOW())
|
||||||
|
ORDER BY score DESC
|
||||||
|
LIMIT @limit";
|
||||||
|
|
||||||
|
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = CreateCommand(sql, connection);
|
||||||
|
AddParameter(command, "@band", bandValue);
|
||||||
|
AddParameter(command, "@limit", limit);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var results = new List<UnknownSymbolDocument>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
results.Add(MapUnknownSymbol(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string SelectAllColumns = @"
|
||||||
|
SELECT id, subject_key, callgraph_id, symbol_id, code_id, purl, purl_version,
|
||||||
|
edge_from, edge_to, reason,
|
||||||
|
popularity_p, deployment_count,
|
||||||
|
exploit_potential_e,
|
||||||
|
uncertainty_u,
|
||||||
|
centrality_c, degree_centrality, betweenness_centrality,
|
||||||
|
staleness_s, days_since_analysis,
|
||||||
|
score, band,
|
||||||
|
unknown_flags, normalization_trace,
|
||||||
|
rescan_attempts, last_rescan_result, next_scheduled_rescan, last_analyzed_at,
|
||||||
|
graph_slice_hash, evidence_set_hash, callgraph_attempt_hash,
|
||||||
|
created_at, updated_at";
|
||||||
|
|
||||||
|
private void AddInsertParameters(NpgsqlCommand command, string itemId, string subjectKey, UnknownSymbolDocument item)
|
||||||
|
{
|
||||||
|
AddParameter(command, "@id", itemId);
|
||||||
|
AddParameter(command, "@subject_key", subjectKey);
|
||||||
|
AddParameter(command, "@callgraph_id", (object?)item.CallgraphId ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@symbol_id", (object?)item.SymbolId ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@code_id", (object?)item.CodeId ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@purl", (object?)item.Purl ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@purl_version", (object?)item.PurlVersion ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@edge_from", (object?)item.EdgeFrom ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@edge_to", (object?)item.EdgeTo ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@reason", (object?)item.Reason ?? DBNull.Value);
|
||||||
|
|
||||||
|
// Scoring factors
|
||||||
|
AddParameter(command, "@popularity_p", item.PopularityScore);
|
||||||
|
AddParameter(command, "@deployment_count", item.DeploymentCount);
|
||||||
|
AddParameter(command, "@exploit_potential_e", item.ExploitPotentialScore);
|
||||||
|
AddParameter(command, "@uncertainty_u", item.UncertaintyScore);
|
||||||
|
AddParameter(command, "@centrality_c", item.CentralityScore);
|
||||||
|
AddParameter(command, "@degree_centrality", item.DegreeCentrality);
|
||||||
|
AddParameter(command, "@betweenness_centrality", item.BetweennessCentrality);
|
||||||
|
AddParameter(command, "@staleness_s", item.StalenessScore);
|
||||||
|
AddParameter(command, "@days_since_analysis", item.DaysSinceLastAnalysis);
|
||||||
|
|
||||||
|
// Composite
|
||||||
|
AddParameter(command, "@score", item.Score);
|
||||||
|
AddParameter(command, "@band", item.Band.ToString().ToLowerInvariant());
|
||||||
|
|
||||||
|
// JSONB columns
|
||||||
|
AddJsonParameter(command, "@unknown_flags", item.Flags);
|
||||||
|
AddJsonParameter(command, "@normalization_trace", item.NormalizationTrace);
|
||||||
|
|
||||||
|
// Rescan scheduling
|
||||||
|
AddParameter(command, "@rescan_attempts", item.RescanAttempts);
|
||||||
|
AddParameter(command, "@last_rescan_result", (object?)item.LastRescanResult ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@next_scheduled_rescan", item.NextScheduledRescan.HasValue ? item.NextScheduledRescan.Value : DBNull.Value);
|
||||||
|
AddParameter(command, "@last_analyzed_at", item.LastAnalyzedAt.HasValue ? item.LastAnalyzedAt.Value : DBNull.Value);
|
||||||
|
|
||||||
|
// Hashes
|
||||||
|
AddParameter(command, "@graph_slice_hash", item.GraphSliceHash != null ? Convert.FromHexString(item.GraphSliceHash) : DBNull.Value);
|
||||||
|
AddParameter(command, "@evidence_set_hash", item.EvidenceSetHash != null ? Convert.FromHexString(item.EvidenceSetHash) : DBNull.Value);
|
||||||
|
AddParameter(command, "@callgraph_attempt_hash", item.CallgraphAttemptHash != null ? Convert.FromHexString(item.CallgraphAttemptHash) : DBNull.Value);
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
AddParameter(command, "@created_at", item.CreatedAt == default ? DateTimeOffset.UtcNow : item.CreatedAt);
|
||||||
|
AddParameter(command, "@updated_at", DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddUpdateParameters(NpgsqlCommand command, UnknownSymbolDocument item)
|
||||||
|
{
|
||||||
|
AddParameter(command, "@id", item.Id);
|
||||||
|
AddParameter(command, "@subject_key", item.SubjectKey);
|
||||||
|
|
||||||
|
// Scoring factors
|
||||||
|
AddParameter(command, "@popularity_p", item.PopularityScore);
|
||||||
|
AddParameter(command, "@deployment_count", item.DeploymentCount);
|
||||||
|
AddParameter(command, "@exploit_potential_e", item.ExploitPotentialScore);
|
||||||
|
AddParameter(command, "@uncertainty_u", item.UncertaintyScore);
|
||||||
|
AddParameter(command, "@centrality_c", item.CentralityScore);
|
||||||
|
AddParameter(command, "@degree_centrality", item.DegreeCentrality);
|
||||||
|
AddParameter(command, "@betweenness_centrality", item.BetweennessCentrality);
|
||||||
|
AddParameter(command, "@staleness_s", item.StalenessScore);
|
||||||
|
AddParameter(command, "@days_since_analysis", item.DaysSinceLastAnalysis);
|
||||||
|
|
||||||
|
// Composite
|
||||||
|
AddParameter(command, "@score", item.Score);
|
||||||
|
AddParameter(command, "@band", item.Band.ToString().ToLowerInvariant());
|
||||||
|
|
||||||
|
// JSONB columns
|
||||||
|
AddJsonParameter(command, "@unknown_flags", item.Flags);
|
||||||
|
AddJsonParameter(command, "@normalization_trace", item.NormalizationTrace);
|
||||||
|
|
||||||
|
// Rescan scheduling
|
||||||
|
AddParameter(command, "@rescan_attempts", item.RescanAttempts);
|
||||||
|
AddParameter(command, "@last_rescan_result", (object?)item.LastRescanResult ?? DBNull.Value);
|
||||||
|
AddParameter(command, "@next_scheduled_rescan", item.NextScheduledRescan.HasValue ? item.NextScheduledRescan.Value : DBNull.Value);
|
||||||
|
AddParameter(command, "@last_analyzed_at", item.LastAnalyzedAt.HasValue ? item.LastAnalyzedAt.Value : DBNull.Value);
|
||||||
|
|
||||||
|
// Hashes
|
||||||
|
AddParameter(command, "@graph_slice_hash", item.GraphSliceHash != null ? Convert.FromHexString(item.GraphSliceHash) : DBNull.Value);
|
||||||
|
AddParameter(command, "@evidence_set_hash", item.EvidenceSetHash != null ? Convert.FromHexString(item.EvidenceSetHash) : DBNull.Value);
|
||||||
|
AddParameter(command, "@callgraph_attempt_hash", item.CallgraphAttemptHash != null ? Convert.FromHexString(item.CallgraphAttemptHash) : DBNull.Value);
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
AddParameter(command, "@updated_at", DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddJsonParameter<T>(NpgsqlCommand command, string name, T? value) where T : class
|
||||||
|
{
|
||||||
|
var param = command.Parameters.Add(name, NpgsqlDbType.Jsonb);
|
||||||
|
param.Value = value != null ? JsonSerializer.Serialize(value, JsonOptions) : DBNull.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UnknownSymbolDocument MapUnknownSymbol(NpgsqlDataReader reader)
|
||||||
|
{
|
||||||
|
var doc = new UnknownSymbolDocument
|
||||||
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
|
SubjectKey = reader.GetString(1),
|
||||||
|
CallgraphId = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||||
|
SymbolId = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
CodeId = reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
|
Purl = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
PurlVersion = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
|
EdgeFrom = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
EdgeTo = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
|
Reason = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
|
|
||||||
|
// Scoring factors
|
||||||
|
PopularityScore = reader.IsDBNull(10) ? 0.0 : reader.GetDouble(10),
|
||||||
|
DeploymentCount = reader.IsDBNull(11) ? 0 : reader.GetInt32(11),
|
||||||
|
ExploitPotentialScore = reader.IsDBNull(12) ? 0.0 : reader.GetDouble(12),
|
||||||
|
UncertaintyScore = reader.IsDBNull(13) ? 0.0 : reader.GetDouble(13),
|
||||||
|
CentralityScore = reader.IsDBNull(14) ? 0.0 : reader.GetDouble(14),
|
||||||
|
DegreeCentrality = reader.IsDBNull(15) ? 0 : reader.GetInt32(15),
|
||||||
|
BetweennessCentrality = reader.IsDBNull(16) ? 0.0 : reader.GetDouble(16),
|
||||||
|
StalenessScore = reader.IsDBNull(17) ? 0.0 : reader.GetDouble(17),
|
||||||
|
DaysSinceLastAnalysis = reader.IsDBNull(18) ? 0 : reader.GetInt32(18),
|
||||||
|
|
||||||
|
// Composite
|
||||||
|
Score = reader.IsDBNull(19) ? 0.0 : reader.GetDouble(19),
|
||||||
|
Band = ParseBand(reader.IsDBNull(20) ? "cold" : reader.GetString(20)),
|
||||||
|
|
||||||
|
// JSONB columns
|
||||||
|
Flags = ParseJson<UnknownFlags>(reader, 21) ?? new UnknownFlags(),
|
||||||
|
NormalizationTrace = ParseJson<UnknownsNormalizationTrace>(reader, 22),
|
||||||
|
|
||||||
|
// Rescan scheduling
|
||||||
|
RescanAttempts = reader.IsDBNull(23) ? 0 : reader.GetInt32(23),
|
||||||
|
LastRescanResult = reader.IsDBNull(24) ? null : reader.GetString(24),
|
||||||
|
NextScheduledRescan = reader.IsDBNull(25) ? null : reader.GetFieldValue<DateTimeOffset>(25),
|
||||||
|
LastAnalyzedAt = reader.IsDBNull(26) ? null : reader.GetFieldValue<DateTimeOffset>(26),
|
||||||
|
|
||||||
|
// Hashes
|
||||||
|
GraphSliceHash = reader.IsDBNull(27) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(27)).ToLowerInvariant(),
|
||||||
|
EvidenceSetHash = reader.IsDBNull(28) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(28)).ToLowerInvariant(),
|
||||||
|
CallgraphAttemptHash = reader.IsDBNull(29) ? null : Convert.ToHexString(reader.GetFieldValue<byte[]>(29)).ToLowerInvariant(),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
CreatedAt = reader.IsDBNull(30) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(30),
|
||||||
|
UpdatedAt = reader.IsDBNull(31) ? DateTimeOffset.UtcNow : reader.GetFieldValue<DateTimeOffset>(31)
|
||||||
|
};
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UnknownsBand ParseBand(string value) => value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"hot" => UnknownsBand.Hot,
|
||||||
|
"warm" => UnknownsBand.Warm,
|
||||||
|
_ => UnknownsBand.Cold
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static T? ParseJson<T>(NpgsqlDataReader reader, int ordinal) where T : class
|
||||||
|
{
|
||||||
|
if (reader.IsDBNull(ordinal))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = reader.GetString(ordinal);
|
||||||
|
return JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
|
private static NpgsqlCommand CreateCommand(string sql, NpgsqlConnection connection, NpgsqlTransaction transaction)
|
||||||
{
|
{
|
||||||
var command = new NpgsqlCommand(sql, connection, transaction);
|
var command = new NpgsqlCommand(sql, connection, transaction);
|
||||||
@@ -151,6 +452,7 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create schema and base table
|
||||||
const string ddl = @"
|
const string ddl = @"
|
||||||
CREATE SCHEMA IF NOT EXISTS signals;
|
CREATE SCHEMA IF NOT EXISTS signals;
|
||||||
|
|
||||||
@@ -161,16 +463,58 @@ public sealed class PostgresUnknownsRepository : RepositoryBase<SignalsDataSourc
|
|||||||
symbol_id TEXT,
|
symbol_id TEXT,
|
||||||
code_id TEXT,
|
code_id TEXT,
|
||||||
purl TEXT,
|
purl TEXT,
|
||||||
|
purl_version TEXT,
|
||||||
edge_from TEXT,
|
edge_from TEXT,
|
||||||
edge_to TEXT,
|
edge_to TEXT,
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
-- Scoring factors
|
||||||
PRIMARY KEY (subject_key, id)
|
popularity_p FLOAT DEFAULT 0.0,
|
||||||
|
deployment_count INT DEFAULT 0,
|
||||||
|
exploit_potential_e FLOAT DEFAULT 0.0,
|
||||||
|
uncertainty_u FLOAT DEFAULT 0.0,
|
||||||
|
centrality_c FLOAT DEFAULT 0.0,
|
||||||
|
degree_centrality INT DEFAULT 0,
|
||||||
|
betweenness_centrality FLOAT DEFAULT 0.0,
|
||||||
|
staleness_s FLOAT DEFAULT 0.0,
|
||||||
|
days_since_analysis INT DEFAULT 0,
|
||||||
|
-- Composite
|
||||||
|
score FLOAT DEFAULT 0.0,
|
||||||
|
band TEXT DEFAULT 'cold',
|
||||||
|
-- JSONB
|
||||||
|
unknown_flags JSONB DEFAULT '{}'::jsonb,
|
||||||
|
normalization_trace JSONB,
|
||||||
|
-- Rescan
|
||||||
|
rescan_attempts INT DEFAULT 0,
|
||||||
|
last_rescan_result TEXT,
|
||||||
|
next_scheduled_rescan TIMESTAMPTZ,
|
||||||
|
last_analyzed_at TIMESTAMPTZ,
|
||||||
|
-- Hashes
|
||||||
|
graph_slice_hash BYTEA,
|
||||||
|
evidence_set_hash BYTEA,
|
||||||
|
callgraph_attempt_hash BYTEA,
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (subject_key, id),
|
||||||
|
CONSTRAINT chk_popularity_range CHECK (popularity_p >= 0.0 AND popularity_p <= 1.0),
|
||||||
|
CONSTRAINT chk_exploit_range CHECK (exploit_potential_e >= 0.0 AND exploit_potential_e <= 1.0),
|
||||||
|
CONSTRAINT chk_uncertainty_range CHECK (uncertainty_u >= 0.0 AND uncertainty_u <= 1.0),
|
||||||
|
CONSTRAINT chk_centrality_range CHECK (centrality_c >= 0.0 AND centrality_c <= 1.0),
|
||||||
|
CONSTRAINT chk_staleness_range CHECK (staleness_s >= 0.0 AND staleness_s <= 1.0),
|
||||||
|
CONSTRAINT chk_score_range CHECK (score >= 0.0 AND score <= 1.0),
|
||||||
|
CONSTRAINT chk_band_value CHECK (band IN ('hot', 'warm', 'cold'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_unknowns_subject_key ON signals.unknowns (subject_key);
|
CREATE INDEX IF NOT EXISTS idx_unknowns_subject_key ON signals.unknowns (subject_key);
|
||||||
CREATE INDEX IF NOT EXISTS idx_unknowns_callgraph_id ON signals.unknowns (callgraph_id) WHERE callgraph_id IS NOT NULL;
|
CREATE INDEX IF NOT EXISTS idx_unknowns_callgraph_id ON signals.unknowns (callgraph_id) WHERE callgraph_id IS NOT NULL;
|
||||||
CREATE INDEX IF NOT EXISTS idx_unknowns_symbol_id ON signals.unknowns (symbol_id) WHERE symbol_id IS NOT NULL;";
|
CREATE INDEX IF NOT EXISTS idx_unknowns_symbol_id ON signals.unknowns (symbol_id) WHERE symbol_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_band ON signals.unknowns(band);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_score_desc ON signals.unknowns(score DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_band_score ON signals.unknowns(band, score DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_next_rescan ON signals.unknowns(next_scheduled_rescan) WHERE next_scheduled_rescan IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_hot_band ON signals.unknowns(score DESC) WHERE band = 'hot';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unknowns_purl ON signals.unknowns(purl);";
|
||||||
|
|
||||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using var command = CreateCommand(ddl, connection);
|
await using var command = CreateCommand(ddl, connection);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ public interface IUnknownsRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken);
|
Task BulkUpdateAsync(IEnumerable<UnknownSymbolDocument> items, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all known subject keys containing unknowns.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> GetAllSubjectKeysAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets unknowns due for rescan in a specific band.
|
/// Gets unknowns due for rescan in a specific band.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StellaOps.Signals.Persistence;
|
||||||
|
|
||||||
|
public sealed class InMemoryDeploymentRefsRepository : IDeploymentRefsRepository
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, int> _deploymentsByPurl = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public void SetDeployments(string purl, int deployments)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
|
||||||
|
if (deployments < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(deployments), "Deployments cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_deploymentsByPurl[purl.Trim()] = deployments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> CountDeploymentsAsync(string purl, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(purl))
|
||||||
|
{
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(_deploymentsByPurl.TryGetValue(purl.Trim(), out var count) ? count : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StellaOps.Signals.Persistence;
|
||||||
|
|
||||||
|
public sealed class InMemoryGraphMetricsRepository : IGraphMetricsRepository
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, GraphMetrics> _metrics = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public void SetMetrics(string symbolId, string callgraphId, GraphMetrics metrics)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(callgraphId);
|
||||||
|
|
||||||
|
var key = BuildKey(symbolId, callgraphId);
|
||||||
|
_metrics[key] = metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<GraphMetrics?> GetMetricsAsync(string symbolId, string callgraphId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(symbolId) || string.IsNullOrWhiteSpace(callgraphId))
|
||||||
|
{
|
||||||
|
return Task.FromResult<GraphMetrics?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = BuildKey(symbolId, callgraphId);
|
||||||
|
return Task.FromResult(_metrics.TryGetValue(key, out var metrics) ? metrics : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildKey(string symbolId, string callgraphId)
|
||||||
|
=> $"{callgraphId.Trim()}|{symbolId.Trim()}";
|
||||||
|
}
|
||||||
@@ -78,7 +78,9 @@ internal sealed class UnknownsIngestionService : IUnknownsIngestionService
|
|||||||
EdgeFrom = entry.EdgeFrom?.Trim(),
|
EdgeFrom = entry.EdgeFrom?.Trim(),
|
||||||
EdgeTo = entry.EdgeTo?.Trim(),
|
EdgeTo = entry.EdgeTo?.Trim(),
|
||||||
Reason = entry.Reason?.Trim(),
|
Reason = entry.Reason?.Trim(),
|
||||||
CreatedAt = now
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now,
|
||||||
|
LastAnalyzedAt = now
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ public sealed class UnknownsScoringService : IUnknownsScoringService
|
|||||||
UnknownsScoringOptions opts,
|
UnknownsScoringOptions opts,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
var trace = new UnknownsNormalizationTrace
|
var trace = new UnknownsNormalizationTrace
|
||||||
{
|
{
|
||||||
ComputedAt = _timeProvider.GetUtcNow(),
|
ComputedAt = now,
|
||||||
Weights = new Dictionary<string, double>
|
Weights = new Dictionary<string, double>
|
||||||
{
|
{
|
||||||
["wP"] = opts.WeightPopularity,
|
["wP"] = opts.WeightPopularity,
|
||||||
@@ -139,24 +141,21 @@ public sealed class UnknownsScoringService : IUnknownsScoringService
|
|||||||
trace.FinalScore = score;
|
trace.FinalScore = score;
|
||||||
|
|
||||||
// Band assignment
|
// Band assignment
|
||||||
unknown.Band = score switch
|
unknown.Band = score >= opts.HotThreshold
|
||||||
{
|
? UnknownsBand.Hot
|
||||||
>= 0.70 => UnknownsBand.Hot,
|
: score >= opts.WarmThreshold ? UnknownsBand.Warm : UnknownsBand.Cold;
|
||||||
>= 0.40 => UnknownsBand.Warm,
|
|
||||||
_ => UnknownsBand.Cold
|
|
||||||
};
|
|
||||||
trace.AssignedBand = unknown.Band.ToString();
|
trace.AssignedBand = unknown.Band.ToString();
|
||||||
|
|
||||||
// Schedule next rescan based on band
|
// Schedule next rescan based on band
|
||||||
unknown.NextScheduledRescan = unknown.Band switch
|
unknown.NextScheduledRescan = unknown.Band switch
|
||||||
{
|
{
|
||||||
UnknownsBand.Hot => _timeProvider.GetUtcNow().AddMinutes(15),
|
UnknownsBand.Hot => now.AddMinutes(opts.HotRescanMinutes),
|
||||||
UnknownsBand.Warm => _timeProvider.GetUtcNow().AddHours(opts.WarmRescanHours),
|
UnknownsBand.Warm => now.AddHours(opts.WarmRescanHours),
|
||||||
_ => _timeProvider.GetUtcNow().AddDays(opts.ColdRescanDays)
|
_ => now.AddDays(opts.ColdRescanDays)
|
||||||
};
|
};
|
||||||
|
|
||||||
unknown.NormalizationTrace = trace;
|
unknown.NormalizationTrace = trace;
|
||||||
unknown.UpdatedAt = _timeProvider.GetUtcNow();
|
unknown.UpdatedAt = now;
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Scored unknown {UnknownId}: P={P:F2} E={E:F2} U={U:F2} C={C:F2} S={S:F2} → Score={Score:F2} Band={Band}",
|
"Scored unknown {UnknownId}: P={P:F2} E={E:F2} U={U:F2} C={C:F2} S={S:F2} → Score={Score:F2} Band={Band}",
|
||||||
@@ -270,9 +269,28 @@ public sealed class UnknownsScoringService : IUnknownsScoringService
|
|||||||
return (1.0, opts.StalenessMaxDays); // Never analyzed = maximum staleness
|
return (1.0, opts.StalenessMaxDays); // Never analyzed = maximum staleness
|
||||||
|
|
||||||
var daysSince = (int)(_timeProvider.GetUtcNow() - lastAnalyzedAt.Value).TotalDays;
|
var daysSince = (int)(_timeProvider.GetUtcNow() - lastAnalyzedAt.Value).TotalDays;
|
||||||
|
if (daysSince < 0)
|
||||||
|
{
|
||||||
|
daysSince = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Formula: S = min(1, age_days / max_days)
|
// Exponential staleness: decayFactor = exp(-t/tau), staleness = (1 - decayFactor) normalized to reach 1 at maxDays.
|
||||||
var score = Math.Min(1.0, (double)daysSince / opts.StalenessMaxDays);
|
// This models confidence decay (higher staleness means lower confidence in evidence freshness).
|
||||||
|
if (opts.StalenessTauDays > 0 && opts.StalenessMaxDays > 0)
|
||||||
|
{
|
||||||
|
var maxDays = Math.Max(1, opts.StalenessMaxDays);
|
||||||
|
var decayFactor = Math.Exp(-daysSince / opts.StalenessTauDays);
|
||||||
|
var maxDecayFactor = Math.Exp(-maxDays / opts.StalenessTauDays);
|
||||||
|
var numerator = 1.0 - decayFactor;
|
||||||
|
var denominator = 1.0 - maxDecayFactor;
|
||||||
|
var normalized = denominator <= 0 ? 0.0 : numerator / denominator;
|
||||||
|
return (Math.Clamp(normalized, 0.0, 1.0), daysSince);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback linear: S = min(1, age_days / max_days)
|
||||||
|
var score = opts.StalenessMaxDays <= 0
|
||||||
|
? 0.0
|
||||||
|
: Math.Min(1.0, (double)daysSince / opts.StalenessMaxDays);
|
||||||
|
|
||||||
return (score, daysSince);
|
return (score, daysSince);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,6 +559,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{D772
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Unknowns.Storage.Postgres.Tests", "Unknowns\__Tests\StellaOps.Unknowns.Storage.Postgres.Tests\StellaOps.Unknowns.Storage.Postgres.Tests.csproj", "{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Unknowns.Storage.Postgres.Tests", "Unknowns\__Tests\StellaOps.Unknowns.Storage.Postgres.Tests\StellaOps.Unknowns.Storage.Postgres.Tests.csproj", "{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Bundle", "__Libraries\StellaOps.Evidence.Bundle\StellaOps.Evidence.Bundle.csproj", "{EF713DD9-A209-47F0-A23E-B1A4A0858140}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{56BCE1BF-7CBA-7CE8-203D-A88051F1D642}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Evidence.Bundle.Tests", "__Tests\StellaOps.Evidence.Bundle.Tests\StellaOps.Evidence.Bundle.Tests.csproj", "{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -3521,6 +3527,30 @@ Global
|
|||||||
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}.Release|x64.Build.0 = Release|Any CPU
|
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}.Release|x86.ActiveCfg = Release|Any CPU
|
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}.Release|x86.Build.0 = Release|Any CPU
|
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -3709,5 +3739,7 @@ Global
|
|||||||
{D7A4570D-DD38-44AC-A562-2CBF1A41F8F2} = {F5E391D7-8CFB-A2F6-B58A-74C6D8F1918D}
|
{D7A4570D-DD38-44AC-A562-2CBF1A41F8F2} = {F5E391D7-8CFB-A2F6-B58A-74C6D8F1918D}
|
||||||
{D772292D-D9E7-A1BA-4BF3-9F968036361A} = {3238BE02-625A-DE8E-F027-4A430B6B6D5B}
|
{D772292D-D9E7-A1BA-4BF3-9F968036361A} = {3238BE02-625A-DE8E-F027-4A430B6B6D5B}
|
||||||
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629} = {D772292D-D9E7-A1BA-4BF3-9F968036361A}
|
{0F1F2E5E-B8CB-4C5E-A8AC-D54563283629} = {D772292D-D9E7-A1BA-4BF3-9F968036361A}
|
||||||
|
{EF713DD9-A209-47F0-A23E-B1A4A0858140} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
|
||||||
|
{8C2E5AD3-437E-4CF9-B066-C30C7F90E543} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
|
/// <summary>Call stack snippet evidence.</summary>
|
||||||
|
public sealed class CallStackEvidence
|
||||||
|
{
|
||||||
|
public required EvidenceStatus Status { get; init; }
|
||||||
|
public string? Hash { get; init; }
|
||||||
|
public IReadOnlyList<StackFrame>? Frames { get; init; }
|
||||||
|
public int? SinkFrameIndex { get; init; }
|
||||||
|
public int? SourceFrameIndex { get; init; }
|
||||||
|
public string? UnavailableReason { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StackFrame
|
||||||
|
{
|
||||||
|
public required string FunctionName { get; init; }
|
||||||
|
public required string FilePath { get; init; }
|
||||||
|
public required int Line { get; init; }
|
||||||
|
public int? Column { get; init; }
|
||||||
|
public string? SourceSnippet { get; init; }
|
||||||
|
public bool IsSink { get; init; }
|
||||||
|
public bool IsSource { get; init; }
|
||||||
|
}
|
||||||
26
src/__Libraries/StellaOps.Evidence.Bundle/DiffEvidence.cs
Normal file
26
src/__Libraries/StellaOps.Evidence.Bundle/DiffEvidence.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
|
/// <summary>SBOM/VEX diff evidence.</summary>
|
||||||
|
public sealed class DiffEvidence
|
||||||
|
{
|
||||||
|
public required EvidenceStatus Status { get; init; }
|
||||||
|
public string? Hash { get; init; }
|
||||||
|
public DiffType DiffType { get; init; }
|
||||||
|
public IReadOnlyList<DiffEntry>? Entries { get; init; }
|
||||||
|
public string? PreviousScanId { get; init; }
|
||||||
|
public DateTimeOffset? PreviousScanTime { get; init; }
|
||||||
|
public string? UnavailableReason { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DiffType { Sbom, Vex, Combined }
|
||||||
|
|
||||||
|
public sealed class DiffEntry
|
||||||
|
{
|
||||||
|
public required DiffOperation Operation { get; init; }
|
||||||
|
public required string Path { get; init; }
|
||||||
|
public string? OldValue { get; init; }
|
||||||
|
public string? NewValue { get; init; }
|
||||||
|
public string? ComponentPurl { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DiffOperation { Added, Removed, Modified }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
namespace StellaOps.Evidence.Bundle;
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
/// <summary>A complete evidence bundle for a single finding/alert. Contains all evidence required for triage decision.</summary>
|
/// <summary>A complete evidence bundle for a single finding/alert.</summary>
|
||||||
public sealed class EvidenceBundle
|
public sealed class EvidenceBundle
|
||||||
{
|
{
|
||||||
public string BundleId { get; init; } = Guid.NewGuid().ToString("N");
|
public string BundleId { get; init; } = Guid.NewGuid().ToString("N");
|
||||||
@@ -16,7 +16,6 @@ public sealed class EvidenceBundle
|
|||||||
public required EvidenceHashSet Hashes { get; init; }
|
public required EvidenceHashSet Hashes { get; init; }
|
||||||
public required DateTimeOffset CreatedAt { get; init; }
|
public required DateTimeOffset CreatedAt { get; init; }
|
||||||
|
|
||||||
/// <summary>Compute evidence completeness score (0-4 based on core evidence types).</summary>
|
|
||||||
public int ComputeCompletenessScore()
|
public int ComputeCompletenessScore()
|
||||||
{
|
{
|
||||||
var score = 0;
|
var score = 0;
|
||||||
@@ -27,7 +26,6 @@ public sealed class EvidenceBundle
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Create status summary from evidence.</summary>
|
|
||||||
public EvidenceStatusSummary CreateStatusSummary() => new()
|
public EvidenceStatusSummary CreateStatusSummary() => new()
|
||||||
{
|
{
|
||||||
Reachability = Reachability?.Status ?? EvidenceStatus.Unavailable,
|
Reachability = Reachability?.Status ?? EvidenceStatus.Unavailable,
|
||||||
@@ -38,7 +36,6 @@ public sealed class EvidenceBundle
|
|||||||
GraphRevision = GraphRevision?.Status ?? EvidenceStatus.Unavailable
|
GraphRevision = GraphRevision?.Status ?? EvidenceStatus.Unavailable
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Create DSSE predicate for signing.</summary>
|
|
||||||
public EvidenceBundlePredicate ToSigningPredicate() => new()
|
public EvidenceBundlePredicate ToSigningPredicate() => new()
|
||||||
{
|
{
|
||||||
BundleId = BundleId,
|
BundleId = BundleId,
|
||||||
|
|||||||
@@ -13,28 +13,22 @@ public sealed class EvidenceBundleBuilder
|
|||||||
private DiffEvidence? _diff;
|
private DiffEvidence? _diff;
|
||||||
private GraphRevisionEvidence? _graphRevision;
|
private GraphRevisionEvidence? _graphRevision;
|
||||||
|
|
||||||
public EvidenceBundleBuilder(TimeProvider timeProvider)
|
public EvidenceBundleBuilder(TimeProvider timeProvider) => _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
{
|
|
||||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
||||||
}
|
|
||||||
|
|
||||||
public EvidenceBundleBuilder() : this(TimeProvider.System) { }
|
public EvidenceBundleBuilder() : this(TimeProvider.System) { }
|
||||||
|
|
||||||
public EvidenceBundleBuilder WithAlertId(string alertId) { _alertId = alertId; return this; }
|
public EvidenceBundleBuilder WithAlertId(string alertId) { _alertId = alertId; return this; }
|
||||||
public EvidenceBundleBuilder WithArtifactId(string artifactId) { _artifactId = artifactId; return this; }
|
public EvidenceBundleBuilder WithArtifactId(string artifactId) { _artifactId = artifactId; return this; }
|
||||||
public EvidenceBundleBuilder WithReachability(ReachabilityEvidence evidence) { _reachability = evidence; return this; }
|
public EvidenceBundleBuilder WithReachability(ReachabilityEvidence e) { _reachability = e; return this; }
|
||||||
public EvidenceBundleBuilder WithCallStack(CallStackEvidence evidence) { _callStack = evidence; return this; }
|
public EvidenceBundleBuilder WithCallStack(CallStackEvidence e) { _callStack = e; return this; }
|
||||||
public EvidenceBundleBuilder WithProvenance(ProvenanceEvidence evidence) { _provenance = evidence; return this; }
|
public EvidenceBundleBuilder WithProvenance(ProvenanceEvidence e) { _provenance = e; return this; }
|
||||||
public EvidenceBundleBuilder WithVexStatus(VexStatusEvidence evidence) { _vexStatus = evidence; return this; }
|
public EvidenceBundleBuilder WithVexStatus(VexStatusEvidence e) { _vexStatus = e; return this; }
|
||||||
public EvidenceBundleBuilder WithDiff(DiffEvidence evidence) { _diff = evidence; return this; }
|
public EvidenceBundleBuilder WithDiff(DiffEvidence e) { _diff = e; return this; }
|
||||||
public EvidenceBundleBuilder WithGraphRevision(GraphRevisionEvidence evidence) { _graphRevision = evidence; return this; }
|
public EvidenceBundleBuilder WithGraphRevision(GraphRevisionEvidence e) { _graphRevision = e; return this; }
|
||||||
|
|
||||||
public EvidenceBundle Build()
|
public EvidenceBundle Build()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_alertId))
|
if (string.IsNullOrWhiteSpace(_alertId)) throw new InvalidOperationException("AlertId is required");
|
||||||
throw new InvalidOperationException("AlertId is required");
|
if (string.IsNullOrWhiteSpace(_artifactId)) throw new InvalidOperationException("ArtifactId is required");
|
||||||
if (string.IsNullOrWhiteSpace(_artifactId))
|
|
||||||
throw new InvalidOperationException("ArtifactId is required");
|
|
||||||
|
|
||||||
var hashes = new Dictionary<string, string>();
|
var hashes = new Dictionary<string, string>();
|
||||||
if (_reachability?.Hash is not null) hashes["reachability"] = _reachability.Hash;
|
if (_reachability?.Hash is not null) hashes["reachability"] = _reachability.Hash;
|
||||||
|
|||||||
16
src/__Libraries/StellaOps.Evidence.Bundle/EvidenceStatus.cs
Normal file
16
src/__Libraries/StellaOps.Evidence.Bundle/EvidenceStatus.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
|
/// <summary>Status of an evidence artifact.</summary>
|
||||||
|
public enum EvidenceStatus
|
||||||
|
{
|
||||||
|
/// <summary>Evidence is available and complete.</summary>
|
||||||
|
Available,
|
||||||
|
/// <summary>Evidence is currently being loaded/computed.</summary>
|
||||||
|
Loading,
|
||||||
|
/// <summary>Evidence is not available (missing inputs).</summary>
|
||||||
|
Unavailable,
|
||||||
|
/// <summary>Error occurred while fetching evidence.</summary>
|
||||||
|
Error,
|
||||||
|
/// <summary>Evidence pending enrichment (offline mode).</summary>
|
||||||
|
PendingEnrichment
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
|
/// <summary>Graph revision and verdict receipt evidence.</summary>
|
||||||
|
public sealed class GraphRevisionEvidence
|
||||||
|
{
|
||||||
|
public required EvidenceStatus Status { get; init; }
|
||||||
|
public string? Hash { get; init; }
|
||||||
|
public required string GraphRevisionId { get; init; }
|
||||||
|
public string? VerdictReceipt { get; init; }
|
||||||
|
public DateTimeOffset? GraphComputedAt { get; init; }
|
||||||
|
public int? TotalNodes { get; init; }
|
||||||
|
public int? TotalEdges { get; init; }
|
||||||
|
public string? UnavailableReason { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
|
/// <summary>Provenance attestation evidence.</summary>
|
||||||
|
public sealed class ProvenanceEvidence
|
||||||
|
{
|
||||||
|
public required EvidenceStatus Status { get; init; }
|
||||||
|
public string? Hash { get; init; }
|
||||||
|
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||||
|
public BuildAncestry? Ancestry { get; init; }
|
||||||
|
public RekorReference? RekorEntry { get; init; }
|
||||||
|
public string? VerificationStatus { get; init; }
|
||||||
|
public string? UnavailableReason { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DsseEnvelope
|
||||||
|
{
|
||||||
|
public required string PayloadType { get; init; }
|
||||||
|
public required string Payload { get; init; }
|
||||||
|
public required IReadOnlyList<DsseSignature> Signatures { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DsseSignature
|
||||||
|
{
|
||||||
|
public required string KeyId { get; init; }
|
||||||
|
public required string Sig { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BuildAncestry
|
||||||
|
{
|
||||||
|
public string? ImageDigest { get; init; }
|
||||||
|
public string? LayerDigest { get; init; }
|
||||||
|
public string? ArtifactDigest { get; init; }
|
||||||
|
public string? CommitHash { get; init; }
|
||||||
|
public string? BuildId { get; init; }
|
||||||
|
public DateTimeOffset? BuildTime { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RekorReference
|
||||||
|
{
|
||||||
|
public required string LogId { get; init; }
|
||||||
|
public required long LogIndex { get; init; }
|
||||||
|
public string? Uuid { get; init; }
|
||||||
|
public DateTimeOffset? IntegratedTime { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
|
/// <summary>Reachability proof evidence.</summary>
|
||||||
|
public sealed class ReachabilityEvidence
|
||||||
|
{
|
||||||
|
public required EvidenceStatus Status { get; init; }
|
||||||
|
public string? Hash { get; init; }
|
||||||
|
public ReachabilityProofType ProofType { get; init; }
|
||||||
|
public IReadOnlyList<FunctionPathNode>? FunctionPath { get; init; }
|
||||||
|
public IReadOnlyList<PackageImportNode>? ImportChain { get; init; }
|
||||||
|
public string? LatticeState { get; init; }
|
||||||
|
public int? ConfidenceTier { get; init; }
|
||||||
|
public string? UnavailableReason { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ReachabilityProofType { FunctionLevel, PackageLevel, ImportChain, Heuristic, Unknown }
|
||||||
|
|
||||||
|
public sealed class FunctionPathNode
|
||||||
|
{
|
||||||
|
public required string FunctionName { get; init; }
|
||||||
|
public required string FilePath { get; init; }
|
||||||
|
public required int Line { get; init; }
|
||||||
|
public int? Column { get; init; }
|
||||||
|
public string? ModuleName { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PackageImportNode
|
||||||
|
{
|
||||||
|
public required string PackageName { get; init; }
|
||||||
|
public string? Version { get; init; }
|
||||||
|
public string? ImportedBy { get; init; }
|
||||||
|
public string? ImportPath { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<RootNamespace>StellaOps.Evidence.Bundle</RootNamespace>
|
||||||
|
<Description>Evidence bundle envelope with cryptographic signatures for offline verification</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-preview.1.25080.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace StellaOps.Evidence.Bundle;
|
||||||
|
|
||||||
|
/// <summary>VEX/CSAF status evidence.</summary>
|
||||||
|
public sealed class VexStatusEvidence
|
||||||
|
{
|
||||||
|
public required EvidenceStatus Status { get; init; }
|
||||||
|
public string? Hash { get; init; }
|
||||||
|
public VexStatement? Current { get; init; }
|
||||||
|
public IReadOnlyList<VexStatement>? History { get; init; }
|
||||||
|
public string? UnavailableReason { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VexStatement
|
||||||
|
{
|
||||||
|
public required string VexStatus { get; init; }
|
||||||
|
public string? Justification { get; init; }
|
||||||
|
public string? ImpactStatement { get; init; }
|
||||||
|
public string? ActionStatement { get; init; }
|
||||||
|
public DateTimeOffset? Timestamp { get; init; }
|
||||||
|
public string? Source { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Time.Testing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Evidence.Bundle.Tests;
|
||||||
|
|
||||||
|
public class EvidenceBundleTests
|
||||||
|
{
|
||||||
|
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2024, 12, 15, 12, 0, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Builder_MinimalBundle_CreatesValid()
|
||||||
|
{
|
||||||
|
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||||
|
.WithAlertId("ALERT-001")
|
||||||
|
.WithArtifactId("sha256:abc123")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Assert.NotNull(bundle);
|
||||||
|
Assert.Equal("ALERT-001", bundle.AlertId);
|
||||||
|
Assert.Equal("sha256:abc123", bundle.ArtifactId);
|
||||||
|
Assert.Equal("1.0", bundle.SchemaVersion);
|
||||||
|
Assert.NotEmpty(bundle.BundleId);
|
||||||
|
Assert.Equal(_timeProvider.GetUtcNow(), bundle.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Builder_MissingAlertId_Throws()
|
||||||
|
{
|
||||||
|
var builder = new EvidenceBundleBuilder(_timeProvider).WithArtifactId("sha256:abc");
|
||||||
|
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Builder_MissingArtifactId_Throws()
|
||||||
|
{
|
||||||
|
var builder = new EvidenceBundleBuilder(_timeProvider).WithAlertId("ALERT-001");
|
||||||
|
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Builder_WithAllEvidence_ComputesHashSet()
|
||||||
|
{
|
||||||
|
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||||
|
.WithAlertId("ALERT-001")
|
||||||
|
.WithArtifactId("sha256:abc123")
|
||||||
|
.WithReachability(new ReachabilityEvidence { Status = EvidenceStatus.Available, Hash = "hash1" })
|
||||||
|
.WithCallStack(new CallStackEvidence { Status = EvidenceStatus.Available, Hash = "hash2" })
|
||||||
|
.WithProvenance(new ProvenanceEvidence { Status = EvidenceStatus.Available, Hash = "hash3" })
|
||||||
|
.WithVexStatus(new VexStatusEvidence { Status = EvidenceStatus.Available, Hash = "hash4" })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Assert.NotNull(bundle.Hashes);
|
||||||
|
Assert.Equal(4, bundle.Hashes.Hashes.Count);
|
||||||
|
Assert.NotEmpty(bundle.Hashes.CombinedHash);
|
||||||
|
Assert.Equal(64, bundle.Hashes.CombinedHash.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeCompletenessScore_AllAvailable_Returns4()
|
||||||
|
{
|
||||||
|
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||||
|
.WithAlertId("ALERT-001")
|
||||||
|
.WithArtifactId("sha256:abc123")
|
||||||
|
.WithReachability(new ReachabilityEvidence { Status = EvidenceStatus.Available })
|
||||||
|
.WithCallStack(new CallStackEvidence { Status = EvidenceStatus.Available })
|
||||||
|
.WithProvenance(new ProvenanceEvidence { Status = EvidenceStatus.Available })
|
||||||
|
.WithVexStatus(new VexStatusEvidence { Status = EvidenceStatus.Available })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Assert.Equal(4, bundle.ComputeCompletenessScore());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeCompletenessScore_NoneAvailable_Returns0()
|
||||||
|
{
|
||||||
|
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||||
|
.WithAlertId("ALERT-001")
|
||||||
|
.WithArtifactId("sha256:abc123")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Assert.Equal(0, bundle.ComputeCompletenessScore());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeCompletenessScore_PartialAvailable_ReturnsCorrect()
|
||||||
|
{
|
||||||
|
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||||
|
.WithAlertId("ALERT-001")
|
||||||
|
.WithArtifactId("sha256:abc123")
|
||||||
|
.WithReachability(new ReachabilityEvidence { Status = EvidenceStatus.Available })
|
||||||
|
.WithCallStack(new CallStackEvidence { Status = EvidenceStatus.Unavailable })
|
||||||
|
.WithProvenance(new ProvenanceEvidence { Status = EvidenceStatus.Available })
|
||||||
|
.WithVexStatus(new VexStatusEvidence { Status = EvidenceStatus.Loading })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Assert.Equal(2, bundle.ComputeCompletenessScore());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateStatusSummary_ReturnsCorrectStatuses()
|
||||||
|
{
|
||||||
|
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||||
|
.WithAlertId("ALERT-001")
|
||||||
|
.WithArtifactId("sha256:abc123")
|
||||||
|
.WithReachability(new ReachabilityEvidence { Status = EvidenceStatus.Available })
|
||||||
|
.WithCallStack(new CallStackEvidence { Status = EvidenceStatus.Loading })
|
||||||
|
.WithProvenance(new ProvenanceEvidence { Status = EvidenceStatus.Error })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var summary = bundle.CreateStatusSummary();
|
||||||
|
|
||||||
|
Assert.Equal(EvidenceStatus.Available, summary.Reachability);
|
||||||
|
Assert.Equal(EvidenceStatus.Loading, summary.CallStack);
|
||||||
|
Assert.Equal(EvidenceStatus.Error, summary.Provenance);
|
||||||
|
Assert.Equal(EvidenceStatus.Unavailable, summary.VexStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToSigningPredicate_CreatesValidPredicate()
|
||||||
|
{
|
||||||
|
var bundle = new EvidenceBundleBuilder(_timeProvider)
|
||||||
|
.WithAlertId("ALERT-001")
|
||||||
|
.WithArtifactId("sha256:abc123")
|
||||||
|
.WithReachability(new ReachabilityEvidence { Status = EvidenceStatus.Available, Hash = "hash1" })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var predicate = bundle.ToSigningPredicate();
|
||||||
|
|
||||||
|
Assert.Equal(EvidenceBundlePredicate.PredicateType, "stellaops.dev/predicates/evidence-bundle@v1");
|
||||||
|
Assert.Equal(bundle.BundleId, predicate.BundleId);
|
||||||
|
Assert.Equal(bundle.AlertId, predicate.AlertId);
|
||||||
|
Assert.Equal(bundle.ArtifactId, predicate.ArtifactId);
|
||||||
|
Assert.Equal(1, predicate.CompletenessScore);
|
||||||
|
Assert.Equal(bundle.CreatedAt, predicate.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EvidenceHashSetTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Compute_DeterministicOutput()
|
||||||
|
{
|
||||||
|
var hashes1 = new Dictionary<string, string> { ["a"] = "hash1", ["b"] = "hash2" };
|
||||||
|
var hashes2 = new Dictionary<string, string> { ["b"] = "hash2", ["a"] = "hash1" };
|
||||||
|
|
||||||
|
var set1 = EvidenceHashSet.Compute(hashes1);
|
||||||
|
var set2 = EvidenceHashSet.Compute(hashes2);
|
||||||
|
|
||||||
|
Assert.Equal(set1.CombinedHash, set2.CombinedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_DifferentInputs_DifferentHash()
|
||||||
|
{
|
||||||
|
var hashes1 = new Dictionary<string, string> { ["a"] = "hash1" };
|
||||||
|
var hashes2 = new Dictionary<string, string> { ["a"] = "hash2" };
|
||||||
|
|
||||||
|
var set1 = EvidenceHashSet.Compute(hashes1);
|
||||||
|
var set2 = EvidenceHashSet.Compute(hashes2);
|
||||||
|
|
||||||
|
Assert.NotEqual(set1.CombinedHash, set2.CombinedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_CreatesEmptyHashSet()
|
||||||
|
{
|
||||||
|
var empty = EvidenceHashSet.Empty();
|
||||||
|
|
||||||
|
Assert.Empty(empty.Hashes);
|
||||||
|
Assert.NotEmpty(empty.CombinedHash);
|
||||||
|
Assert.Equal("SHA-256", empty.Algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_PreservesLabeledHashes()
|
||||||
|
{
|
||||||
|
var hashes = new Dictionary<string, string> { ["reachability"] = "h1", ["vex"] = "h2" };
|
||||||
|
var set = EvidenceHashSet.Compute(hashes);
|
||||||
|
|
||||||
|
Assert.NotNull(set.LabeledHashes);
|
||||||
|
Assert.Equal("h1", set.LabeledHashes["reachability"]);
|
||||||
|
Assert.Equal("h2", set.LabeledHashes["vex"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Compute_NullInput_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentNullException>(() => EvidenceHashSet.Compute(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ServiceCollectionExtensionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AddEvidenceBundleServices_RegistersBuilder()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddEvidenceBundleServices();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var builder = provider.GetService<EvidenceBundleBuilder>();
|
||||||
|
Assert.NotNull(builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddEvidenceBundleServices_WithTimeProvider_UsesProvided()
|
||||||
|
{
|
||||||
|
var fakeTime = new FakeTimeProvider();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddEvidenceBundleServices(fakeTime);
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var timeProvider = provider.GetService<TimeProvider>();
|
||||||
|
Assert.Same(fakeTime, timeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddEvidenceBundleServices_NullServices_Throws()
|
||||||
|
{
|
||||||
|
IServiceCollection? services = null;
|
||||||
|
Assert.Throws<ArgumentNullException>(() => services!.AddEvidenceBundleServices());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddEvidenceBundleServices_NullTimeProvider_Throws()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
Assert.Throws<ArgumentNullException>(() => services.AddEvidenceBundleServices(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Bundle\StellaOps.Evidence.Bundle.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user