@@ -134,6 +147,26 @@ export interface FindingDetail extends FindingEvidenceResponse { /> + + + @if (hasProofBadges()) { +
+ +
+ } + + + @if (hasAttestation()) { +
+ +
+ } @@ -330,21 +363,38 @@ export interface FindingDetail extends FindingEvidenceResponse { role="tabpanel" aria-labelledby="tab-proof" > + + @if (hasProofSpine()) { +
+

Evidence Chain

+ +
+ } + + @if (finding()?.decisionDigest) { - - } @else { +
+

Decision Proof

+ +
+ } + + @if (!hasProofSpine() && !finding()?.decisionDigest) {

- No decision digest available. This finding may not have been cached. + No proof data available. This finding may not have been cached or analyzed.

} @@ -643,6 +693,30 @@ export interface FindingDetail extends FindingEvidenceResponse { } } + /* ProofSpine & Attestation integration (SPRINT_1227_0005) */ + .finding-detail__proof-badges { + margin-top: 1rem; + padding: 0.75rem 1rem; + background: rgba(0, 123, 255, 0.05); + border-radius: 8px; + } + + .finding-detail__actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + } + + .finding-detail__proof-spine-section, + .finding-detail__proof-tree-section { + margin-bottom: 1.5rem; + } + + .finding-detail__proof-spine-section:last-child, + .finding-detail__proof-tree-section:last-child { + margin-bottom: 0; + } + @media (prefers-color-scheme: dark) { .finding-detail { background: #1e1e1e; @@ -732,6 +806,7 @@ export class FindingDetailComponent { readonly copyHash = output(); readonly downloadEvidence = output(); readonly refreshManifest = output(); + readonly viewSegmentDetails = output(); // Tab definitions readonly tabs: { id: FindingDetailTab; label: string; icon: string; badge?: () => string | null }[] = [ @@ -829,6 +904,20 @@ export class FindingDetailComponent { return !!f?.veri_key || !!f?.cache_source; }); + // ProofSpine computed properties (SPRINT_1227_0005_0002) + readonly hasProofSpine = computed(() => { + const f = this.finding(); + return !!f?.proofSpine && (f.proofSpine.segments?.length ?? 0) > 0; + }); + + readonly hasProofBadges = computed(() => { + return !!this.finding()?.proofBadges; + }); + + readonly hasAttestation = computed(() => { + return !!this.finding()?.attestationDigest; + }); + // Methods setActiveTab(tab: FindingDetailTab): void { this.tabChange.emit(tab); @@ -864,6 +953,10 @@ export class FindingDetailComponent { this.refreshManifest.emit(); } + onViewSegmentDetails(segment: ProofSegment): void { + this.viewSegmentDetails.emit(segment); + } + formatDate(isoString?: string): string { if (!isoString) return '—'; try { diff --git a/src/Web/StellaOps.Web/src/app/shared/components/findings-view-toggle/findings-view-toggle.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/components/findings-view-toggle/findings-view-toggle.component.spec.ts new file mode 100644 index 000000000..4f62d894f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/findings-view-toggle/findings-view-toggle.component.spec.ts @@ -0,0 +1,73 @@ +// ----------------------------------------------------------------------------- +// findings-view-toggle.component.spec.ts +// Sprint: SPRINT_1227_0005_0001_FE_diff_first_default +// Task: T6 — Unit tests for view toggle component +// ----------------------------------------------------------------------------- + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FindingsViewToggleComponent } from './findings-view-toggle.component'; +import { ViewPreferenceService, FindingsViewMode } from '../../../core/services/view-preference.service'; +import { signal } from '@angular/core'; + +describe('FindingsViewToggleComponent', () => { + let component: FindingsViewToggleComponent; + let fixture: ComponentFixture; + let mockViewPrefService: jasmine.SpyObj; + let viewModeSignal: ReturnType>; + + beforeEach(async () => { + viewModeSignal = signal('diff'); + + mockViewPrefService = jasmine.createSpyObj('ViewPreferenceService', ['setViewMode'], { + viewMode: viewModeSignal.asReadonly() + }); + + await TestBed.configureTestingModule({ + imports: [ + FindingsViewToggleComponent, + NoopAnimationsModule + ], + providers: [ + { provide: ViewPreferenceService, useValue: mockViewPrefService } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(FindingsViewToggleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the current view mode', () => { + expect(component.currentView()).toBe('diff'); + }); + + it('should call setViewMode when toggle changes', () => { + component.onViewChange('detail'); + expect(mockViewPrefService.setViewMode).toHaveBeenCalledWith('detail'); + }); + + it('should render both toggle options', () => { + const toggleButtons = fixture.nativeElement.querySelectorAll('mat-button-toggle'); + expect(toggleButtons.length).toBe(2); + }); + + it('should have correct aria labels', () => { + const diffButton = fixture.nativeElement.querySelector('mat-button-toggle[value="diff"]'); + const detailButton = fixture.nativeElement.querySelector('mat-button-toggle[value="detail"]'); + + expect(diffButton.getAttribute('aria-label')).toBe('Diff view'); + expect(detailButton.getAttribute('aria-label')).toBe('Detail view'); + }); + + it('should update selected state when preference changes', () => { + viewModeSignal.set('detail'); + fixture.detectChanges(); + + expect(component.currentView()).toBe('detail'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/findings-view-toggle/findings-view-toggle.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/findings-view-toggle/findings-view-toggle.component.ts new file mode 100644 index 000000000..d18e818b5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/findings-view-toggle/findings-view-toggle.component.ts @@ -0,0 +1,93 @@ +// ----------------------------------------------------------------------------- +// findings-view-toggle.component.ts +// Sprint: SPRINT_1227_0005_0001_FE_diff_first_default +// Task: T2 — View toggle component for findings (diff/detail) +// ----------------------------------------------------------------------------- + +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ViewPreferenceService, FindingsViewMode } from '../../../core/services/view-preference.service'; + +/** + * Toggle component for switching between diff and detail views. + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'stella-findings-view-toggle', + standalone: true, + imports: [ + CommonModule, + MatButtonToggleModule, + MatIconModule, + MatTooltipModule + ], + template: ` + + + compare_arrows + Diff + + + list + Detail + + + `, + styles: [` + .findings-view-toggle { + height: 36px; + } + + .findings-view-toggle ::ng-deep .mat-button-toggle { + min-width: 80px; + } + + .findings-view-toggle ::ng-deep .mat-button-toggle-label-content { + display: flex; + align-items: center; + gap: 4px; + line-height: 36px; + } + + .toggle-label { + font-size: 13px; + } + + @media (max-width: 600px) { + .toggle-label { + display: none; + } + + .findings-view-toggle ::ng-deep .mat-button-toggle { + min-width: 48px; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FindingsViewToggleComponent { + private readonly viewPref = inject(ViewPreferenceService); + + readonly currentView = this.viewPref.viewMode; + + onViewChange(mode: FindingsViewMode): void { + this.viewPref.setViewMode(mode); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/function-diff/function-diff.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/function-diff/function-diff.component.ts new file mode 100644 index 000000000..7e9dd6914 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/function-diff/function-diff.component.ts @@ -0,0 +1,593 @@ +/** + * Function Diff Component. + * Sprint: SPRINT_1227_0003_0001 (Backport-Aware Resolution UI) + * Task: T2 - Create FunctionDiffComponent + * + * Displays side-by-side function-level disassembly or source diffs + * for binary fingerprint matches, helping users understand what changed. + */ + +import { Component, input, computed, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import type { FunctionChangeInfo } from '../../../core/api/binary-resolution.models'; + +/** + * View mode for the diff display + */ +export type DiffViewMode = 'side-by-side' | 'unified' | 'summary'; + +/** + * Syntax highlight theme + */ +export type DiffTheme = 'light' | 'dark' | 'auto'; + +/** + * Function diff line annotation + */ +interface DiffLine { + lineNumber: number; + type: 'added' | 'removed' | 'unchanged' | 'context'; + content: string; + highlight?: boolean; +} + +/** + * Side-by-side function diff viewer. + * + * Shows before/after function disassembly with: + * - Syntax highlighting for assembly instructions + * - Line-by-line diff annotations + * - Collapsible context lines + * - Hash comparisons + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'stella-function-diff', + standalone: true, + imports: [CommonModule], + template: ` +
+
+
+ + {{ functionName() }} + @if (functionChange().address) { + {{ functionChange().address | number:'16' }} + } +
+ +
+ @if (similarity() != null) { + + {{ similarity() }}% similar + + } + +
+ + + +
+
+
+ + @if (!collapsed()) { +
+ @switch (currentViewMode()) { + @case ('side-by-side') { +
+
+
+ Before (Vulnerable) + @if (functionChange().beforeHash) { + {{ truncateHash(functionChange().beforeHash!) }} + } +
+
{{ formatBeforeLines() }}
+
+ +
+
+ After (Fixed) + @if (functionChange().afterHash) { + {{ truncateHash(functionChange().afterHash!) }} + } +
+
{{ formatAfterLines() }}
+
+
+ } + @case ('unified') { +
+
+ Unified Diff +
+
+
+ } + @case ('summary') { +
+
+
+
Function
+
{{ functionName() }}
+
+
+
Change Type
+
{{ changeTypeLabel() }}
+
+ @if (functionChange().beforeHash && functionChange().afterHash) { +
+
Before Hash
+
{{ functionChange().beforeHash }}
+
+
+
After Hash
+
{{ functionChange().afterHash }}
+
+ } + @if (similarity() != null) { +
+
Similarity
+
{{ similarity() }}%
+
+ } + @if (functionChange().patchId) { +
+
Patch ID
+
{{ functionChange().patchId }}
+
+ } +
+
+ } + } +
+ } +
+ `, + styles: [` + .function-diff { + border: 1px solid var(--border-color, #d0d7de); + border-radius: 6px; + overflow: hidden; + font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + font-size: 0.8125rem; + margin-bottom: 1rem; + + &--collapsed { + .function-diff__header { + border-bottom: none; + } + } + } + + .function-diff__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background: var(--header-bg, #f6f8fa); + border-bottom: 1px solid var(--border-color, #d0d7de); + gap: 1rem; + } + + .function-diff__name { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + } + + .function-diff__icon { + font-size: 1rem; + } + + .function-diff__fn-name { + font-weight: 600; + color: var(--fn-name-color, #0550ae); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .function-diff__address { + color: var(--muted-color, #57606a); + font-size: 0.75rem; + + &::before { + content: '@ 0x'; + } + } + + .function-diff__meta { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .function-diff__similarity { + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 500; + + &--high { + background: rgba(40, 167, 69, 0.15); + color: #28a745; + } + + &--medium { + background: rgba(255, 193, 7, 0.15); + color: #856404; + } + + &--low { + background: rgba(220, 53, 69, 0.15); + color: #dc3545; + } + } + + .function-diff__controls { + display: flex; + gap: 0.5rem; + } + + .function-diff__view-toggle, + .function-diff__collapse-toggle { + padding: 0.25rem 0.5rem; + border: 1px solid var(--border-color, #d0d7de); + border-radius: 4px; + background: var(--btn-bg, #fff); + color: var(--text-color, #24292f); + font-size: 0.6875rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + + &:hover { + background: var(--btn-hover-bg, #f3f4f6); + border-color: var(--border-hover-color, #8c959f); + } + + &:focus { + outline: 2px solid var(--focus-color, #0969da); + outline-offset: 1px; + } + } + + .function-diff__body { + background: var(--body-bg, #fff); + } + + .function-diff__side-by-side { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .function-diff__pane { + overflow-x: auto; + border-right: 1px solid var(--border-color, #d0d7de); + + &:last-child { + border-right: none; + } + + &--before { + background: rgba(255, 129, 130, 0.1); + } + + &--after { + background: rgba(63, 185, 80, 0.1); + } + } + + .function-diff__pane-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--muted-color, #57606a); + background: var(--header-bg, #f6f8fa); + border-bottom: 1px solid var(--border-color, #d0d7de); + } + + .function-diff__hash { + font-size: 0.6875rem; + color: var(--muted-color, #57606a); + background: var(--code-bg, #eff1f3); + padding: 0.125rem 0.375rem; + border-radius: 3px; + } + + .function-diff__code { + margin: 0; + padding: 0.5rem; + overflow-x: auto; + line-height: 1.6; + tab-size: 4; + white-space: pre; + + &--unified { + .diff-add { + background: rgba(63, 185, 80, 0.25); + display: inline-block; + width: 100%; + } + + .diff-remove { + background: rgba(255, 129, 130, 0.25); + display: inline-block; + width: 100%; + } + } + } + + .function-diff__unified { + .function-diff__pane-header { + border-bottom: 1px solid var(--border-color, #d0d7de); + } + } + + .function-diff__summary { + padding: 1rem; + } + + .function-diff__summary-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.75rem; + margin: 0; + } + + .function-diff__summary-item { + dt { + font-size: 0.6875rem; + font-weight: 500; + color: var(--muted-color, #57606a); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + } + + dd { + margin: 0; + font-weight: 500; + + code { + font-size: 0.75rem; + word-break: break-all; + background: var(--code-bg, #eff1f3); + padding: 0.125rem 0.375rem; + border-radius: 3px; + } + } + } + + // Dark mode + :host-context(.dark-theme) { + .function-diff { + --border-color: #30363d; + --header-bg: #161b22; + --body-bg: #0d1117; + --text-color: #c9d1d9; + --muted-color: #8b949e; + --fn-name-color: #58a6ff; + --code-bg: #21262d; + --btn-bg: #21262d; + --btn-hover-bg: #30363d; + --focus-color: #58a6ff; + } + + .function-diff__pane--before { + background: rgba(248, 81, 73, 0.15); + } + + .function-diff__pane--after { + background: rgba(63, 185, 80, 0.15); + } + } + `], +}) +export class FunctionDiffComponent { + /** Function change info to display */ + functionChange = input.required(); + + /** Current view mode */ + viewMode = input('side-by-side'); + + /** Color theme */ + theme = input('auto'); + + /** Whether initially collapsed */ + initiallyCollapsed = input(false); + + /** Internal view mode state */ + private _currentViewMode = signal('side-by-side'); + + /** Collapsed state */ + collapsed = signal(false); + + /** Current view mode (respects initial input then internal state) */ + currentViewMode = computed(() => this._currentViewMode()); + + /** Function name for display */ + functionName = computed(() => { + const change = this.functionChange(); + return change.functionName || ''; + }); + + /** Icon for change type */ + changeIcon = computed(() => { + const change = this.functionChange(); + + switch (change.changeType) { + case 'modified': + return '📝'; + case 'added': + return '➕'; + case 'removed': + return '➖'; + case 'renamed': + return '✏️'; + default: + return '📄'; + } + }); + + /** Label for change type */ + changeTypeLabel = computed(() => { + const change = this.functionChange(); + + switch (change.changeType) { + case 'modified': + return 'Modified'; + case 'added': + return 'Added'; + case 'removed': + return 'Removed'; + case 'renamed': + return 'Renamed'; + default: + return 'Unknown'; + } + }); + + /** Similarity percentage (0-100) */ + similarity = computed(() => { + const change = this.functionChange(); + return change.similarity != null ? Math.round(change.similarity * 100) : null; + }); + + /** Label for current view mode button */ + viewModeLabel = computed(() => { + switch (this.currentViewMode()) { + case 'side-by-side': + return 'Side-by-side'; + case 'unified': + return 'Unified'; + case 'summary': + return 'Summary'; + } + }); + + /** Next view mode for cycling */ + nextViewMode = computed((): DiffViewMode => { + switch (this.currentViewMode()) { + case 'side-by-side': + return 'unified'; + case 'unified': + return 'summary'; + case 'summary': + return 'side-by-side'; + } + }); + + constructor() { + // Initialize with input values + this._currentViewMode.set(this.viewMode()); + this.collapsed.set(this.initiallyCollapsed()); + } + + /** Cycle through view modes */ + cycleViewMode(): void { + this._currentViewMode.set(this.nextViewMode()); + } + + /** Toggle collapsed state */ + toggleCollapsed(): void { + this.collapsed.update(c => !c); + } + + /** Truncate hash for display */ + truncateHash(hash: string): string { + if (hash.length <= 12) return hash; + return hash.slice(0, 8) + '…' + hash.slice(-4); + } + + /** Format before lines for display */ + formatBeforeLines(): string { + const change = this.functionChange(); + if (!change.beforeDisasm) { + return '// No disassembly available'; + } + return change.beforeDisasm; + } + + /** Format after lines for display */ + formatAfterLines(): string { + const change = this.functionChange(); + if (!change.afterDisasm) { + return '// No disassembly available'; + } + return change.afterDisasm; + } + + /** Format unified diff with HTML highlighting */ + formatUnifiedDiff(): string { + const change = this.functionChange(); + const before = change.beforeDisasm?.split('\n') ?? []; + const after = change.afterDisasm?.split('\n') ?? []; + + const lines: string[] = []; + + // Simple line-by-line diff (real implementation would use proper diff algorithm) + const maxLen = Math.max(before.length, after.length); + + for (let i = 0; i < maxLen; i++) { + const beforeLine = before[i]; + const afterLine = after[i]; + + if (beforeLine === afterLine && beforeLine != null) { + lines.push(this.escapeHtml(' ' + beforeLine)); + } else { + if (beforeLine != null) { + lines.push(`-${this.escapeHtml(beforeLine)}`); + } + if (afterLine != null) { + lines.push(`+${this.escapeHtml(afterLine)}`); + } + } + } + + return lines.join('\n'); + } + + /** Escape HTML special characters */ + private escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/index.ts b/src/Web/StellaOps.Web/src/app/shared/components/index.ts index 638adb24d..e23798e30 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/index.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/index.ts @@ -63,3 +63,22 @@ export { InputManifestComponent, InputManifestMode, InputManifestDisplayConfig } export { ProofTreeComponent, VerdictEntry, VerdictStatus, EvidenceChunk } from './proof-tree.component'; export { TimelineEventComponent, TimelineEventType, TimelineEventSeverity, TimelineEvent } from './timeline-event.component'; export { FindingDetailComponent, FindingDetailTab, FindingDetail } from './finding-detail.component'; + +// Diff-First Default View (SPRINT_1227_0005_0001) +export { FindingsViewToggleComponent } from './findings-view-toggle/findings-view-toggle.component'; +export { SmartDiffBadgeComponent, SmartDiffRule, SmartDiffRuleInfo, getSmartDiffRuleInfo, SMART_DIFF_RULES } from './smart-diff-badge/smart-diff-badge.component'; + +// Proof Tree Integration (SPRINT_1227_0005_0002) +export { ProofSpineComponent } from './proof-spine/proof-spine.component'; +export { ProofSegmentComponent } from './proof-spine/proof-segment.component'; +export { ProofBadgesRowComponent } from './proof-spine/proof-badges-row.component'; +export { ChainIntegrityBadgeComponent } from './proof-spine/chain-integrity-badge.component'; + +// Copy & Audit Pack Export (SPRINT_1227_0005_0003) +export { CopyAttestationButtonComponent, AttestationFormat, DsseEnvelope, AttestationResponse } from './copy-attestation/copy-attestation-button.component'; +export { ExportAuditPackButtonComponent } from './audit-pack/export-audit-pack-button.component'; +export { ExportAuditPackDialogComponent, ExportDialogData } from './audit-pack/export-audit-pack-dialog.component'; + +// VEX Trust Column (SPRINT_1227_0004_0002) +export { VexTrustChipComponent, VexTrustStatus, TrustScoreBreakdown, TrustTier, FreshnessStatus, TrustChipPopoverEvent } from './vex-trust-chip'; +export { VexTrustPopoverComponent } from './vex-trust-popover'; diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/__tests__/proof-spine.e2e.spec.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/__tests__/proof-spine.e2e.spec.ts new file mode 100644 index 000000000..1086d00f6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/__tests__/proof-spine.e2e.spec.ts @@ -0,0 +1,299 @@ +// ----------------------------------------------------------------------------- +// proof-spine.e2e.spec.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Task: T10 — E2E tests for proof tree interaction +// ----------------------------------------------------------------------------- + +import { test, expect } from '@playwright/test'; + +test.describe('Proof Spine E2E', () => { + test.beforeEach(async ({ page }) => { + // Navigate to a finding detail page that has proof spine data + await page.goto('/findings/sha256:test-finding-id'); + await page.waitForSelector('.finding-detail, .proof-spine', { timeout: 10000 }).catch(() => {}); + }); + + test.describe('Proof Spine Component', () => { + test('should display proof spine when finding has evidence chain', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + const hasProofSpine = await proofSpine.count() > 0; + + if (hasProofSpine) { + await expect(proofSpine.first()).toBeVisible(); + } + }); + + test('should show segment count in toggle button', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + const toggle = proofSpine.locator('.proof-spine-toggle'); + await expect(toggle).toContainText(/\d+ segments/); + } + }); + + test('should show chain integrity badge', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + const integrityBadge = proofSpine.locator('stella-chain-integrity-badge, .chain-integrity-badge'); + await expect(integrityBadge).toBeVisible(); + } + }); + }); + + test.describe('Expand/Collapse', () => { + test('should start collapsed by default', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + const content = proofSpine.locator('.proof-spine-content'); + await expect(content).not.toBeVisible(); + } + }); + + test('should expand when toggle is clicked', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + const toggle = proofSpine.locator('.proof-spine-toggle'); + await toggle.click(); + + const content = proofSpine.locator('.proof-spine-content'); + await expect(content).toBeVisible(); + } + }); + + test('should collapse when toggle is clicked again', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + const toggle = proofSpine.locator('.proof-spine-toggle'); + + // Expand + await toggle.click(); + await expect(proofSpine.locator('.proof-spine-content')).toBeVisible(); + + // Collapse + await toggle.click(); + await expect(proofSpine.locator('.proof-spine-content')).not.toBeVisible(); + } + }); + + test('should have correct aria-expanded attribute', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + const toggle = proofSpine.locator('.proof-spine-toggle'); + + // Initially collapsed + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + // Click to expand + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + } + }); + }); + + test.describe('Proof Segments', () => { + test('should display all segment types with correct icons', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + // Expand the proof spine + await proofSpine.locator('.proof-spine-toggle').click(); + + const segments = proofSpine.locator('stella-proof-segment, .proof-segment'); + const segmentCount = await segments.count(); + + for (let i = 0; i < segmentCount; i++) { + const segment = segments.nth(i); + // Each segment should have an icon + await expect(segment.locator('mat-icon').first()).toBeVisible(); + // Each segment should have content + await expect(segment.locator('.segment-content, .segment-header')).toBeVisible(); + } + } + }); + + test('should display segment connectors between segments', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + const segments = proofSpine.locator('stella-proof-segment, .proof-segment'); + const segmentCount = await segments.count(); + + if (segmentCount > 1) { + // Middle segments should have connector lines + const connector = proofSpine.locator('.connector-line, .segment-connector'); + expect(await connector.count()).toBeGreaterThan(0); + } + } + }); + + test('should display segment digest truncated', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + const digestDisplay = proofSpine.locator('.segment-digest, code'); + if (await digestDisplay.count() > 0) { + const digestText = await digestDisplay.first().textContent(); + // Digest should be truncated (not full sha256) + expect(digestText?.length).toBeLessThan(70); + } + } + }); + }); + + test.describe('Segment Detail Modal', () => { + test('should open modal when view details is clicked', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + // Find and click view details button + const viewButton = proofSpine.locator('button[mattooltip*="details"], button:has(mat-icon:has-text("visibility"))'); + if (await viewButton.count() > 0) { + await viewButton.first().click(); + + // Modal should appear + const modal = page.locator('mat-dialog-container, .segment-detail-modal'); + await expect(modal).toBeVisible(); + } + } + }); + + test('should display segment type in modal header', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + const viewButton = proofSpine.locator('button[mattooltip*="details"], button:has(mat-icon:has-text("visibility"))'); + if (await viewButton.count() > 0) { + await viewButton.first().click(); + + const modalHeader = page.locator('mat-dialog-container h2, .dialog-header h2'); + await expect(modalHeader).toBeVisible(); + } + } + }); + + test('should have tabs for Summary, Evidence, Verification, Raw', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + const viewButton = proofSpine.locator('button[mattooltip*="details"], button:has(mat-icon:has-text("visibility"))'); + if (await viewButton.count() > 0) { + await viewButton.first().click(); + + const tabs = page.locator('mat-tab-header .mat-tab-label, .mat-mdc-tab'); + if (await tabs.count() >= 4) { + await expect(tabs.nth(0)).toContainText(/Summary/i); + await expect(tabs.nth(1)).toContainText(/Evidence/i); + await expect(tabs.nth(2)).toContainText(/Verification/i); + await expect(tabs.nth(3)).toContainText(/Raw/i); + } + } + } + }); + + test('should close modal on close button click', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + const viewButton = proofSpine.locator('button[mattooltip*="details"], button:has(mat-icon:has-text("visibility"))'); + if (await viewButton.count() > 0) { + await viewButton.first().click(); + + const closeButton = page.locator('button[mat-dialog-close], button:has-text("Close")'); + if (await closeButton.count() > 0) { + await closeButton.first().click(); + + await expect(page.locator('mat-dialog-container')).not.toBeVisible(); + } + } + } + }); + + test('should copy digest to clipboard', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + const viewButton = proofSpine.locator('button[mattooltip*="details"], button:has(mat-icon:has-text("visibility"))'); + if (await viewButton.count() > 0) { + await viewButton.first().click(); + + // Navigate to Verification tab + const verificationTab = page.locator('mat-tab-header .mat-tab-label:has-text("Verification"), .mat-mdc-tab:has-text("Verification")'); + if (await verificationTab.count() > 0) { + await verificationTab.click(); + + // Click copy button + const copyButton = page.locator('.digest-value-row button, button[mattooltip*="Copy"]'); + if (await copyButton.count() > 0) { + await copyButton.first().click(); + + // Check for check icon indicating copy success + await expect(copyButton.first().locator('mat-icon')).toContainText(/check|content_copy/); + } + } + } + } + }); + }); + + test.describe('Proof Badges Row', () => { + test('should display proof badges when available', async ({ page }) => { + const badges = page.locator('stella-proof-badges-row, .proof-badges-row'); + if (await badges.count() > 0) { + await expect(badges.first()).toBeVisible(); + } + }); + + test('should display all 4 axis badges', async ({ page }) => { + const badgesRow = page.locator('stella-proof-badges-row, .proof-badges-row'); + if (await badgesRow.count() > 0) { + const badges = badgesRow.locator('stella-proof-badge, .proof-badge'); + // Should have 4 badges: reachability, runtime, policy, provenance + expect(await badges.count()).toBe(4); + } + }); + + test('should show tooltips on badge hover', async ({ page }) => { + const badgesRow = page.locator('stella-proof-badges-row, .proof-badges-row'); + if (await badgesRow.count() > 0) { + const badge = badgesRow.locator('stella-proof-badge, .proof-badge').first(); + await badge.hover(); + + // Tooltip should appear + const tooltip = page.locator('.mat-tooltip, .mdc-tooltip'); + // Tooltip may or may not be visible depending on implementation + } + }); + }); + + test.describe('Accessibility', () => { + test('should have proper role attributes', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + await proofSpine.locator('.proof-spine-toggle').click(); + + const content = proofSpine.locator('.proof-spine-content'); + await expect(content).toHaveAttribute('role', 'tree'); + } + }); + + test('should be keyboard navigable', async ({ page }) => { + const proofSpine = page.locator('stella-proof-spine, .proof-spine'); + if (await proofSpine.count() > 0) { + const toggle = proofSpine.locator('.proof-spine-toggle'); + + // Focus toggle + await toggle.focus(); + + // Press Enter to toggle + await page.keyboard.press('Enter'); + await expect(proofSpine.locator('.proof-spine-content')).toBeVisible(); + } + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/chain-integrity-badge.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/chain-integrity-badge.component.ts new file mode 100644 index 000000000..0487db86b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/chain-integrity-badge.component.ts @@ -0,0 +1,74 @@ +// ----------------------------------------------------------------------------- +// chain-integrity-badge.component.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Task: T2 — Chain integrity indicator badge +// ----------------------------------------------------------------------------- + +import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +/** + * Badge indicating proof chain integrity status. + * + * Shows whether all segment digests are correctly linked. + */ +@Component({ + selector: 'stella-chain-integrity-badge', + standalone: true, + imports: [CommonModule, MatIconModule, MatTooltipModule], + template: ` + + {{ valid ? 'verified' : 'error' }} + {{ valid ? 'Chain Valid' : 'Chain Broken' }} + + `, + styles: [` + .chain-integrity-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + } + + .chain-integrity-badge mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + .chain-integrity-badge.valid { + background: rgba(76, 175, 80, 0.15); + color: #2e7d32; + } + + .chain-integrity-badge.invalid { + background: rgba(244, 67, 54, 0.15); + color: #c62828; + } + + @media (max-width: 600px) { + .badge-label { + display: none; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ChainIntegrityBadgeComponent { + @Input() valid = false; + + get tooltip(): string { + return this.valid + ? 'All segment digests correctly link - chain is cryptographically valid' + : 'Chain integrity check failed - one or more segment links are broken'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/index.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/index.ts new file mode 100644 index 000000000..c5ca6cde0 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/index.ts @@ -0,0 +1,11 @@ +// ----------------------------------------------------------------------------- +// proof-spine/index.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Export all proof-spine components +// ----------------------------------------------------------------------------- + +export { ProofSpineComponent } from './proof-spine.component'; +export { ProofSegmentComponent } from './proof-segment.component'; +export { ProofBadgesRowComponent } from './proof-badges-row.component'; +export { ChainIntegrityBadgeComponent } from './chain-integrity-badge.component'; +export { SegmentDetailModalComponent, SegmentDetailData } from './segment-detail-modal.component'; diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-badges-row.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-badges-row.component.ts new file mode 100644 index 000000000..5db51e2c3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-badges-row.component.ts @@ -0,0 +1,158 @@ +// ----------------------------------------------------------------------------- +// proof-badges-row.component.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Task: T3 — 4-axis proof badges row component +// ----------------------------------------------------------------------------- + +import { Component, ChangeDetectionStrategy, Input, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { + ProofBadges, + BadgeStatus, + BADGE_AXIS_META +} from '../../../core/models/proof-spine.model'; + +/** + * Row of 4-axis proof indicator badges. + * + * Shows at-a-glance proof status for: + * - Reachability: Call path analysis + * - Runtime: Signal correlation + * - Policy: Policy evaluation + * - Provenance: SBOM/attestation chain + */ +@Component({ + selector: 'stella-proof-badges-row', + standalone: true, + imports: [CommonModule, MatIconModule, MatTooltipModule], + template: ` +
+ @for (axis of axisMetadata; track axis.axis) { + + {{ axis.icon }} + {{ getStatusIcon(axis.axis) }} + + } +
+ `, + styles: [` + .proof-badges-row { + display: flex; + gap: 4px; + } + + .proof-badge { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + width: 28px; + height: 28px; + border-radius: 50%; + cursor: help; + } + + .proof-badge mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + .badge-status-icon { + position: absolute; + bottom: -2px; + right: -2px; + font-size: 10px; + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: white; + box-shadow: 0 1px 2px rgba(0,0,0,0.2); + } + + /* Axis colors */ + .proof-badge-reachability { + background: rgba(156, 39, 176, 0.15); + color: #7b1fa2; + } + + .proof-badge-runtime { + background: rgba(255, 152, 0, 0.15); + color: #ef6c00; + } + + .proof-badge-policy { + background: rgba(96, 125, 139, 0.15); + color: #455a64; + } + + .proof-badge-provenance { + background: rgba(33, 150, 243, 0.15); + color: #1565c0; + } + + /* Status modifiers */ + .status-confirmed { + border: 2px solid #4caf50; + } + + .status-partial { + border: 2px solid #ff9800; + } + + .status-none { + border: 2px solid #9e9e9e; + opacity: 0.6; + } + + .status-unknown { + border: 2px dashed #9e9e9e; + opacity: 0.5; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ProofBadgesRowComponent { + @Input() badges!: ProofBadges; + + readonly axisMetadata = BADGE_AXIS_META; + + getStatus(axis: keyof ProofBadges): BadgeStatus { + return this.badges?.[axis] ?? 'unknown'; + } + + getStatusIcon(axis: keyof ProofBadges): string { + const status = this.getStatus(axis); + switch (status) { + case 'confirmed': return '\u2713'; // check mark + case 'partial': return '~'; + case 'none': return '\u2717'; // x mark + default: return '?'; + } + } + + getTooltip(axis: { axis: keyof ProofBadges; label: string; description: string }): string { + const status = this.getStatus(axis.axis); + const statusLabel = this.getStatusLabel(status); + return `${axis.label}: ${statusLabel}\n${axis.description}`; + } + + private getStatusLabel(status: BadgeStatus): string { + switch (status) { + case 'confirmed': return 'Confirmed'; + case 'partial': return 'Partial'; + case 'none': return 'Not Available'; + default: return 'Unknown'; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-segment.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-segment.component.ts new file mode 100644 index 000000000..b8688aa6f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-segment.component.ts @@ -0,0 +1,242 @@ +// ----------------------------------------------------------------------------- +// proof-segment.component.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Task: T2 — Individual proof segment display component +// ----------------------------------------------------------------------------- + +import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, computed } from '@angular/core'; +import { CommonModule, DatePipe } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { + ProofSegment, + ProofSegmentType, + getSegmentTypeMeta +} from '../../../core/models/proof-spine.model'; +import { TruncatePipe } from '../../pipes/truncate.pipe'; + +/** + * Individual segment display within ProofSpine tree. + * + * Shows: + * - Segment type icon and label + * - Timestamp + * - Evidence summary + * - Digest hash (truncated) + * - Connector lines to chain + */ +@Component({ + selector: 'stella-proof-segment', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + MatTooltipModule, + DatePipe, + TruncatePipe + ], + template: ` +
+
+ @if (!isFirst) { +
+ } +
+ {{ segmentMeta().icon }} +
+ @if (!isLast) { +
+ } +
+ +
+
+ {{ segmentMeta().label }} + + {{ segment.timestamp | date:'short' }} + +
+ +
+ {{ segmentSummary() }} +
+ + +
+
+ `, + styles: [` + .proof-segment { + display: flex; + gap: 12px; + position: relative; + } + + .segment-connector { + display: flex; + flex-direction: column; + align-items: center; + width: 40px; + flex-shrink: 0; + } + + .connector-line { + width: 2px; + background-color: var(--mat-divider-color, #e0e0e0); + } + + .connector-line-top { + height: 12px; + } + + .connector-line-bottom { + flex: 1; + min-height: 12px; + } + + .segment-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .segment-icon mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + + .segment-content { + flex: 1; + padding-bottom: 16px; + min-width: 0; + } + + .proof-segment.last .segment-content { + padding-bottom: 0; + } + + .segment-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + } + + .segment-type { + font-weight: 500; + font-size: 14px; + } + + .segment-timestamp { + font-size: 12px; + color: var(--mat-hint-text-color, #666); + } + + .segment-summary { + font-size: 13px; + color: var(--mat-secondary-text-color, #555); + margin-bottom: 8px; + line-height: 1.4; + } + + .segment-footer { + display: flex; + align-items: center; + gap: 8px; + } + + .segment-digest { + font-family: 'SF Mono', 'Consolas', monospace; + font-size: 11px; + background: var(--mat-app-surface, #f5f5f5); + padding: 2px 6px; + border-radius: 4px; + cursor: help; + } + + .segment-confidence { + font-size: 11px; + font-weight: 500; + padding: 2px 6px; + border-radius: 4px; + } + + .segment-confidence.high { + background: rgba(76, 175, 80, 0.15); + color: #2e7d32; + } + + .segment-confidence.medium { + background: rgba(255, 193, 7, 0.15); + color: #f57f17; + } + + .segment-confidence.low { + background: rgba(244, 67, 54, 0.15); + color: #c62828; + } + + .view-details-btn { + margin-left: auto; + width: 28px; + height: 28px; + } + + .view-details-btn mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ProofSegmentComponent { + @Input() segment!: ProofSegment; + @Input() isFirst = false; + @Input() isLast = false; + @Output() viewDetails = new EventEmitter(); + + readonly segmentMeta = computed(() => getSegmentTypeMeta(this.segment.type)); + + readonly segmentSummary = computed(() => { + return this.segment.evidence?.summary ?? 'View details for more information'; + }); +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-spine.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-spine.component.spec.ts new file mode 100644 index 000000000..be0a5615a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-spine.component.spec.ts @@ -0,0 +1,114 @@ +// ----------------------------------------------------------------------------- +// proof-spine.component.spec.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Tests for ProofSpine component +// ----------------------------------------------------------------------------- + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ProofSpineComponent } from './proof-spine.component'; +import { ProofSpine, ProofSegment } from '../../../core/models/proof-spine.model'; + +describe('ProofSpineComponent', () => { + let component: ProofSpineComponent; + let fixture: ComponentFixture; + + const mockSegment = (type: string, digest: string, prevDigest: string | null): ProofSegment => ({ + type: type as any, + segmentDigest: digest, + previousSegmentDigest: prevDigest, + timestamp: new Date().toISOString(), + evidence: { + summary: `Test ${type} evidence`, + details: {} + } + }); + + const mockProofSpine: ProofSpine = { + findingId: 'finding-123', + segments: [ + mockSegment('SbomSlice', 'sha256:aaa', null), + mockSegment('Match', 'sha256:bbb', 'sha256:aaa'), + mockSegment('Reachability', 'sha256:ccc', 'sha256:bbb'), + ], + chainIntegrity: true, + computedAt: new Date().toISOString() + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + ProofSpineComponent, + NoopAnimationsModule + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ProofSpineComponent); + component = fixture.componentInstance; + component.proofSpine = mockProofSpine; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display segment count', () => { + expect(component.segmentCount()).toBe(3); + }); + + it('should start collapsed', () => { + expect(component.expanded()).toBe(false); + }); + + it('should expand on toggle', () => { + component.toggle(); + expect(component.expanded()).toBe(true); + }); + + it('should report valid chain for properly linked segments', () => { + expect(component.chainValid()).toBe(true); + }); + + it('should report invalid chain when segments are unlinked', () => { + component.proofSpine = { + ...mockProofSpine, + chainIntegrity: undefined, + segments: [ + mockSegment('SbomSlice', 'sha256:aaa', null), + mockSegment('Match', 'sha256:bbb', 'sha256:wrong'), // Broken link + ] + }; + fixture.detectChanges(); + expect(component.chainValid()).toBe(false); + }); + + it('should emit viewSegmentDetails when segment is clicked', () => { + const spy = jest.spyOn(component.viewSegmentDetails, 'emit'); + const segment = mockProofSpine.segments[0]; + component.onViewDetails(segment); + expect(spy).toHaveBeenCalledWith(segment); + }); + + it('should handle empty segments array', () => { + component.proofSpine = { + ...mockProofSpine, + segments: [] + }; + fixture.detectChanges(); + expect(component.segmentCount()).toBe(0); + expect(component.chainValid()).toBe(true); // Empty chain is valid + }); + + it('should reject first segment with non-null previous digest', () => { + component.proofSpine = { + ...mockProofSpine, + chainIntegrity: undefined, + segments: [ + mockSegment('SbomSlice', 'sha256:aaa', 'sha256:wrong'), // Should be null + ] + }; + fixture.detectChanges(); + expect(component.chainValid()).toBe(false); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-spine.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-spine.component.ts new file mode 100644 index 000000000..69929f4bc --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/proof-spine.component.ts @@ -0,0 +1,209 @@ +// ----------------------------------------------------------------------------- +// proof-spine.component.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Task: T2 — ProofSpine collapsible tree component +// ----------------------------------------------------------------------------- + +import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, signal, computed, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; + +import { + ProofSpine, + ProofSegment, + SEGMENT_TYPE_META, + getSegmentTypeMeta +} from '../../../core/models/proof-spine.model'; +import { ProofSegmentComponent } from './proof-segment.component'; +import { ChainIntegrityBadgeComponent } from './chain-integrity-badge.component'; +import { SegmentDetailModalComponent, SegmentDetailData } from './segment-detail-modal.component'; + +/** + * Collapsible ProofSpine tree component. + * + * Displays the evidence chain for a finding with: + * - Collapsible tree structure + * - Chain integrity indicator + * - Segment drill-down capability + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'stella-proof-spine', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + MatTooltipModule, + MatDialogModule, + ProofSegmentComponent, + ChainIntegrityBadgeComponent + ], + template: ` +
+ + + @if (expanded()) { +
+ @for (segment of segments(); track segment.segmentDigest; let i = $index) { + + } + + @if (segments().length === 0) { +
+ info_outline + No evidence segments available +
+ } +
+ } +
+ `, + styles: [` + .proof-spine { + border: 1px solid var(--mat-divider-color, #e0e0e0); + border-radius: 8px; + overflow: hidden; + background: var(--mat-card-background-color, #fff); + } + + .proof-spine-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 12px 16px; + background: var(--mat-toolbar-container-background-color, #f5f5f5); + border: none; + cursor: pointer; + text-align: left; + font-size: 14px; + font-weight: 500; + transition: background-color 0.2s; + } + + .proof-spine-toggle:hover { + background: var(--mat-toolbar-container-background-color, #eeeeee); + } + + .proof-spine-toggle:focus-visible { + outline: 2px solid var(--mat-primary-color, #1976d2); + outline-offset: -2px; + } + + .toggle-icon { + color: var(--mat-icon-color, #666); + transition: transform 0.2s; + } + + .expanded .toggle-icon { + transform: rotate(180deg); + } + + .toggle-label { + flex: 1; + } + + .proof-spine-content { + padding: 16px; + border-top: 1px solid var(--mat-divider-color, #e0e0e0); + } + + .proof-spine-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: var(--mat-hint-text-color, #666); + font-size: 14px; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ProofSpineComponent { + private readonly dialog = inject(MatDialog); + + @Input() proofSpine!: ProofSpine; + @Input() findingId?: string; + @Output() viewSegmentDetails = new EventEmitter(); + + readonly expanded = signal(false); + + readonly segments = computed(() => this.proofSpine?.segments ?? []); + readonly segmentCount = computed(() => this.segments().length); + + readonly chainValid = computed(() => { + if (!this.proofSpine) return false; + if (this.proofSpine.chainIntegrity !== undefined) { + return this.proofSpine.chainIntegrity; + } + return this.validateChain(); + }); + + toggle(): void { + this.expanded.update(v => !v); + } + + onViewDetails(segment: ProofSegment): void { + const segmentIndex = this.segments().findIndex( + s => s.segmentDigest === segment.segmentDigest + ); + + const data: SegmentDetailData = { + segment, + findingId: this.findingId || this.proofSpine?.findingId || 'unknown', + segmentIndex, + totalSegments: this.segmentCount() + }; + + this.dialog.open(SegmentDetailModalComponent, { + data, + width: '700px', + maxHeight: '90vh' + }); + + this.viewSegmentDetails.emit(segment); + } + + private validateChain(): boolean { + const segs = this.segments(); + if (segs.length === 0) return true; + + // First segment should have null previousSegmentDigest + if (segs[0].previousSegmentDigest !== null) return false; + + // Each subsequent segment should link to the previous + for (let i = 1; i < segs.length; i++) { + if (segs[i].previousSegmentDigest !== segs[i - 1].segmentDigest) { + return false; + } + } + return true; + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/segment-detail-modal.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/segment-detail-modal.component.ts new file mode 100644 index 000000000..51acef1fe --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/proof-spine/segment-detail-modal.component.ts @@ -0,0 +1,503 @@ +// ----------------------------------------------------------------------------- +// segment-detail-modal.component.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Task: T8 — Segment detail modal for drill-down view +// ----------------------------------------------------------------------------- + +import { Component, ChangeDetectionStrategy, Inject, computed, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatChipsModule } from '@angular/material/chips'; +import { Clipboard } from '@angular/cdk/clipboard'; + +import { ProofSegment, ProofSegmentType } from '../../../core/models/proof-spine.model'; + +/** + * Data passed to the segment detail modal. + */ +export interface SegmentDetailData { + segment: ProofSegment; + findingId: string; + segmentIndex: number; + totalSegments: number; +} + +/** + * Modal dialog for displaying detailed proof segment information. + * Shows the full evidence payload, cryptographic verification details, + * and allows copying of attestation data. + */ +@Component({ + selector: 'stella-segment-detail-modal', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatTabsModule, + MatTooltipModule, + MatExpansionModule, + MatChipsModule + ], + template: ` +
+
+
+ {{ segmentIcon() }} +
+

{{ segmentTypeLabel() }}

+ + Segment {{ data.segmentIndex + 1 }} of {{ data.totalSegments }} + +
+
+ +
+ + + + + +
+
+

Evidence Summary

+

{{ segment.evidence?.summary || 'No summary available' }}

+
+ + +
+
+ + + +
+
+

Evidence Details

+ @if (hasEvidenceDetails()) { + + @for (key of evidenceKeys(); track key) { + + + {{ formatKey(key) }} + +
{{ formatValue(segment.evidence?.details?.[key]) }}
+
+ } +
+ } @else { +

No detailed evidence available

+ } +
+
+
+ + + +
+
+

Cryptographic Verification

+ +
+
+ Segment Digest +
+ {{ segment.segmentDigest }} + +
+
+ + @if (segment.previousSegmentDigest) { +
+ Previous Segment Digest +
+ {{ segment.previousSegmentDigest }} + +
+
+ } @else { +
+ Chain Position + Root Segment +
+ } +
+ + @if (segment.evidence?.digests?.length) { +
+

Referenced Content Digests

+
+ @for (digest of segment.evidence!.digests!; track digest; let i = $index) { +
+ {{ digest }} + +
+ } +
+
+ } +
+
+
+ + + +
+
+
+

Raw Segment Data

+ +
+
{{ rawJson() }}
+
+
+
+
+
+ + + + +
+ `, + styles: [` + .segment-detail-modal { + min-width: 600px; + max-width: 800px; + } + + .dialog-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding-bottom: 16px; + border-bottom: 1px solid #e5e7eb; + } + + .header-content { + display: flex; + align-items: center; + gap: 16px; + } + + .segment-icon { + font-size: 32px; + width: 32px; + height: 32px; + padding: 8px; + border-radius: 8px; + background: #f3f4f6; + + &.type-SbomSlice { color: #3b82f6; background: #eff6ff; } + &.type-Match { color: #ef4444; background: #fef2f2; } + &.type-Reachability { color: #f59e0b; background: #fffbeb; } + &.type-GuardAnalysis { color: #10b981; background: #ecfdf5; } + &.type-RuntimeObservation { color: #8b5cf6; background: #f5f3ff; } + &.type-PolicyEval { color: #6366f1; background: #eef2ff; } + } + + .header-text h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + } + + .segment-position { + font-size: 13px; + color: #6b7280; + } + + .tab-content { + padding: 24px 0; + } + + h3 { + margin: 0 0 16px 0; + font-size: 16px; + font-weight: 600; + color: #374151; + } + + h4 { + margin: 16px 0 8px 0; + font-size: 14px; + font-weight: 600; + color: #4b5563; + } + + .summary-text { + font-size: 15px; + line-height: 1.6; + color: #1f2937; + padding: 16px; + background: #f9fafb; + border-radius: 8px; + } + + .metadata-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-top: 24px; + } + + .metadata-item { + display: flex; + flex-direction: column; + gap: 4px; + + .label { + font-size: 12px; + font-weight: 500; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .value { + font-size: 14px; + color: #1f2937; + } + } + + .type-chip { + &.type-SbomSlice { background: #eff6ff; color: #1d4ed8; } + &.type-Match { background: #fef2f2; color: #b91c1c; } + &.type-Reachability { background: #fffbeb; color: #b45309; } + &.type-GuardAnalysis { background: #ecfdf5; color: #047857; } + &.type-RuntimeObservation { background: #f5f3ff; color: #6d28d9; } + &.type-PolicyEval { background: #eef2ff; color: #4338ca; } + } + + .evidence-value { + font-family: monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-all; + background: #f9fafb; + padding: 12px; + border-radius: 4px; + margin: 0; + max-height: 200px; + overflow: auto; + } + + .no-data { + color: #9ca3af; + font-style: italic; + } + + .digest-block { + background: #f9fafb; + border-radius: 8px; + padding: 16px; + } + + .digest-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 0; + + &:not(:last-child) { + border-bottom: 1px solid #e5e7eb; + } + + &.root-indicator { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } + + .digest-label { + font-size: 12px; + font-weight: 500; + color: #6b7280; + text-transform: uppercase; + } + + .digest-value-row { + display: flex; + align-items: center; + gap: 8px; + } + + .digest-value { + font-family: monospace; + font-size: 12px; + color: #1f2937; + word-break: break-all; + flex: 1; + } + + .referenced-digests { + margin-top: 24px; + } + + .digest-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .referenced-digest { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #f9fafb; + border-radius: 4px; + + code { + font-size: 11px; + word-break: break-all; + } + } + + .raw-section { + .raw-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + } + + .raw-json { + font-family: monospace; + font-size: 12px; + background: #1f2937; + color: #f9fafb; + padding: 16px; + border-radius: 8px; + overflow: auto; + max-height: 400px; + white-space: pre-wrap; + word-break: break-all; + } + + .copied { + color: #10b981 !important; + } + + mat-dialog-content { + max-height: 70vh; + overflow: auto; + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SegmentDetailModalComponent { + readonly segment: ProofSegment; + readonly copied = signal(null); + + constructor( + private readonly dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public readonly data: SegmentDetailData, + private readonly clipboard: Clipboard + ) { + this.segment = data.segment; + } + + readonly segmentIcon = computed(() => { + switch (this.segment.type) { + case 'SbomSlice': return 'inventory_2'; + case 'Match': return 'search'; + case 'Reachability': return 'call_split'; + case 'GuardAnalysis': return 'shield'; + case 'RuntimeObservation': return 'sensors'; + case 'PolicyEval': return 'gavel'; + default: return 'help'; + } + }); + + readonly segmentTypeLabel = computed(() => { + switch (this.segment.type) { + case 'SbomSlice': return 'SBOM Component Identification'; + case 'Match': return 'Vulnerability Match'; + case 'Reachability': return 'Reachability Analysis'; + case 'GuardAnalysis': return 'Guard/Mitigation Analysis'; + case 'RuntimeObservation': return 'Runtime Signal Observation'; + case 'PolicyEval': return 'Policy Evaluation'; + default: return this.segment.type; + } + }); + + readonly rawJson = computed(() => { + return JSON.stringify(this.segment, null, 2); + }); + + hasEvidenceDetails(): boolean { + return !!this.segment.evidence?.details && + Object.keys(this.segment.evidence.details).length > 0; + } + + evidenceKeys(): string[] { + return Object.keys(this.segment.evidence?.details || {}); + } + + formatKey(key: string): string { + return key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .trim(); + } + + formatValue(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'string') return value; + return JSON.stringify(value, null, 2); + } + + copyToClipboard(text: string, id = 'segment'): void { + this.clipboard.copy(text); + this.copied.set(id); + setTimeout(() => this.copied.set(null), 2000); + } + + copyRawJson(): void { + this.clipboard.copy(this.rawJson()); + this.copied.set('raw'); + setTimeout(() => this.copied.set(null), 2000); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/resolution-chip/resolution-chip.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/components/resolution-chip/resolution-chip.component.spec.ts new file mode 100644 index 000000000..bcacae0a9 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/resolution-chip/resolution-chip.component.spec.ts @@ -0,0 +1,229 @@ +/** + * Resolution Chip Component Tests. + * Sprint: SPRINT_1227_0003_0001 (Backport-Aware Resolution UI) + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ResolutionChipComponent } from './resolution-chip.component'; +import type { VulnResolutionSummary } from '../../../core/api/binary-resolution.models'; + +describe('ResolutionChipComponent', () => { + let component: ResolutionChipComponent; + let fixture: ComponentFixture; + + const mockFixedBackport: VulnResolutionSummary = { + status: 'Fixed', + matchType: 'fingerprint', + confidence: 0.92, + hasEvidence: true, + distroAdvisoryId: 'DSA-5123-1', + fixedVersion: '1.2.3-1ubuntu0.1', + }; + + const mockFixedVersion: VulnResolutionSummary = { + status: 'Fixed', + matchType: 'version_range', + confidence: 1.0, + hasEvidence: false, + fixedVersion: '2.0.0', + }; + + const mockVulnerable: VulnResolutionSummary = { + status: 'Vulnerable', + matchType: 'hash_exact', + confidence: 0.99, + hasEvidence: true, + }; + + const mockUnknown: VulnResolutionSummary = { + status: 'Unknown', + hasEvidence: false, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResolutionChipComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ResolutionChipComponent); + component = fixture.componentInstance; + }); + + describe('rendering', () => { + it('should create', () => { + fixture.componentRef.setInput('resolution', mockFixedVersion); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should display backport label for fingerprint matches', () => { + fixture.componentRef.setInput('resolution', mockFixedBackport); + fixture.detectChanges(); + + const label = component.label(); + expect(label).toContain('backport'); + expect(label).toContain('DSA-5123-1'); + }); + + it('should display version label for version range matches', () => { + fixture.componentRef.setInput('resolution', mockFixedVersion); + fixture.detectChanges(); + + const label = component.label(); + expect(label).toBe('Fixed (2.0.0)'); + expect(label).not.toContain('backport'); + }); + + it('should display Vulnerable status', () => { + fixture.componentRef.setInput('resolution', mockVulnerable); + fixture.detectChanges(); + + expect(component.label()).toBe('Vulnerable'); + }); + + it('should display Unknown status', () => { + fixture.componentRef.setInput('resolution', mockUnknown); + fixture.detectChanges(); + + expect(component.label()).toBe('Unknown'); + }); + }); + + describe('icons', () => { + it('should show search icon for backport matches', () => { + fixture.componentRef.setInput('resolution', mockFixedBackport); + fixture.detectChanges(); + + expect(component.icon()).toBe('🔍'); + }); + + it('should show checkmark for version-based fix', () => { + fixture.componentRef.setInput('resolution', mockFixedVersion); + fixture.detectChanges(); + + expect(component.icon()).toBe('✅'); + }); + + it('should show warning for vulnerable', () => { + fixture.componentRef.setInput('resolution', mockVulnerable); + fixture.detectChanges(); + + expect(component.icon()).toBe('⚠️'); + }); + + it('should show question mark for unknown', () => { + fixture.componentRef.setInput('resolution', mockUnknown); + fixture.detectChanges(); + + expect(component.icon()).toBe('❓'); + }); + }); + + describe('CSS classes', () => { + it('should apply backport class for fingerprint matches', () => { + fixture.componentRef.setInput('resolution', mockFixedBackport); + fixture.detectChanges(); + + expect(component.chipClass()).toContain('fixed-backport'); + }); + + it('should apply fixed class for version matches', () => { + fixture.componentRef.setInput('resolution', mockFixedVersion); + fixture.detectChanges(); + + expect(component.chipClass()).toContain('fixed'); + expect(component.chipClass()).not.toContain('backport'); + }); + + it('should apply vulnerable class', () => { + fixture.componentRef.setInput('resolution', mockVulnerable); + fixture.detectChanges(); + + expect(component.chipClass()).toContain('vulnerable'); + }); + + it('should apply unknown class', () => { + fixture.componentRef.setInput('resolution', mockUnknown); + fixture.detectChanges(); + + expect(component.chipClass()).toContain('unknown'); + }); + }); + + describe('evidence button', () => { + it('should show evidence button when evidence exists', () => { + fixture.componentRef.setInput('resolution', mockFixedBackport); + fixture.detectChanges(); + + expect(component.hasEvidence()).toBe(true); + }); + + it('should hide evidence button when no evidence', () => { + fixture.componentRef.setInput('resolution', mockFixedVersion); + fixture.detectChanges(); + + expect(component.hasEvidence()).toBe(false); + }); + + it('should emit showEvidence when button clicked', () => { + fixture.componentRef.setInput('resolution', mockFixedBackport); + fixture.detectChanges(); + + const spy = jest.spyOn(component.showEvidence, 'emit'); + component.onShowEvidence(new MouseEvent('click')); + + expect(spy).toHaveBeenCalledWith(mockFixedBackport); + }); + }); + + describe('accessibility', () => { + it('should provide aria label with status', () => { + fixture.componentRef.setInput('resolution', mockFixedBackport); + fixture.detectChanges(); + + const ariaLabel = component.ariaLabel(); + expect(ariaLabel).toContain('status: Fixed'); + expect(ariaLabel).toContain('backport'); + expect(ariaLabel).toContain('DSA-5123-1'); + }); + + it('should include confidence percentage in aria label', () => { + fixture.componentRef.setInput('resolution', mockVulnerable); + fixture.detectChanges(); + + expect(component.ariaLabel()).toContain('99 percent'); + }); + }); + + describe('tooltip', () => { + it('should include all resolution details', () => { + fixture.componentRef.setInput('resolution', mockFixedBackport); + fixture.detectChanges(); + + const tooltip = component.tooltip(); + expect(tooltip).toContain('Status: Fixed'); + expect(tooltip).toContain('Binary fingerprint'); + expect(tooltip).toContain('92%'); + expect(tooltip).toContain('DSA-5123-1'); + }); + }); + + describe('showLabel input', () => { + it('should default to true', () => { + fixture.componentRef.setInput('resolution', mockFixedVersion); + fixture.detectChanges(); + + expect(component.showLabel()).toBe(true); + }); + + it('should hide label when set to false', () => { + fixture.componentRef.setInput('resolution', mockFixedVersion); + fixture.componentRef.setInput('showLabel', false); + fixture.detectChanges(); + + expect(component.showLabel()).toBe(false); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/resolution-chip/resolution-chip.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/resolution-chip/resolution-chip.component.ts new file mode 100644 index 000000000..679ce3f47 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/resolution-chip/resolution-chip.component.ts @@ -0,0 +1,322 @@ +/** + * Resolution Status Chip Component. + * Sprint: SPRINT_1227_0003_0001 (Backport-Aware Resolution UI) + * Task: T1 - Create ResolutionChipComponent + * + * Displays a compact chip showing binary fingerprint resolution status. + * Shows "Fixed (backport)" when a binary was matched via fingerprint + * rather than simple version comparison. + */ + +import { Component, input, computed, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import type { VulnResolutionSummary, ResolutionStatus, MatchType } from '../../core/api/binary-resolution.models'; + +/** + * Compact chip component displaying binary resolution status. + * + * Color scheme: + * - Fixed (green): Binary contains the fix + * - Vulnerable (red): Binary is vulnerable + * - NotAffected (blue): Vulnerability doesn't apply + * - Unknown (yellow): Unable to determine + * + * Shows additional context for fingerprint-based matches (backport detection). + * + * @example + * ```html + * + * ``` + */ +@Component({ + selector: 'stella-resolution-chip', + standalone: true, + imports: [CommonModule], + template: ` + + + @if (showLabel()) { + {{ label() }} + } + @if (hasEvidence() && showEvidenceButton()) { + + } + + `, + styles: [` + .resolution-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: default; + transition: opacity 0.15s, box-shadow 0.15s; + white-space: nowrap; + + &:hover { + opacity: 0.95; + } + + &:focus-within { + box-shadow: 0 0 0 2px currentColor; + outline: none; + } + } + + .resolution-chip__icon { + font-size: 0.875rem; + line-height: 1; + } + + .resolution-chip__label { + letter-spacing: 0.02em; + } + + .resolution-chip__info-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + padding: 0; + margin-left: 0.125rem; + border: none; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + color: inherit; + font-size: 0.625rem; + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: rgba(255, 255, 255, 0.35); + } + + &:focus { + outline: 2px solid currentColor; + outline-offset: 1px; + } + } + + // Status-specific colors (WCAG AA compliant) + .resolution-chip--fixed { + background: rgba(40, 167, 69, 0.15); + color: #28a745; + border: 1px solid rgba(40, 167, 69, 0.3); + } + + .resolution-chip--fixed-backport { + background: rgba(0, 123, 255, 0.15); + color: #007bff; + border: 1px solid rgba(0, 123, 255, 0.3); + } + + .resolution-chip--vulnerable { + background: rgba(220, 53, 69, 0.15); + color: #dc3545; + border: 1px solid rgba(220, 53, 69, 0.3); + } + + .resolution-chip--not-affected { + background: rgba(23, 162, 184, 0.15); + color: #17a2b8; + border: 1px solid rgba(23, 162, 184, 0.3); + } + + .resolution-chip--unknown { + background: rgba(255, 193, 7, 0.15); + color: #856404; + border: 1px solid rgba(255, 193, 7, 0.3); + } + + // Dark mode support + :host-context(.dark-theme) { + .resolution-chip--fixed { + background: rgba(40, 167, 69, 0.25); + color: #7ece7e; + } + + .resolution-chip--fixed-backport { + background: rgba(0, 123, 255, 0.25); + color: #6db3f2; + } + + .resolution-chip--vulnerable { + background: rgba(220, 53, 69, 0.25); + color: #ea868f; + } + + .resolution-chip--not-affected { + background: rgba(23, 162, 184, 0.25); + color: #6edff6; + } + + .resolution-chip--unknown { + background: rgba(255, 193, 7, 0.25); + color: #ffda6a; + } + } + `], +}) +export class ResolutionChipComponent { + /** Resolution summary to display */ + resolution = input.required(); + + /** Whether to show the label text */ + showLabel = input(true); + + /** Whether to show the evidence info button */ + showEvidenceButton = input(true); + + /** Emitted when user clicks to show evidence */ + showEvidence = output(); + + /** CSS class for the chip based on status and match type */ + chipClass = computed(() => { + const res = this.resolution(); + const status = res.status.toLowerCase(); + + if (status === 'fixed' && this.isBackportMatch()) { + return 'resolution-chip resolution-chip--fixed-backport'; + } + + return `resolution-chip resolution-chip--${status.replace('_', '-')}`; + }); + + /** Icon to display */ + icon = computed((): string => { + const res = this.resolution(); + + if (res.status === 'Fixed') { + return this.isBackportMatch() ? '🔍' : '✅'; + } + + switch (res.status) { + case 'Vulnerable': + return '⚠️'; + case 'NotAffected': + return '✓'; + case 'Unknown': + default: + return '❓'; + } + }); + + /** Label text */ + label = computed((): string => { + const res = this.resolution(); + + if (res.status === 'Fixed') { + if (this.isBackportMatch()) { + const advisory = res.distroAdvisoryId; + return advisory ? `Fixed (backport: ${advisory})` : 'Fixed (backport)'; + } + return res.fixedVersion ? `Fixed (${res.fixedVersion})` : 'Fixed'; + } + + switch (res.status) { + case 'Vulnerable': + return 'Vulnerable'; + case 'NotAffected': + return 'Not Affected'; + case 'Unknown': + default: + return 'Unknown'; + } + }); + + /** Tooltip text with full details */ + tooltip = computed((): string => { + const res = this.resolution(); + const parts: string[] = []; + + parts.push(`Status: ${res.status}`); + + if (res.matchType) { + parts.push(`Match: ${this.formatMatchType(res.matchType)}`); + } + + if (res.confidence != null) { + parts.push(`Confidence: ${Math.round(res.confidence * 100)}%`); + } + + if (res.fixedVersion) { + parts.push(`Fixed in: ${res.fixedVersion}`); + } + + if (res.distroAdvisoryId) { + parts.push(`Advisory: ${res.distroAdvisoryId}`); + } + + return parts.join('\n'); + }); + + /** Aria label for screen readers */ + ariaLabel = computed((): string => { + const res = this.resolution(); + let label = `Resolution status: ${res.status}`; + + if (this.isBackportMatch()) { + label += '. Fixed via backport detection'; + if (res.distroAdvisoryId) { + label += ` from ${res.distroAdvisoryId}`; + } + } + + if (res.confidence != null) { + label += `. Confidence: ${Math.round(res.confidence * 100)} percent`; + } + + return label; + }); + + /** Whether detailed evidence is available */ + hasEvidence = computed(() => this.resolution().hasEvidence); + + /** Whether this is a backport match (fingerprint-based) */ + private isBackportMatch(): boolean { + const matchType = this.resolution().matchType; + return matchType === 'fingerprint' || matchType === 'build_id'; + } + + /** Format match type for display */ + private formatMatchType(type: MatchType): string { + switch (type) { + case 'build_id': + return 'Build ID catalog'; + case 'fingerprint': + return 'Binary fingerprint'; + case 'hash_exact': + return 'Exact hash match'; + case 'version_range': + return 'Version comparison'; + default: + return type; + } + } + + /** Handle evidence button click */ + onShowEvidence(event: MouseEvent): void { + event.stopPropagation(); + this.showEvidence.emit(this.resolution()); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/smart-diff-badge/smart-diff-badge.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/components/smart-diff-badge/smart-diff-badge.component.spec.ts new file mode 100644 index 000000000..60b152a0c --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/smart-diff-badge/smart-diff-badge.component.spec.ts @@ -0,0 +1,133 @@ +// ----------------------------------------------------------------------------- +// smart-diff-badge.component.spec.ts +// Sprint: SPRINT_1227_0005_0001_FE_diff_first_default +// Task: T6 — Unit tests for SmartDiff badge component +// ----------------------------------------------------------------------------- + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { SmartDiffBadgeComponent, SmartDiffRule, getSmartDiffRuleInfo } from './smart-diff-badge.component'; + +describe('SmartDiffBadgeComponent', () => { + let component: SmartDiffBadgeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SmartDiffBadgeComponent, NoopAnimationsModule] + }).compileComponents(); + + fixture = TestBed.createComponent(SmartDiffBadgeComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('single rule display', () => { + it('should display R1 rule correctly', () => { + component.rule = 'R1'; + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.smart-diff-badge'); + expect(badge).toBeTruthy(); + expect(badge.textContent).toContain('Reachability Changed'); + }); + + it('should display R2 rule correctly', () => { + component.rule = 'R2'; + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.smart-diff-badge'); + expect(badge.textContent).toContain('VEX Status Changed'); + }); + + it('should display R3 rule correctly', () => { + component.rule = 'R3'; + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.smart-diff-badge'); + expect(badge.textContent).toContain('Version Boundary'); + }); + + it('should display R4 rule correctly', () => { + component.rule = 'R4'; + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.smart-diff-badge'); + expect(badge.textContent).toContain('Risk Intelligence'); + }); + + it('should apply rule-based styling by default', () => { + component.rule = 'R1'; + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.smart-diff-badge'); + expect(badge.classList.contains('rule-R1')).toBe(true); + }); + + it('should apply severity-based styling when colorMode is severity', () => { + component.rule = 'R1'; + component.colorMode = 'severity'; + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector('.smart-diff-badge'); + expect(badge.classList.contains('severity-high')).toBe(true); + }); + }); + + describe('multiple rules display', () => { + it('should display multiple rules in compact mode', () => { + component.rules = ['R1', 'R4']; + fixture.detectChanges(); + + const badges = fixture.nativeElement.querySelectorAll('.smart-diff-badge.compact'); + expect(badges.length).toBe(2); + }); + + it('should not show labels in compact mode', () => { + component.rules = ['R1', 'R2', 'R3']; + fixture.detectChanges(); + + const labels = fixture.nativeElement.querySelectorAll('.badge-label'); + expect(labels.length).toBe(0); + }); + + it('should display all rules when multiple provided', () => { + component.rules = ['R1', 'R2', 'R3', 'R4']; + fixture.detectChanges(); + + const badges = fixture.nativeElement.querySelectorAll('.smart-diff-badge'); + expect(badges.length).toBe(4); + }); + }); + + describe('getSmartDiffRuleInfo helper', () => { + it('should return correct info for R1', () => { + const info = getSmartDiffRuleInfo('R1'); + expect(info.rule).toBe('R1'); + expect(info.label).toBe('Reachability Changed'); + expect(info.icon).toBe('call_split'); + expect(info.severity).toBe('high'); + }); + + it('should return correct info for R2', () => { + const info = getSmartDiffRuleInfo('R2'); + expect(info.rule).toBe('R2'); + expect(info.label).toBe('VEX Status Changed'); + }); + + it('should return correct info for R3', () => { + const info = getSmartDiffRuleInfo('R3'); + expect(info.rule).toBe('R3'); + expect(info.severity).toBe('medium'); + }); + + it('should return correct info for R4', () => { + const info = getSmartDiffRuleInfo('R4'); + expect(info.rule).toBe('R4'); + expect(info.label).toBe('Risk Intelligence'); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/smart-diff-badge/smart-diff-badge.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/smart-diff-badge/smart-diff-badge.component.ts new file mode 100644 index 000000000..6b89c7261 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/smart-diff-badge/smart-diff-badge.component.ts @@ -0,0 +1,234 @@ +// ----------------------------------------------------------------------------- +// smart-diff-badge.component.ts +// Sprint: SPRINT_1227_0005_0001_FE_diff_first_default +// Task: T4 — SmartDiff badge component showing change detection rules +// ----------------------------------------------------------------------------- + +import { Component, ChangeDetectionStrategy, Input, computed, signal, effect } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +/** + * SmartDiff detection rule types. + * + * - R1: reachability_flip - Reachable <-> Unreachable transition + * - R2: vex_flip - VEX status changed (e.g., affected -> fixed) + * - R3: range_boundary - Version range boundary crossed + * - R4: intelligence_flip - KEV/EPSS threshold crossed + */ +export type SmartDiffRule = 'R1' | 'R2' | 'R3' | 'R4'; + +/** + * SmartDiff rule metadata. + */ +export interface SmartDiffRuleInfo { + rule: SmartDiffRule; + label: string; + icon: string; + tooltip: string; + severity: 'high' | 'medium' | 'low'; +} + +const RULE_INFO: Record = { + R1: { + rule: 'R1', + label: 'Reachability Changed', + icon: 'call_split', + tooltip: 'Code path reachability status changed (reachable <-> unreachable)', + severity: 'high' + }, + R2: { + rule: 'R2', + label: 'VEX Status Changed', + icon: 'swap_horiz', + tooltip: 'VEX exploitability status changed (e.g., affected -> fixed)', + severity: 'high' + }, + R3: { + rule: 'R3', + label: 'Version Boundary', + icon: 'trending_up', + tooltip: 'Package version crossed vulnerable/safe boundary', + severity: 'medium' + }, + R4: { + rule: 'R4', + label: 'Risk Intelligence', + icon: 'warning', + tooltip: 'Known Exploited (KEV) or EPSS score crossed threshold', + severity: 'high' + } +}; + +/** + * Badge component for displaying SmartDiff change detection rules. + * + * Shows which detection rule triggered the change highlight: + * - R1: Reachability flip + * - R2: VEX status flip + * - R3: Version range boundary + * - R4: Intelligence flip (KEV/EPSS) + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'stella-smart-diff-badge', + standalone: true, + imports: [CommonModule, MatIconModule, MatTooltipModule], + template: ` + @if (singleRule()) { + + {{ ruleInfo().icon }} + {{ ruleInfo().label }} + + } @else if (multipleRules().length > 0) { +
+ @for (info of multipleRules(); track info.rule) { + + {{ info.icon }} + + } +
+ } + `, + styles: [` + .smart-diff-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: default; + } + + .smart-diff-badge.compact { + padding: 4px; + } + + .smart-diff-badges { + display: flex; + gap: 4px; + } + + .badge-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + .badge-label { + white-space: nowrap; + } + + /* Severity-based colors */ + .smart-diff-badge.severity-high { + background-color: rgba(239, 83, 80, 0.15); + color: #c62828; + } + + .smart-diff-badge.severity-medium { + background-color: rgba(255, 193, 7, 0.15); + color: #f57f17; + } + + .smart-diff-badge.severity-low { + background-color: rgba(76, 175, 80, 0.15); + color: #2e7d32; + } + + /* Rule-specific colors (alternative styling) */ + .smart-diff-badge.rule-R1 { + background-color: rgba(156, 39, 176, 0.15); + color: #7b1fa2; + } + + .smart-diff-badge.rule-R2 { + background-color: rgba(33, 150, 243, 0.15); + color: #1565c0; + } + + .smart-diff-badge.rule-R3 { + background-color: rgba(255, 152, 0, 0.15); + color: #ef6c00; + } + + .smart-diff-badge.rule-R4 { + background-color: rgba(244, 67, 54, 0.15); + color: #c62828; + } + + @media (max-width: 600px) { + .badge-label { + display: none; + } + } + `], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SmartDiffBadgeComponent { + /** + * Single rule to display. + */ + @Input() rule?: SmartDiffRule; + + /** + * Multiple rules to display (compact mode). + */ + @Input() rules?: SmartDiffRule[]; + + /** + * Whether to use severity-based or rule-based coloring. + */ + @Input() colorMode: 'severity' | 'rule' = 'rule'; + + // Computed for single rule display + readonly singleRule = computed(() => this.rule && !this.rules?.length); + + readonly ruleInfo = computed((): SmartDiffRuleInfo => { + return this.rule ? RULE_INFO[this.rule] : RULE_INFO.R1; + }); + + readonly badgeClass = computed(() => { + const info = this.ruleInfo(); + return this.colorMode === 'severity' + ? `severity-${info.severity}` + : `rule-${info.rule}`; + }); + + // Computed for multiple rules display + readonly multipleRules = computed((): SmartDiffRuleInfo[] => { + if (!this.rules?.length) return []; + return this.rules.map(r => RULE_INFO[r]); + }); + + getBadgeClass(info: SmartDiffRuleInfo): string { + return this.colorMode === 'severity' + ? `severity-${info.severity}` + : `rule-${info.rule}`; + } +} + +/** + * Helper function to get rule info. + */ +export function getSmartDiffRuleInfo(rule: SmartDiffRule): SmartDiffRuleInfo { + return RULE_INFO[rule]; +} + +/** + * All available SmartDiff rules. + */ +export const SMART_DIFF_RULES: SmartDiffRule[] = ['R1', 'R2', 'R3', 'R4']; diff --git a/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/index.ts b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/index.ts new file mode 100644 index 000000000..9a20835b2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/index.ts @@ -0,0 +1 @@ +export * from './vex-trust-chip.component'; diff --git a/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/vex-trust-chip.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/vex-trust-chip.component.spec.ts new file mode 100644 index 000000000..3fdd017a3 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/vex-trust-chip.component.spec.ts @@ -0,0 +1,326 @@ +/** + * VEX Trust Chip Component Tests. + * Sprint: SPRINT_1227_0004_0002_FE_trust_column + * Task: T7 - Unit tests for VexTrustChipComponent + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + VexTrustChipComponent, + VexTrustStatus, + TrustTier +} from './vex-trust-chip.component'; + +describe('VexTrustChipComponent', () => { + let component: VexTrustChipComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VexTrustChipComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(VexTrustChipComponent); + component = fixture.componentInstance; + }); + + describe('tier computation', () => { + it('should render high tier for score >= 0.7', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.detectChanges(); + + expect(component.tier()).toBe('high'); + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.classList).toContain('vex-trust-chip--high'); + }); + + it('should render medium tier for score 0.5-0.7', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.55 }); + fixture.detectChanges(); + + expect(component.tier()).toBe('medium'); + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.classList).toContain('vex-trust-chip--medium'); + }); + + it('should render low tier for score < 0.5', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.25 }); + fixture.detectChanges(); + + expect(component.tier()).toBe('low'); + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.classList).toContain('vex-trust-chip--low'); + }); + + it('should render unknown tier for null/undefined', () => { + fixture.componentRef.setInput('trustStatus', null); + fixture.detectChanges(); + + expect(component.tier()).toBe('unknown'); + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.classList).toContain('vex-trust-chip--unknown'); + }); + + it('should render unknown tier when trustScore is undefined', () => { + fixture.componentRef.setInput('trustStatus', {}); + fixture.detectChanges(); + + expect(component.tier()).toBe('unknown'); + }); + }); + + describe('display modes', () => { + it('should show label in full mode', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.componentRef.setInput('compact', false); + fixture.detectChanges(); + + const label = fixture.debugElement.query(By.css('.vex-trust-chip__label')); + expect(label).toBeTruthy(); + expect(label.nativeElement.textContent).toBe('High Trust'); + }); + + it('should hide label in compact mode', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.componentRef.setInput('compact', true); + fixture.detectChanges(); + + const label = fixture.debugElement.query(By.css('.vex-trust-chip__label')); + expect(label).toBeNull(); + }); + + it('should add compact class when compact=true', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.componentRef.setInput('compact', true); + fixture.detectChanges(); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.classList).toContain('vex-trust-chip--compact'); + }); + + it('should show score when showScoreValue=true', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.componentRef.setInput('showScoreValue', true); + fixture.detectChanges(); + + const score = fixture.debugElement.query(By.css('.vex-trust-chip__score')); + expect(score).toBeTruthy(); + expect(score.nativeElement.textContent).toBe('0.85'); + }); + + it('should hide score when showScoreValue=false', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.componentRef.setInput('showScoreValue', false); + fixture.detectChanges(); + + const score = fixture.debugElement.query(By.css('.vex-trust-chip__score')); + expect(score).toBeNull(); + }); + }); + + describe('icons', () => { + it('should show checkmark for high trust', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.detectChanges(); + + expect(component.icon()).toBe('✓'); + }); + + it('should show warning for medium trust', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.55 }); + fixture.detectChanges(); + + expect(component.icon()).toBe('⚠'); + }); + + it('should show X for low trust', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.25 }); + fixture.detectChanges(); + + expect(component.icon()).toBe('✗'); + }); + + it('should show question mark for unknown', () => { + fixture.componentRef.setInput('trustStatus', null); + fixture.detectChanges(); + + expect(component.icon()).toBe('?'); + }); + }); + + describe('threshold indicators', () => { + it('should add meets-threshold class when meetsPolicyThreshold=true', () => { + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + meetsPolicyThreshold: true + }); + fixture.detectChanges(); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.classList).toContain('vex-trust-chip--meets-threshold'); + }); + + it('should add below-threshold class when meetsPolicyThreshold=false', () => { + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.55, + meetsPolicyThreshold: false + }); + fixture.detectChanges(); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.classList).toContain('vex-trust-chip--below-threshold'); + }); + }); + + describe('accessibility', () => { + it('should have aria-label', () => { + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.detectChanges(); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.getAttribute('aria-label')).toContain('VEX trust'); + }); + + it('should include threshold status in aria-label', () => { + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + meetsPolicyThreshold: true + }); + fixture.detectChanges(); + + const ariaLabel = component.ariaLabel(); + expect(ariaLabel).toContain('meets policy threshold'); + }); + + it('should have title attribute for tooltip', () => { + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + issuerName: 'Red Hat Security' + }); + fixture.detectChanges(); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + const title = chip.nativeElement.getAttribute('title'); + expect(title).toContain('Red Hat Security'); + }); + }); + + describe('popover interaction', () => { + it('should emit openPopover event on click when evidence present', () => { + const emitSpy = jest.fn(); + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + issuerName: 'Test Issuer' + }); + fixture.detectChanges(); + + component.openPopover.subscribe(emitSpy); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + chip.nativeElement.click(); + + expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({ + trustStatus: expect.objectContaining({ trustScore: 0.85 }) + })); + }); + + it('should not emit openPopover when no evidence present', () => { + const emitSpy = jest.fn(); + fixture.componentRef.setInput('trustStatus', { trustScore: 0.85 }); + fixture.detectChanges(); + + component.openPopover.subscribe(emitSpy); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + chip.nativeElement.click(); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should show indicator when hasEvidence and not compact', () => { + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + issuerName: 'Test Issuer' + }); + fixture.componentRef.setInput('compact', false); + fixture.detectChanges(); + + const indicator = fixture.debugElement.query(By.css('.vex-trust-chip__indicator')); + expect(indicator).toBeTruthy(); + }); + + it('should set aria-haspopup when popover available', () => { + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + trustBreakdown: { originScore: 0.8, freshnessScore: 0.9 } + }); + fixture.detectChanges(); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + expect(chip.nativeElement.getAttribute('aria-haspopup')).toBe('dialog'); + }); + }); + + describe('keyboard navigation', () => { + it('should handle Enter key', () => { + const emitSpy = jest.fn(); + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + issuerName: 'Test' + }); + fixture.detectChanges(); + + component.openPopover.subscribe(emitSpy); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + chip.triggerEventHandler('keydown.enter', { preventDefault: () => {}, stopPropagation: () => {} }); + + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should handle Space key', () => { + const emitSpy = jest.fn(); + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + issuerName: 'Test' + }); + fixture.detectChanges(); + + component.openPopover.subscribe(emitSpy); + + const chip = fixture.debugElement.query(By.css('.vex-trust-chip')); + chip.triggerEventHandler('keydown.space', { preventDefault: () => {}, stopPropagation: () => {} }); + + expect(emitSpy).toHaveBeenCalled(); + }); + }); + + describe('tooltip content', () => { + it('should include all available information', () => { + fixture.componentRef.setInput('trustStatus', { + trustScore: 0.85, + policyTrustThreshold: 0.7, + issuerName: 'Red Hat Security', + signatureVerified: true, + freshness: 'fresh' + }); + fixture.detectChanges(); + + const tooltip = component.tooltip(); + expect(tooltip).toContain('Score: 0.85'); + expect(tooltip).toContain('Threshold: 0.70'); + expect(tooltip).toContain('Red Hat Security'); + expect(tooltip).toContain('Verified'); + expect(tooltip).toContain('fresh'); + }); + + it('should handle missing data gracefully', () => { + fixture.componentRef.setInput('trustStatus', null); + fixture.detectChanges(); + + const tooltip = component.tooltip(); + expect(tooltip).toBe('No VEX trust data available'); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/vex-trust-chip.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/vex-trust-chip.component.ts new file mode 100644 index 000000000..7230ecffd --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-chip/vex-trust-chip.component.ts @@ -0,0 +1,410 @@ +/** + * VEX Trust Chip Component. + * Sprint: SPRINT_1227_0004_0002_FE_trust_column + * Task: T1 - VexTrustChipComponent for displaying trust score badges + * + * Displays a compact chip showing the VEX trust score with tier-based color coding. + * Supports both full and compact display modes with popover interaction. + */ + +import { Component, input, computed, output, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +/** + * Trust tier enum matching backend TrustTiers. + */ +export type TrustTier = 'high' | 'medium' | 'low' | 'unknown'; + +/** + * Freshness status matching backend FreshnessStatuses. + */ +export type FreshnessStatus = 'fresh' | 'stale' | 'superseded' | 'expired'; + +/** + * VEX trust status interface for chip input. + */ +export interface VexTrustStatus { + readonly trustScore?: number; + readonly policyTrustThreshold?: number; + readonly meetsPolicyThreshold?: boolean; + readonly trustBreakdown?: TrustScoreBreakdown; + readonly issuerName?: string; + readonly issuerId?: string; + readonly signatureVerified?: boolean; + readonly signatureMethod?: string; + readonly rekorLogIndex?: number; + readonly rekorLogId?: string; + readonly freshness?: FreshnessStatus; + readonly verifiedAt?: string; +} + +/** + * Breakdown of VEX trust score factors. + */ +export interface TrustScoreBreakdown { + readonly originScore?: number; + readonly freshnessScore?: number; + readonly accuracyScore?: number; + readonly verificationScore?: number; + readonly authorityScore?: number; + readonly coverageScore?: number; + readonly reputationScore?: number; +} + +/** + * Event emitted when popover should open. + */ +export interface TrustChipPopoverEvent { + readonly trustStatus: VexTrustStatus; + readonly anchorElement: HTMLElement; +} + +/** + * Trust tier thresholds. + */ +const TRUST_THRESHOLDS = { + high: 0.7, + medium: 0.5, +} as const; + +/** + * Compact chip component displaying VEX trust score. + * + * Color scheme: + * - high (≥0.7): Green - strong trust, verified sources + * - medium (0.5-0.7): Yellow/amber - moderate trust + * - low (<0.5): Red - weak trust, caution advised + * - unknown (null/undefined): Gray - no VEX data available + * + * @example + * + * + * + */ +@Component({ + selector: 'stella-vex-trust-chip', + standalone: true, + imports: [CommonModule], + template: ` + + `, + styles: [` + .vex-trust-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border: 1px solid; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + background: transparent; + transition: all 0.15s ease; + + &:hover { + opacity: 0.9; + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color, #4f46e5); + outline-offset: 2px; + } + } + + .vex-trust-chip__icon { + font-size: 0.875rem; + line-height: 1; + } + + .vex-trust-chip__label { + letter-spacing: 0.02em; + } + + .vex-trust-chip__score { + font-variant-numeric: tabular-nums; + opacity: 0.85; + font-weight: 600; + } + + .vex-trust-chip__indicator { + font-size: 0.625rem; + opacity: 0.7; + margin-left: 0.125rem; + } + + // Tier-specific colors + .vex-trust-chip--high { + background: #dcfce7; + color: #15803d; + border-color: #86efac; + + &:hover { + background: #bbf7d0; + } + } + + .vex-trust-chip--medium { + background: #fef3c7; + color: #92400e; + border-color: #fcd34d; + + &:hover { + background: #fde68a; + } + } + + .vex-trust-chip--low { + background: #fee2e2; + color: #dc2626; + border-color: #fca5a5; + + &:hover { + background: #fecaca; + } + } + + .vex-trust-chip--unknown { + background: #f3f4f6; + color: #6b7280; + border-color: #d1d5db; + + &:hover { + background: #e5e7eb; + } + } + + // Compact variant + .vex-trust-chip--compact { + padding: 0.125rem 0.375rem; + font-size: 0.6875rem; + + .vex-trust-chip__icon { + font-size: 0.75rem; + } + + .vex-trust-chip__score { + font-size: 0.625rem; + } + } + + // Meets threshold indicator + .vex-trust-chip--meets-threshold { + box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.3); + } + + .vex-trust-chip--below-threshold { + box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.3); + } + + // Dark mode support + @media (prefers-color-scheme: dark) { + .vex-trust-chip--high { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + border-color: rgba(34, 197, 94, 0.4); + } + + .vex-trust-chip--medium { + background: rgba(245, 158, 11, 0.2); + color: #fcd34d; + border-color: rgba(245, 158, 11, 0.4); + } + + .vex-trust-chip--low { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.4); + } + + .vex-trust-chip--unknown { + background: rgba(107, 114, 128, 0.2); + color: #9ca3af; + border-color: rgba(107, 114, 128, 0.4); + } + } + `] +}) +export class VexTrustChipComponent { + /** Trust status input */ + trustStatus = input(null); + + /** Compact mode (icon + score only) */ + compact = input(false); + + /** Show score value */ + showScoreValue = input(true); + + /** Popover open event */ + openPopover = output(); + + /** Popover close event */ + closePopover = output(); + + /** Internal popover state */ + popoverOpen = signal(false); + + /** Computed trust tier */ + tier = computed(() => { + const score = this.trustStatus()?.trustScore; + if (score === null || score === undefined) return 'unknown'; + if (score >= TRUST_THRESHOLDS.high) return 'high'; + if (score >= TRUST_THRESHOLDS.medium) return 'medium'; + return 'low'; + }); + + /** Computed chip CSS class */ + chipClass = computed(() => { + const classes = [`vex-trust-chip--${this.tier()}`]; + if (this.compact()) { + classes.push('vex-trust-chip--compact'); + } + const status = this.trustStatus(); + if (status?.meetsPolicyThreshold !== undefined) { + classes.push(status.meetsPolicyThreshold + ? 'vex-trust-chip--meets-threshold' + : 'vex-trust-chip--below-threshold' + ); + } + return classes.join(' '); + }); + + /** Computed icon */ + icon = computed(() => { + switch (this.tier()) { + case 'high': return '✓'; + case 'medium': return '⚠'; + case 'low': return '✗'; + default: return '?'; + } + }); + + /** Computed label */ + label = computed(() => { + switch (this.tier()) { + case 'high': return 'High Trust'; + case 'medium': return 'Medium Trust'; + case 'low': return 'Low Trust'; + default: return 'No VEX'; + } + }); + + /** Show score value */ + showScore = computed(() => { + return this.showScoreValue() && this.trustStatus()?.trustScore !== undefined; + }); + + /** Format score for display */ + formatScore = computed(() => { + const score = this.trustStatus()?.trustScore; + if (score === null || score === undefined) return ''; + return score.toFixed(2); + }); + + /** Check if trust status has evidence details */ + hasEvidence = computed(() => { + const status = this.trustStatus(); + return !!(status?.issuerName || status?.signatureVerified || status?.rekorLogIndex); + }); + + /** Check if popover interaction is available */ + hasPopover = computed(() => { + return this.hasEvidence() || this.trustStatus()?.trustBreakdown !== undefined; + }); + + /** Computed tooltip text */ + tooltip = computed(() => { + const status = this.trustStatus(); + if (!status) return 'No VEX trust data available'; + + const parts: string[] = []; + parts.push(`Trust: ${this.label()}`); + + if (status.trustScore !== undefined) { + parts.push(`Score: ${status.trustScore.toFixed(2)}`); + } + + if (status.policyTrustThreshold !== undefined) { + parts.push(`Threshold: ${status.policyTrustThreshold.toFixed(2)}`); + } + + if (status.issuerName) { + parts.push(`Issuer: ${status.issuerName}`); + } + + if (status.signatureVerified) { + parts.push(`Signature: Verified`); + } + + if (status.freshness) { + parts.push(`Freshness: ${status.freshness}`); + } + + return parts.join(' • '); + }); + + /** Computed ARIA label */ + ariaLabel = computed(() => { + const status = this.trustStatus(); + if (!status) return 'No VEX trust data'; + + let label = `VEX trust: ${this.label()}`; + if (status.trustScore !== undefined) { + label += `, score ${status.trustScore.toFixed(2)}`; + } + if (status.meetsPolicyThreshold !== undefined) { + label += status.meetsPolicyThreshold + ? ', meets policy threshold' + : ', below policy threshold'; + } + return label; + }); + + /** Handle chip click */ + onChipClick(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + if (!this.hasPopover()) return; + + const element = event.currentTarget as HTMLElement; + const status = this.trustStatus(); + + if (status) { + this.popoverOpen.set(true); + this.openPopover.emit({ + trustStatus: status, + anchorElement: element + }); + } + } + + /** Close popover programmatically */ + close(): void { + this.popoverOpen.set(false); + this.closePopover.emit(); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/index.ts b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/index.ts new file mode 100644 index 000000000..fa0fd486f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/index.ts @@ -0,0 +1 @@ +export * from './vex-trust-popover.component'; diff --git a/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/vex-trust-popover.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/vex-trust-popover.component.spec.ts new file mode 100644 index 000000000..1c362889b --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/vex-trust-popover.component.spec.ts @@ -0,0 +1,335 @@ +/** + * VEX Trust Popover Component Tests. + * Sprint: SPRINT_1227_0004_0002_FE_trust_column + * Task: T7 - Unit tests for VexTrustPopoverComponent + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { VexTrustPopoverComponent } from './vex-trust-popover.component'; +import { VexTrustStatus } from '../vex-trust-chip'; + +describe('VexTrustPopoverComponent', () => { + let component: VexTrustPopoverComponent; + let fixture: ComponentFixture; + + const createTrustStatus = (overrides: Partial = {}): VexTrustStatus => ({ + trustScore: 0.72, + policyTrustThreshold: 0.7, + meetsPolicyThreshold: true, + trustBreakdown: { + originScore: 0.8, + freshnessScore: 0.7, + reputationScore: 0.6 + }, + issuerName: 'Red Hat Security', + issuerId: 'security@redhat.com', + signatureVerified: true, + signatureMethod: 'ECDSA-P256', + rekorLogIndex: 12345678, + freshness: 'fresh', + verifiedAt: '2024-01-15T10:30:00Z', + ...overrides + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VexTrustPopoverComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(VexTrustPopoverComponent); + component = fixture.componentInstance; + }); + + describe('basic rendering', () => { + it('should create', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should display title', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const title = fixture.debugElement.query(By.css('.trust-popover__title')); + expect(title.nativeElement.textContent).toBe('VEX Trust Breakdown'); + }); + + it('should have correct ARIA attributes', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const popover = fixture.debugElement.query(By.css('.trust-popover')); + expect(popover.nativeElement.getAttribute('role')).toBe('dialog'); + expect(popover.nativeElement.getAttribute('aria-labelledby')).toBe('trust-title'); + expect(popover.nativeElement.getAttribute('aria-modal')).toBe('true'); + }); + }); + + describe('score display', () => { + it('should display trust score', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ trustScore: 0.85 })); + fixture.detectChanges(); + + const score = fixture.debugElement.query(By.css('.trust-popover__score')); + expect(score.nativeElement.textContent).toBe('0.85'); + }); + + it('should display threshold', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ + trustScore: 0.72, + policyTrustThreshold: 0.7 + })); + fixture.detectChanges(); + + const threshold = fixture.debugElement.query(By.css('.trust-popover__threshold')); + expect(threshold.nativeElement.textContent).toContain('0.70'); + expect(threshold.nativeElement.textContent).toContain('required'); + }); + + it('should hide threshold when not provided', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ + policyTrustThreshold: undefined + })); + fixture.detectChanges(); + + const threshold = fixture.debugElement.query(By.css('.trust-popover__threshold')); + expect(threshold).toBeNull(); + }); + }); + + describe('breakdown display', () => { + it('should display factor breakdown when available', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const breakdown = fixture.debugElement.query(By.css('.trust-popover__breakdown')); + expect(breakdown).toBeTruthy(); + + const factors = fixture.debugElement.queryAll(By.css('.trust-factor')); + expect(factors.length).toBeGreaterThan(0); + }); + + it('should hide breakdown section when no breakdown data', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ + trustBreakdown: undefined + })); + fixture.detectChanges(); + + const breakdown = fixture.debugElement.query(By.css('.trust-popover__breakdown')); + expect(breakdown).toBeNull(); + }); + + it('should display correct factor labels', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const labels = fixture.debugElement.queryAll(By.css('.trust-factor__label')); + const labelTexts = labels.map(l => l.nativeElement.textContent); + + expect(labelTexts).toContain('Origin'); + expect(labelTexts).toContain('Freshness'); + expect(labelTexts).toContain('Reputation'); + }); + + it('should display correct factor values as percentages', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ + trustBreakdown: { originScore: 0.8 } + })); + fixture.detectChanges(); + + const value = fixture.debugElement.query(By.css('.trust-factor__value')); + expect(value.nativeElement.textContent).toBe('80%'); + }); + + it('should apply correct tier class to factor fill', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ + trustBreakdown: { originScore: 0.8 } + })); + fixture.detectChanges(); + + const fill = fixture.debugElement.query(By.css('.trust-factor__fill')); + expect(fill.nativeElement.classList).toContain('trust-factor__fill--high'); + }); + }); + + describe('evidence display', () => { + it('should display issuer name', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const evidence = fixture.debugElement.query(By.css('.trust-popover__evidence')); + expect(evidence.nativeElement.textContent).toContain('Red Hat Security'); + }); + + it('should display issuer as link when issuerId provided', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const link = fixture.debugElement.query(By.css('.trust-evidence-item a')); + expect(link).toBeTruthy(); + expect(link.nativeElement.href).toContain('sigstore.dev'); + }); + + it('should display signature verification status', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const evidence = fixture.debugElement.query(By.css('.trust-popover__evidence')); + expect(evidence.nativeElement.textContent).toContain('Verified'); + expect(evidence.nativeElement.textContent).toContain('ECDSA-P256'); + }); + + it('should display Rekor transparency link', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const rekorLink = fixture.debugElement.queryAll(By.css('.trust-evidence-item a')) + .find(el => el.nativeElement.textContent.includes('Rekor')); + expect(rekorLink).toBeTruthy(); + expect(rekorLink?.nativeElement.href).toContain('12345678'); + }); + + it('should display freshness status with correct class', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ freshness: 'fresh' })); + fixture.detectChanges(); + + const freshness = fixture.debugElement.query(By.css('.freshness-fresh')); + expect(freshness).toBeTruthy(); + expect(freshness.nativeElement.textContent).toContain('Fresh'); + }); + + it('should hide evidence section when no evidence', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus({ + issuerName: undefined, + signatureVerified: undefined, + rekorLogIndex: undefined, + freshness: undefined, + verifiedAt: undefined + })); + fixture.detectChanges(); + + const evidence = fixture.debugElement.query(By.css('.trust-popover__evidence')); + expect(evidence).toBeNull(); + }); + }); + + describe('actions', () => { + it('should emit close event when close button clicked', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const closeSpy = jest.fn(); + component.close.subscribe(closeSpy); + + const closeBtn = fixture.debugElement.query(By.css('.trust-popover__close')); + closeBtn.nativeElement.click(); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should emit viewDetails event when Full Details clicked', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const viewSpy = jest.fn(); + component.viewDetails.subscribe(viewSpy); + + const detailsBtn = fixture.debugElement.query(By.css('.trust-popover__action--primary')); + detailsBtn.nativeElement.click(); + + expect(viewSpy).toHaveBeenCalled(); + }); + + it('should copy evidence to clipboard when Copy Evidence clicked', async () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const mockWriteText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + clipboard: { writeText: mockWriteText } + }); + + const copyBtn = fixture.debugElement.query(By.css('.trust-popover__action--secondary')); + copyBtn.nativeElement.click(); + + await fixture.whenStable(); + + expect(mockWriteText).toHaveBeenCalled(); + const copiedData = JSON.parse(mockWriteText.mock.calls[0][0]); + expect(copiedData.trustScore).toBe(0.72); + }); + }); + + describe('keyboard navigation', () => { + it('should emit close on Escape key', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const closeSpy = jest.fn(); + component.close.subscribe(closeSpy); + + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(event); + + expect(closeSpy).toHaveBeenCalled(); + }); + }); + + describe('outside click', () => { + it('should emit close on outside click', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const closeSpy = jest.fn(); + component.close.subscribe(closeSpy); + + // Simulate click outside + const event = new MouseEvent('click', { bubbles: true }); + document.body.dispatchEvent(event); + + expect(closeSpy).toHaveBeenCalled(); + }); + + it('should not close on inside click', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const closeSpy = jest.fn(); + component.close.subscribe(closeSpy); + + const popover = fixture.debugElement.query(By.css('.trust-popover')); + popover.nativeElement.click(); + + expect(closeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('utility methods', () => { + it('should format score correctly', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + expect(component.formatScore(0.8567)).toBe('0.86'); + expect(component.formatScore(undefined)).toBe('—'); + }); + + it('should format percent correctly', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + expect(component.formatPercent(0.85)).toBe('85%'); + expect(component.formatPercent(0.333)).toBe('33%'); + }); + + it('should format date correctly', () => { + fixture.componentRef.setInput('trustStatus', createTrustStatus()); + fixture.detectChanges(); + + const result = component.formatDate('2024-01-15T10:30:00Z'); + expect(result).toContain('2024'); + expect(result).toContain('Jan'); + }); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/vex-trust-popover.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/vex-trust-popover.component.ts new file mode 100644 index 000000000..35f041d87 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/components/vex-trust-popover/vex-trust-popover.component.ts @@ -0,0 +1,626 @@ +/** + * VEX Trust Popover Component. + * Sprint: SPRINT_1227_0004_0002_FE_trust_column + * Task: T2 - VexTrustPopoverComponent for displaying trust score breakdown + * + * Displays a detailed breakdown of VEX trust score with: + * - Score summary with threshold comparison + * - Factor breakdown with progress bars + * - Evidence details (issuer, signature, transparency) + * - Copy and details actions + */ + +import { Component, input, computed, output, inject, ElementRef, HostListener } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { VexTrustStatus, TrustScoreBreakdown, VexTrustChipComponent } from '../vex-trust-chip'; + +/** + * Trust factor for breakdown display. + */ +interface TrustFactor { + readonly label: string; + readonly value: number; + readonly tier: 'high' | 'medium' | 'low'; + readonly weight?: number; +} + +/** + * Popover component for VEX trust score breakdown. + * + * @example + * + */ +@Component({ + selector: 'stella-vex-trust-popover', + standalone: true, + imports: [CommonModule, VexTrustChipComponent], + template: ` + + `, + styles: [` + .trust-popover { + width: 320px; + background: var(--popover-bg, #ffffff); + border: 1px solid var(--popover-border, #e5e7eb); + border-radius: 8px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + font-size: 0.875rem; + } + + .trust-popover__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--popover-border, #e5e7eb); + } + + .trust-popover__title { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary, #111827); + } + + .trust-popover__close { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + font-size: 1.25rem; + color: var(--text-secondary, #6b7280); + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: var(--hover-bg, #f3f4f6); + color: var(--text-primary, #111827); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color, #4f46e5); + outline-offset: 1px; + } + } + + .trust-popover__summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + gap: 1rem; + } + + .trust-popover__score-display { + display: flex; + align-items: baseline; + gap: 0.25rem; + } + + .trust-popover__score { + font-size: 1.5rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text-primary, #111827); + } + + .trust-popover__threshold { + font-size: 0.75rem; + color: var(--text-secondary, #6b7280); + } + + .trust-popover__breakdown, + .trust-popover__evidence { + padding: 0.75rem 1rem; + border-top: 1px solid var(--popover-border, #e5e7eb); + } + + .trust-popover__section-title { + margin: 0 0 0.75rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, #6b7280); + } + + .trust-popover__factors { + display: flex; + flex-direction: column; + gap: 0.625rem; + } + + .trust-factor { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .trust-factor__header { + display: flex; + justify-content: space-between; + align-items: center; + } + + .trust-factor__label { + font-size: 0.75rem; + color: var(--text-primary, #111827); + } + + .trust-factor__value { + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-secondary, #6b7280); + } + + .trust-factor__bar { + height: 6px; + background: var(--bar-bg, #e5e7eb); + border-radius: 3px; + overflow: hidden; + } + + .trust-factor__fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; + } + + .trust-factor__fill--high { + background: #22c55e; + } + + .trust-factor__fill--medium { + background: #f59e0b; + } + + .trust-factor__fill--low { + background: #ef4444; + } + + .trust-popover__evidence-list { + margin: 0; + padding: 0; + list-style: none; + } + + .trust-evidence-item { + display: flex; + gap: 0.5rem; + padding: 0.375rem 0; + font-size: 0.8125rem; + color: var(--text-primary, #111827); + + &:not(:last-child) { + border-bottom: 1px solid var(--popover-border-light, #f3f4f6); + } + + strong { + flex-shrink: 0; + color: var(--text-secondary, #6b7280); + } + + a { + color: var(--link-color, #3b82f6); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .freshness-fresh { + color: #22c55e; + } + + .freshness-stale { + color: #f59e0b; + } + + .freshness-superseded, + .freshness-expired { + color: #ef4444; + } + + .trust-popover__footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--popover-border, #e5e7eb); + } + + .trust-popover__action { + padding: 0.375rem 0.75rem; + border: 1px solid; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + + &:focus-visible { + outline: 2px solid var(--focus-ring-color, #4f46e5); + outline-offset: 1px; + } + } + + .trust-popover__action--secondary { + background: transparent; + border-color: var(--border-color, #d1d5db); + color: var(--text-primary, #374151); + + &:hover { + background: var(--hover-bg, #f3f4f6); + } + } + + .trust-popover__action--primary { + background: var(--primary-color, #3b82f6); + border-color: var(--primary-color, #3b82f6); + color: white; + + &:hover { + background: var(--primary-hover, #2563eb); + border-color: var(--primary-hover, #2563eb); + } + } + + // Dark mode + @media (prefers-color-scheme: dark) { + .trust-popover { + --popover-bg: #1f2937; + --popover-border: #374151; + --popover-border-light: #374151; + --text-primary: #f9fafb; + --text-secondary: #9ca3af; + --hover-bg: #374151; + --bar-bg: #374151; + --border-color: #4b5563; + --link-color: #60a5fa; + } + } + `] +}) +export class VexTrustPopoverComponent { + private readonly elementRef = inject(ElementRef); + + /** Trust status input */ + trustStatus = input.required(); + + /** Anchor element for positioning */ + anchorElement = input(); + + /** Close event */ + close = output(); + + /** View details event */ + viewDetails = output(); + + /** Copy evidence event */ + copyEvidence = output(); + + /** Computed factors for breakdown display */ + factors = computed(() => { + const breakdown = this.trustStatus()?.trustBreakdown; + if (!breakdown) return []; + + const factors: TrustFactor[] = []; + + if (breakdown.originScore !== undefined) { + factors.push({ + label: 'Origin', + value: breakdown.originScore, + tier: this.getTier(breakdown.originScore), + weight: 0.5 + }); + } + + if (breakdown.freshnessScore !== undefined) { + factors.push({ + label: 'Freshness', + value: breakdown.freshnessScore, + tier: this.getTier(breakdown.freshnessScore), + weight: 0.3 + }); + } + + if (breakdown.reputationScore !== undefined) { + factors.push({ + label: 'Reputation', + value: breakdown.reputationScore, + tier: this.getTier(breakdown.reputationScore), + weight: 0.2 + }); + } + + if (breakdown.accuracyScore !== undefined) { + factors.push({ + label: 'Accuracy', + value: breakdown.accuracyScore, + tier: this.getTier(breakdown.accuracyScore) + }); + } + + if (breakdown.verificationScore !== undefined) { + factors.push({ + label: 'Verification', + value: breakdown.verificationScore, + tier: this.getTier(breakdown.verificationScore) + }); + } + + if (breakdown.authorityScore !== undefined) { + factors.push({ + label: 'Authority', + value: breakdown.authorityScore, + tier: this.getTier(breakdown.authorityScore) + }); + } + + if (breakdown.coverageScore !== undefined) { + factors.push({ + label: 'Coverage', + value: breakdown.coverageScore, + tier: this.getTier(breakdown.coverageScore) + }); + } + + return factors; + }); + + /** Check if breakdown is available */ + hasBreakdown = computed(() => { + const breakdown = this.trustStatus()?.trustBreakdown; + return breakdown && Object.keys(breakdown).length > 0; + }); + + /** Check if evidence details are available */ + hasEvidence = computed(() => { + const status = this.trustStatus(); + return !!( + status?.issuerName || + status?.signatureVerified || + status?.rekorLogIndex || + status?.freshness || + status?.verifiedAt + ); + }); + + /** Issuer profile URL */ + issuerProfileUrl = computed(() => { + const issuerId = this.trustStatus()?.issuerId; + if (!issuerId) return null; + // TODO: Make this configurable + return `https://search.sigstore.dev/?email=${encodeURIComponent(issuerId)}`; + }); + + /** Rekor log URL */ + rekorUrl = computed(() => { + const logIndex = this.trustStatus()?.rekorLogIndex; + const logId = this.trustStatus()?.rekorLogId ?? 'rekor.sigstore.dev'; + if (!logIndex) return null; + return `https://search.sigstore.dev/?logIndex=${logIndex}`; + }); + + /** Get tier for score value */ + private getTier(score: number): 'high' | 'medium' | 'low' { + if (score >= 0.7) return 'high'; + if (score >= 0.5) return 'medium'; + return 'low'; + } + + /** Format score for display */ + formatScore(value?: number): string { + if (value === undefined || value === null) return '—'; + return value.toFixed(2); + } + + /** Format value as percentage */ + formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; + } + + /** Format date for display */ + formatDate(dateStr?: string): string { + if (!dateStr) return '—'; + try { + const date = new Date(dateStr); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return dateStr; + } + } + + /** Handle close button */ + onClose(): void { + this.close.emit(); + } + + /** Handle copy evidence action */ + onCopyEvidence(): void { + const status = this.trustStatus(); + const evidence: Record = { + trustScore: status?.trustScore, + policyThreshold: status?.policyTrustThreshold, + meetsPolicyThreshold: status?.meetsPolicyThreshold, + issuer: status?.issuerName, + issuerId: status?.issuerId, + signatureVerified: status?.signatureVerified, + signatureMethod: status?.signatureMethod, + rekorLogIndex: status?.rekorLogIndex, + freshness: status?.freshness, + verifiedAt: status?.verifiedAt, + breakdown: status?.trustBreakdown + }; + + const text = JSON.stringify(evidence, null, 2); + navigator.clipboard.writeText(text).then(() => { + this.copyEvidence.emit(text); + }); + } + + /** Handle view details action */ + onViewDetails(): void { + this.viewDetails.emit(); + } + + /** Close on Escape key */ + @HostListener('document:keydown.escape') + onEscapeKey(): void { + this.close.emit(); + } + + /** Close on outside click */ + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + const anchor = this.anchorElement(); + + // Don't close if clicking inside popover or anchor + if (this.elementRef.nativeElement.contains(target)) return; + if (anchor && anchor.contains(target)) return; + + this.close.emit(); + } +} diff --git a/src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.ts b/src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.ts new file mode 100644 index 000000000..fa262dd6a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.ts @@ -0,0 +1,28 @@ +// ----------------------------------------------------------------------------- +// truncate.pipe.ts +// Sprint: SPRINT_1227_0005_0002_FE_proof_tree_integration +// Shared utility pipe for truncating strings +// ----------------------------------------------------------------------------- + +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Truncates a string to a specified length with ellipsis. + * + * @example + * ```html + * {{ longHash | truncate:12 }} + * {{ text | truncate:50:'...' }} + * ``` + */ +@Pipe({ + name: 'truncate', + standalone: true +}) +export class TruncatePipe implements PipeTransform { + transform(value: string | null | undefined, length = 20, suffix = '...'): string { + if (!value) return ''; + if (value.length <= length) return value; + return value.substring(0, length) + suffix; + } +} diff --git a/src/Zastava/StellaOps.Zastava.sln b/src/Zastava/StellaOps.Zastava.sln index 2a3711cf9..42ae80d82 100644 --- a/src/Zastava/StellaOps.Zastava.sln +++ b/src/Zastava/StellaOps.Zastava.sln @@ -1,520 +1,397 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{8CFCFE22-5252-529E-B495-D4E19870D339}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{E3FABCDE-8417-5C88-9745-274A891656BE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Agent", "StellaOps.Zastava.Agent\StellaOps.Zastava.Agent.csproj", "{D3BA9C21-1337-5091-AD41-ABD11C4B150D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Observer", "StellaOps.Zastava.Observer\StellaOps.Zastava.Observer.csproj", "{849DA55E-D3D1-5E35-A339-B1AC4590E0A3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Webhook", "StellaOps.Zastava.Webhook\StellaOps.Zastava.Webhook.csproj", "{CEE84738-20C1-5800-B982-E331652C3917}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Core", "__Libraries\StellaOps.Zastava.Core\StellaOps.Zastava.Core.csproj", "{B118588F-2F12-5CA8-8EED-426A7D34FF9A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Core.Tests", "__Tests\StellaOps.Zastava.Core.Tests\StellaOps.Zastava.Core.Tests.csproj", "{7D3BAFD9-4120-5A6A-B215-10AB461844EB}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Observer.Tests", "__Tests\StellaOps.Zastava.Observer.Tests\StellaOps.Zastava.Observer.Tests.csproj", "{27196784-FFEA-59AB-8F26-3840EDF6C831}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Webhook.Tests", "__Tests\StellaOps.Zastava.Webhook.Tests\StellaOps.Zastava.Webhook.Tests.csproj", "{69AE1332-70C7-501D-A64E-F769F52B2449}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "External", "External", "{A288BEE0-695A-BEEA-455C-649571D89326}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1F92E26C-13A4-0B19-72A1-5EF3151D62EA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{C53B2D73-6745-4583-93E0-1197805FCCA7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{93DFCEE2-15D7-44D6-B55D-EB965162A3CE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "..\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{65171F97-7835-4062-AEC6-F7C286AC8705}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{383DE39D-E195-48CF-BD33-680CEFE6E73E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "..\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{06230BCA-524E-4218-A73B-F9EAED47878A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{832E68D6-5EC3-4899-89C8-392A05698CEC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "..\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{DAA306DE-D22D-4761-BD56-5CBD002A4F99}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "..\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{8E844664-A8B8-4C7D-853C-FF01DAAB004C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "..\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "..\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{48481977-0573-43FD-81D4-6E8B1CBFE629}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "..\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{7B9ABC4D-818F-46B5-8235-ECB49AA612B0}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "..\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{752521A5-25CD-4F4D-8686-6E4F46177481}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{D46A6C13-950B-415D-9A3D-F96ADC0DFD74}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{2B929A39-48F9-439F-A158-7F6BA1651420}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{6AC98C40-0802-402B-A837-2FD78279F415}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "..\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{91D7A839-2A33-47AE-9218-664E6B9961FE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authority", "Authority", "{F2A1A59E-5D99-90EB-2C08-D68B3B8F346A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "..\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{7BBB8EB1-54CA-411B-8127-698065065224}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scanner", "Scanner", "{C116BC78-CCE5-BACD-1286-6679035DDE2F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Env", "..\Scanner\__Libraries\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj", "{3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.FS", "..\Scanner\__Libraries\StellaOps.Scanner.Surface.FS\StellaOps.Scanner.Surface.FS.csproj", "{CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Secrets", "..\Scanner\__Libraries\StellaOps.Scanner.Surface.Secrets\StellaOps.Scanner.Surface.Secrets.csproj", "{FE887A1C-D835-4C35-AE19-6218FF168E85}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Validation", "..\Scanner\__Libraries\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj", "{C8481514-77F8-4D1A-9151-BAB14CC0A609}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Signals", "Signals", "{AD729257-9153-F4E3-A929-26FE469E5725}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals", "..\Signals\StellaOps.Signals\StellaOps.Signals.csproj", "{76E6E5D6-A41C-4813-8513-F77B937AD096}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AirGap", "AirGap", "{F7CAB0A3-2ADD-CCF5-0F83-E1B4BF207CF0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Debug|x64.ActiveCfg = Debug|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Debug|x64.Build.0 = Debug|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Debug|x86.ActiveCfg = Debug|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Debug|x86.Build.0 = Debug|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Release|Any CPU.Build.0 = Release|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Release|x64.ActiveCfg = Release|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Release|x64.Build.0 = Release|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Release|x86.ActiveCfg = Release|Any CPU - {D3BA9C21-1337-5091-AD41-ABD11C4B150D}.Release|x86.Build.0 = Release|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Debug|x64.ActiveCfg = Debug|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Debug|x64.Build.0 = Debug|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Debug|x86.ActiveCfg = Debug|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Debug|x86.Build.0 = Debug|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Release|Any CPU.Build.0 = Release|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Release|x64.ActiveCfg = Release|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Release|x64.Build.0 = Release|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Release|x86.ActiveCfg = Release|Any CPU - {849DA55E-D3D1-5E35-A339-B1AC4590E0A3}.Release|x86.Build.0 = Release|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Debug|x64.ActiveCfg = Debug|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Debug|x64.Build.0 = Debug|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Debug|x86.ActiveCfg = Debug|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Debug|x86.Build.0 = Debug|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Release|Any CPU.Build.0 = Release|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Release|x64.ActiveCfg = Release|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Release|x64.Build.0 = Release|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Release|x86.ActiveCfg = Release|Any CPU - {CEE84738-20C1-5800-B982-E331652C3917}.Release|x86.Build.0 = Release|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Debug|x64.ActiveCfg = Debug|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Debug|x64.Build.0 = Debug|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Debug|x86.ActiveCfg = Debug|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Debug|x86.Build.0 = Debug|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Release|Any CPU.Build.0 = Release|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Release|x64.ActiveCfg = Release|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Release|x64.Build.0 = Release|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Release|x86.ActiveCfg = Release|Any CPU - {B118588F-2F12-5CA8-8EED-426A7D34FF9A}.Release|x86.Build.0 = Release|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Debug|x64.ActiveCfg = Debug|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Debug|x64.Build.0 = Debug|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Debug|x86.ActiveCfg = Debug|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Debug|x86.Build.0 = Debug|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Release|Any CPU.Build.0 = Release|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Release|x64.ActiveCfg = Release|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Release|x64.Build.0 = Release|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Release|x86.ActiveCfg = Release|Any CPU - {7D3BAFD9-4120-5A6A-B215-10AB461844EB}.Release|x86.Build.0 = Release|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Debug|Any CPU.Build.0 = Debug|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Debug|x64.ActiveCfg = Debug|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Debug|x64.Build.0 = Debug|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Debug|x86.ActiveCfg = Debug|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Debug|x86.Build.0 = Debug|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Release|Any CPU.ActiveCfg = Release|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Release|Any CPU.Build.0 = Release|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Release|x64.ActiveCfg = Release|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Release|x64.Build.0 = Release|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Release|x86.ActiveCfg = Release|Any CPU - {27196784-FFEA-59AB-8F26-3840EDF6C831}.Release|x86.Build.0 = Release|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Debug|Any CPU.Build.0 = Debug|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Debug|x64.ActiveCfg = Debug|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Debug|x64.Build.0 = Debug|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Debug|x86.ActiveCfg = Debug|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Debug|x86.Build.0 = Debug|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Release|Any CPU.ActiveCfg = Release|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Release|Any CPU.Build.0 = Release|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Release|x64.ActiveCfg = Release|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Release|x64.Build.0 = Release|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Release|x86.ActiveCfg = Release|Any CPU - {69AE1332-70C7-501D-A64E-F769F52B2449}.Release|x86.Build.0 = Release|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Debug|x64.ActiveCfg = Debug|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Debug|x64.Build.0 = Debug|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Debug|x86.ActiveCfg = Debug|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Debug|x86.Build.0 = Debug|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Release|Any CPU.Build.0 = Release|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Release|x64.ActiveCfg = Release|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Release|x64.Build.0 = Release|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Release|x86.ActiveCfg = Release|Any CPU - {C53B2D73-6745-4583-93E0-1197805FCCA7}.Release|x86.Build.0 = Release|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Debug|x64.ActiveCfg = Debug|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Debug|x64.Build.0 = Debug|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Debug|x86.ActiveCfg = Debug|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Debug|x86.Build.0 = Debug|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Release|Any CPU.Build.0 = Release|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Release|x64.ActiveCfg = Release|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Release|x64.Build.0 = Release|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Release|x86.ActiveCfg = Release|Any CPU - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE}.Release|x86.Build.0 = Release|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Debug|Any CPU.Build.0 = Debug|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Debug|x64.ActiveCfg = Debug|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Debug|x64.Build.0 = Debug|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Debug|x86.ActiveCfg = Debug|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Debug|x86.Build.0 = Debug|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Release|Any CPU.ActiveCfg = Release|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Release|Any CPU.Build.0 = Release|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Release|x64.ActiveCfg = Release|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Release|x64.Build.0 = Release|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Release|x86.ActiveCfg = Release|Any CPU - {65171F97-7835-4062-AEC6-F7C286AC8705}.Release|x86.Build.0 = Release|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Debug|x64.ActiveCfg = Debug|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Debug|x64.Build.0 = Debug|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Debug|x86.ActiveCfg = Debug|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Debug|x86.Build.0 = Debug|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Release|Any CPU.Build.0 = Release|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Release|x64.ActiveCfg = Release|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Release|x64.Build.0 = Release|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Release|x86.ActiveCfg = Release|Any CPU - {383DE39D-E195-48CF-BD33-680CEFE6E73E}.Release|x86.Build.0 = Release|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Debug|x64.ActiveCfg = Debug|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Debug|x64.Build.0 = Debug|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Debug|x86.ActiveCfg = Debug|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Debug|x86.Build.0 = Debug|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Release|Any CPU.Build.0 = Release|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Release|x64.ActiveCfg = Release|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Release|x64.Build.0 = Release|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Release|x86.ActiveCfg = Release|Any CPU - {06230BCA-524E-4218-A73B-F9EAED47878A}.Release|x86.Build.0 = Release|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Debug|x64.ActiveCfg = Debug|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Debug|x64.Build.0 = Debug|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Debug|x86.ActiveCfg = Debug|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Debug|x86.Build.0 = Debug|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Release|Any CPU.Build.0 = Release|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Release|x64.ActiveCfg = Release|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Release|x64.Build.0 = Release|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Release|x86.ActiveCfg = Release|Any CPU - {832E68D6-5EC3-4899-89C8-392A05698CEC}.Release|x86.Build.0 = Release|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Debug|x64.ActiveCfg = Debug|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Debug|x64.Build.0 = Debug|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Debug|x86.ActiveCfg = Debug|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Debug|x86.Build.0 = Debug|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Release|Any CPU.Build.0 = Release|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Release|x64.ActiveCfg = Release|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Release|x64.Build.0 = Release|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Release|x86.ActiveCfg = Release|Any CPU - {DAA306DE-D22D-4761-BD56-5CBD002A4F99}.Release|x86.Build.0 = Release|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Debug|x64.ActiveCfg = Debug|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Debug|x64.Build.0 = Debug|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Debug|x86.ActiveCfg = Debug|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Debug|x86.Build.0 = Debug|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Release|Any CPU.Build.0 = Release|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Release|x64.ActiveCfg = Release|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Release|x64.Build.0 = Release|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Release|x86.ActiveCfg = Release|Any CPU - {8E844664-A8B8-4C7D-853C-FF01DAAB004C}.Release|x86.Build.0 = Release|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Debug|Any CPU.Build.0 = Debug|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Debug|x64.ActiveCfg = Debug|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Debug|x64.Build.0 = Debug|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Debug|x86.ActiveCfg = Debug|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Debug|x86.Build.0 = Debug|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Release|Any CPU.ActiveCfg = Release|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Release|Any CPU.Build.0 = Release|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Release|x64.ActiveCfg = Release|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Release|x64.Build.0 = Release|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Release|x86.ActiveCfg = Release|Any CPU - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343}.Release|x86.Build.0 = Release|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Debug|x64.ActiveCfg = Debug|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Debug|x64.Build.0 = Debug|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Debug|x86.ActiveCfg = Debug|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Debug|x86.Build.0 = Debug|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Release|Any CPU.ActiveCfg = Release|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Release|Any CPU.Build.0 = Release|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Release|x64.ActiveCfg = Release|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Release|x64.Build.0 = Release|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Release|x86.ActiveCfg = Release|Any CPU - {48481977-0573-43FD-81D4-6E8B1CBFE629}.Release|x86.Build.0 = Release|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Debug|x64.ActiveCfg = Debug|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Debug|x64.Build.0 = Debug|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Debug|x86.ActiveCfg = Debug|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Debug|x86.Build.0 = Debug|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Release|Any CPU.Build.0 = Release|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Release|x64.ActiveCfg = Release|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Release|x64.Build.0 = Release|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Release|x86.ActiveCfg = Release|Any CPU - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0}.Release|x86.Build.0 = Release|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Debug|Any CPU.Build.0 = Debug|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Debug|x64.ActiveCfg = Debug|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Debug|x64.Build.0 = Debug|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Debug|x86.ActiveCfg = Debug|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Debug|x86.Build.0 = Debug|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Release|Any CPU.ActiveCfg = Release|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Release|Any CPU.Build.0 = Release|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Release|x64.ActiveCfg = Release|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Release|x64.Build.0 = Release|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Release|x86.ActiveCfg = Release|Any CPU - {752521A5-25CD-4F4D-8686-6E4F46177481}.Release|x86.Build.0 = Release|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Debug|x64.ActiveCfg = Debug|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Debug|x64.Build.0 = Debug|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Debug|x86.ActiveCfg = Debug|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Debug|x86.Build.0 = Debug|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Release|Any CPU.Build.0 = Release|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Release|x64.ActiveCfg = Release|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Release|x64.Build.0 = Release|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Release|x86.ActiveCfg = Release|Any CPU - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74}.Release|x86.Build.0 = Release|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Debug|x64.ActiveCfg = Debug|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Debug|x64.Build.0 = Debug|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Debug|x86.ActiveCfg = Debug|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Debug|x86.Build.0 = Debug|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Release|Any CPU.Build.0 = Release|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Release|x64.ActiveCfg = Release|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Release|x64.Build.0 = Release|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Release|x86.ActiveCfg = Release|Any CPU - {2B929A39-48F9-439F-A158-7F6BA1651420}.Release|x86.Build.0 = Release|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Debug|x64.ActiveCfg = Debug|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Debug|x64.Build.0 = Debug|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Debug|x86.ActiveCfg = Debug|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Debug|x86.Build.0 = Debug|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Release|Any CPU.Build.0 = Release|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Release|x64.ActiveCfg = Release|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Release|x64.Build.0 = Release|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Release|x86.ActiveCfg = Release|Any CPU - {6AC98C40-0802-402B-A837-2FD78279F415}.Release|x86.Build.0 = Release|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Debug|x64.ActiveCfg = Debug|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Debug|x64.Build.0 = Debug|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Debug|x86.ActiveCfg = Debug|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Debug|x86.Build.0 = Debug|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Release|Any CPU.Build.0 = Release|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Release|x64.ActiveCfg = Release|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Release|x64.Build.0 = Release|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Release|x86.ActiveCfg = Release|Any CPU - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E}.Release|x86.Build.0 = Release|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Debug|x64.ActiveCfg = Debug|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Debug|x64.Build.0 = Debug|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Debug|x86.ActiveCfg = Debug|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Debug|x86.Build.0 = Debug|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Release|Any CPU.Build.0 = Release|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Release|x64.ActiveCfg = Release|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Release|x64.Build.0 = Release|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Release|x86.ActiveCfg = Release|Any CPU - {91D7A839-2A33-47AE-9218-664E6B9961FE}.Release|x86.Build.0 = Release|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Debug|Any CPU.Build.0 = Debug|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Debug|x64.ActiveCfg = Debug|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Debug|x64.Build.0 = Debug|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Debug|x86.ActiveCfg = Debug|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Debug|x86.Build.0 = Debug|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Release|Any CPU.ActiveCfg = Release|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Release|Any CPU.Build.0 = Release|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Release|x64.ActiveCfg = Release|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Release|x64.Build.0 = Release|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Release|x86.ActiveCfg = Release|Any CPU - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68}.Release|x86.Build.0 = Release|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Debug|x64.ActiveCfg = Debug|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Debug|x64.Build.0 = Debug|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Debug|x86.ActiveCfg = Debug|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Debug|x86.Build.0 = Debug|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Release|Any CPU.Build.0 = Release|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Release|x64.ActiveCfg = Release|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Release|x64.Build.0 = Release|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Release|x86.ActiveCfg = Release|Any CPU - {7BBB8EB1-54CA-411B-8127-698065065224}.Release|x86.Build.0 = Release|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Debug|x64.ActiveCfg = Debug|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Debug|x64.Build.0 = Debug|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Debug|x86.ActiveCfg = Debug|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Debug|x86.Build.0 = Debug|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Release|Any CPU.Build.0 = Release|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Release|x64.ActiveCfg = Release|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Release|x64.Build.0 = Release|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Release|x86.ActiveCfg = Release|Any CPU - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1}.Release|x86.Build.0 = Release|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Debug|x64.ActiveCfg = Debug|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Debug|x64.Build.0 = Debug|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Debug|x86.ActiveCfg = Debug|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Debug|x86.Build.0 = Debug|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Release|Any CPU.Build.0 = Release|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Release|x64.ActiveCfg = Release|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Release|x64.Build.0 = Release|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Release|x86.ActiveCfg = Release|Any CPU - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2}.Release|x86.Build.0 = Release|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Debug|x64.ActiveCfg = Debug|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Debug|x64.Build.0 = Debug|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Debug|x86.ActiveCfg = Debug|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Debug|x86.Build.0 = Debug|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Release|Any CPU.Build.0 = Release|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Release|x64.ActiveCfg = Release|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Release|x64.Build.0 = Release|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Release|x86.ActiveCfg = Release|Any CPU - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6}.Release|x86.Build.0 = Release|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Debug|x64.ActiveCfg = Debug|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Debug|x64.Build.0 = Debug|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Debug|x86.ActiveCfg = Debug|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Debug|x86.Build.0 = Debug|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Release|Any CPU.Build.0 = Release|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Release|x64.ActiveCfg = Release|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Release|x64.Build.0 = Release|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Release|x86.ActiveCfg = Release|Any CPU - {FE887A1C-D835-4C35-AE19-6218FF168E85}.Release|x86.Build.0 = Release|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Debug|x64.ActiveCfg = Debug|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Debug|x64.Build.0 = Debug|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Debug|x86.ActiveCfg = Debug|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Debug|x86.Build.0 = Debug|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Release|Any CPU.Build.0 = Release|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Release|x64.ActiveCfg = Release|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Release|x64.Build.0 = Release|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Release|x86.ActiveCfg = Release|Any CPU - {C8481514-77F8-4D1A-9151-BAB14CC0A609}.Release|x86.Build.0 = Release|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Debug|Any CPU.Build.0 = Debug|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Debug|x64.ActiveCfg = Debug|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Debug|x64.Build.0 = Debug|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Debug|x86.ActiveCfg = Debug|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Debug|x86.Build.0 = Debug|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Release|Any CPU.ActiveCfg = Release|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Release|Any CPU.Build.0 = Release|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Release|x64.ActiveCfg = Release|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Release|x64.Build.0 = Release|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Release|x86.ActiveCfg = Release|Any CPU - {76E6E5D6-A41C-4813-8513-F77B937AD096}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {B118588F-2F12-5CA8-8EED-426A7D34FF9A} = {8CFCFE22-5252-529E-B495-D4E19870D339} - {7D3BAFD9-4120-5A6A-B215-10AB461844EB} = {E3FABCDE-8417-5C88-9745-274A891656BE} - {27196784-FFEA-59AB-8F26-3840EDF6C831} = {E3FABCDE-8417-5C88-9745-274A891656BE} - {69AE1332-70C7-501D-A64E-F769F52B2449} = {E3FABCDE-8417-5C88-9745-274A891656BE} - {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} = {A288BEE0-695A-BEEA-455C-649571D89326} - {C53B2D73-6745-4583-93E0-1197805FCCA7} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {93DFCEE2-15D7-44D6-B55D-EB965162A3CE} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {65171F97-7835-4062-AEC6-F7C286AC8705} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {383DE39D-E195-48CF-BD33-680CEFE6E73E} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {06230BCA-524E-4218-A73B-F9EAED47878A} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {832E68D6-5EC3-4899-89C8-392A05698CEC} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {DAA306DE-D22D-4761-BD56-5CBD002A4F99} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {8E844664-A8B8-4C7D-853C-FF01DAAB004C} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {91E4BEB3-DB83-4880-B2FC-0A3ABD4C4343} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {48481977-0573-43FD-81D4-6E8B1CBFE629} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {7B9ABC4D-818F-46B5-8235-ECB49AA612B0} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {752521A5-25CD-4F4D-8686-6E4F46177481} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {D46A6C13-950B-415D-9A3D-F96ADC0DFD74} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {2B929A39-48F9-439F-A158-7F6BA1651420} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {6AC98C40-0802-402B-A837-2FD78279F415} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {ABEFEE73-BB5F-48E0-A5E4-F943C2D3560E} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {91D7A839-2A33-47AE-9218-664E6B9961FE} = {1F92E26C-13A4-0B19-72A1-5EF3151D62EA} - {F2A1A59E-5D99-90EB-2C08-D68B3B8F346A} = {A288BEE0-695A-BEEA-455C-649571D89326} - {339E2955-2EAA-4AF8-A3AA-E8F9C0AD4C68} = {F2A1A59E-5D99-90EB-2C08-D68B3B8F346A} - {7BBB8EB1-54CA-411B-8127-698065065224} = {F2A1A59E-5D99-90EB-2C08-D68B3B8F346A} - {6A8695BD-4FA8-4A34-9A9D-EF150C09F7B1} = {F2A1A59E-5D99-90EB-2C08-D68B3B8F346A} - {C116BC78-CCE5-BACD-1286-6679035DDE2F} = {A288BEE0-695A-BEEA-455C-649571D89326} - {3984AD8B-E8B4-41C3-ADA3-9972E811DDF2} = {C116BC78-CCE5-BACD-1286-6679035DDE2F} - {CFF4A7B9-BCC0-4E42-934C-FD2E555EB8A6} = {C116BC78-CCE5-BACD-1286-6679035DDE2F} - {FE887A1C-D835-4C35-AE19-6218FF168E85} = {C116BC78-CCE5-BACD-1286-6679035DDE2F} - {C8481514-77F8-4D1A-9151-BAB14CC0A609} = {C116BC78-CCE5-BACD-1286-6679035DDE2F} - {AD729257-9153-F4E3-A929-26FE469E5725} = {A288BEE0-695A-BEEA-455C-649571D89326} - {76E6E5D6-A41C-4813-8513-F77B937AD096} = {AD729257-9153-F4E3-A929-26FE469E5725} - {F7CAB0A3-2ADD-CCF5-0F83-E1B4BF207CF0} = {A288BEE0-695A-BEEA-455C-649571D89326} - EndGlobalSection -EndGlobal +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Zastava.Agent", "StellaOps.Zastava.Agent", "{91DF1D43-A799-FBAC-9FAB-50805F3B8E95}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Zastava.Observer", "StellaOps.Zastava.Observer", "{077163DD-F675-2418-D9F6-1EE41D4A52F1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Zastava.Webhook", "StellaOps.Zastava.Webhook", "{D822E254-8BCD-A471-A8EB-B89B793121BE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AirGap", "AirGap", "{F310596E-88BB-9E54-885E-21C61971917E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{D9492ED1-A812-924B-65E4-F518592B49BB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{3823DE1E-2ACE-C956-99E1-00DB786D9E1D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Authority", "Authority", "{C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Abstractions", "StellaOps.Auth.Abstractions", "{F2E6CB0E-DF77-1FAA-582B-62B040DF3848}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Client", "StellaOps.Auth.Client", "{C494ECBE-DEA5-3576-D2AF-200FF12BC144}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.ServerIntegration", "StellaOps.Auth.ServerIntegration", "{7E890DF9-B715-B6DF-2498-FD74DDA87D71}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority.Plugins.Abstractions", "{64689413-46D7-8499-68A6-B6367ACBC597}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scanner", "Scanner", "{5896C4B3-31D1-1EDD-11D0-C46DB178DC12}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{D4D193A8-47D7-0B1A-1327-F9C580E7AD07}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scanner.Surface.Env", "StellaOps.Scanner.Surface.Env", "{336213F7-1241-D268-8EA5-1C73F0040714}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scanner.Surface.FS", "StellaOps.Scanner.Surface.FS", "{5693F73D-6707-6F86-65D6-654023205615}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scanner.Surface.Secrets", "StellaOps.Scanner.Surface.Secrets", "{593308D7-2453-DC66-4151-E983E4B3F422}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Scanner.Surface.Validation", "StellaOps.Scanner.Surface.Validation", "{7D55A179-3CDB-8D44-C448-F502BF7ECB3D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Signals", "Signals", "{AD65DDE7-9FEA-7380-8C10-FA165F745354}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Signals", "StellaOps.Signals", "{076B8074-5735-5367-1EEA-CA16A5B8ABD7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Auth.Security", "StellaOps.Auth.Security", "{9C2DD234-FA33-FDB6-86F0-EF9B75A13450}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Configuration", "StellaOps.Configuration", "{538E2D98-5325-3F54-BE74-EFE5FC1ECBD8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.DependencyInjection", "StellaOps.Cryptography.DependencyInjection", "{7203223D-FF02-7BEB-2798-D1639ACC01C4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.CryptoPro", "StellaOps.Cryptography.Plugin.CryptoPro", "{3C69853C-90E3-D889-1960-3B9229882590}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "StellaOps.Cryptography.Plugin.OpenSslGost", "{643E4D4C-BC96-A37F-E0EC-488127F0B127}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "StellaOps.Cryptography.Plugin.Pkcs11Gost", "{6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.PqSoft", "StellaOps.Cryptography.Plugin.PqSoft", "{F04B7DBB-77A5-C978-B2DE-8C189A32AA72}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SimRemote", "StellaOps.Cryptography.Plugin.SimRemote", "{7C72F22A-20FF-DF5B-9191-6DFD0D497DB2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmRemote", "StellaOps.Cryptography.Plugin.SmRemote", "{C896CC0A-F5E6-9AA4-C582-E691441F8D32}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.SmSoft", "StellaOps.Cryptography.Plugin.SmSoft", "{0AA3A418-AB45-CCA4-46D4-EEBFE011FECA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.WineCsp", "StellaOps.Cryptography.Plugin.WineCsp", "{225D9926-4AE8-E539-70AD-8698E688F271}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.PluginLoader", "StellaOps.Cryptography.PluginLoader", "{D6E8E69C-F721-BBCB-8C39-9716D53D72AD}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Messaging", "StellaOps.Messaging", "{F13BD9B8-30E2-C0F1-F73B-5B5E8B381174}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Zastava.Core", "StellaOps.Zastava.Core", "{E56F19DE-990B-0DFA-84CD-E7D9E3D8E6E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Zastava.Core.Tests", "StellaOps.Zastava.Core.Tests", "{19A31EDC-D634-74F9-0619-B157C79F6408}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Zastava.Observer.Tests", "StellaOps.Zastava.Observer.Tests", "{7F1A0818-835A-3FBA-597A-A48858B41EF8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Zastava.Webhook.Tests", "StellaOps.Zastava.Webhook.Tests", "{3BDF66FB-66EE-50BC-E0AB-BF1D040118F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "E:\dev\git.stella-ops.org\src\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{AD31623A-BC43-52C2-D906-AC1D8784A541}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{DE5BF139-1E5C-D6EA-4FAA-661EF353A194}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Security", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj", "{335E62C0-9E69-A952-680B-753B1B17C6D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{97F94029-5419-6187-5A63-5C8FD9232FAE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{92C62F7B-8028-6EE1-B71B-F45F459B8E97}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{FA83F778-5252-0B80-5555-E69F790322EA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.CryptoPro", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.CryptoPro\StellaOps.Cryptography.Plugin.CryptoPro.csproj", "{C53E0895-879A-D9E6-0A43-24AD17A2F270}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OpenSslGost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj", "{0AED303F-69E6-238F-EF80-81985080EDB7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.Pkcs11Gost", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.Pkcs11Gost\StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj", "{2904D288-CE64-A565-2C46-C2E85A96A1EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.PqSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.PqSoft\StellaOps.Cryptography.Plugin.PqSoft.csproj", "{A6667CC3-B77F-023E-3A67-05F99E9FF46A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SimRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SimRemote\StellaOps.Cryptography.Plugin.SimRemote.csproj", "{A26E2816-F787-F76B-1D6C-E086DD3E19CE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmRemote", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmRemote\StellaOps.Cryptography.Plugin.SmRemote.csproj", "{B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.SmSoft", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj", "{90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.WineCsp", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.WineCsp\StellaOps.Cryptography.Plugin.WineCsp.csproj", "{059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.PluginLoader", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.PluginLoader\StellaOps.Cryptography.PluginLoader.csproj", "{8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Messaging", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj", "{F8CF01C2-3B5D-C488-C272-0B793C2321FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Env", "E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj", "{52698305-D6F8-C13C-0882-48FC37726404}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.FS", "E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Surface.FS\StellaOps.Scanner.Surface.FS.csproj", "{5567139C-0365-B6A0-5DD0-978A09B9F176}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Secrets", "E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Surface.Secrets\StellaOps.Scanner.Surface.Secrets.csproj", "{256D269B-35EA-F833-2F1D-8E0058908DEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Surface.Validation", "E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj", "{6E9C9582-67FA-2EB1-C6BA-AD4CD326E276}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals", "E:\dev\git.stella-ops.org\src\Signals\StellaOps.Signals\StellaOps.Signals.csproj", "{A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Agent", "StellaOps.Zastava.Agent\StellaOps.Zastava.Agent.csproj", "{AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Core", "__Libraries\StellaOps.Zastava.Core\StellaOps.Zastava.Core.csproj", "{DA7634C2-9156-9B79-7A1D-90D8E605DC8A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Core.Tests", "__Tests\StellaOps.Zastava.Core.Tests\StellaOps.Zastava.Core.Tests.csproj", "{9AF9FFAF-DD68-DC74-1FB6-C63BE479F136}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Observer", "StellaOps.Zastava.Observer\StellaOps.Zastava.Observer.csproj", "{4F839682-8912-4BEB-8F70-D6E1333694EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Observer.Tests", "__Tests\StellaOps.Zastava.Observer.Tests\StellaOps.Zastava.Observer.Tests.csproj", "{07853E17-1FB9-E258-2939-D89B37DCF588}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Webhook", "StellaOps.Zastava.Webhook\StellaOps.Zastava.Webhook.csproj", "{2810366C-138B-1227-5FDB-E353A38674B7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Zastava.Webhook.Tests", "__Tests\StellaOps.Zastava.Webhook.Tests\StellaOps.Zastava.Webhook.Tests.csproj", "{F13DBBD1-2D97-373D-2F00-C4C12E47665C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD31623A-BC43-52C2-D906-AC1D8784A541}.Release|Any CPU.Build.0 = Release|Any CPU + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214}.Release|Any CPU.Build.0 = Release|Any CPU + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194}.Release|Any CPU.Build.0 = Release|Any CPU + {335E62C0-9E69-A952-680B-753B1B17C6D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {335E62C0-9E69-A952-680B-753B1B17C6D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {335E62C0-9E69-A952-680B-753B1B17C6D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {335E62C0-9E69-A952-680B-753B1B17C6D0}.Release|Any CPU.Build.0 = Release|Any CPU + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA}.Release|Any CPU.Build.0 = Release|Any CPU + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97F94029-5419-6187-5A63-5C8FD9232FAE}.Release|Any CPU.Build.0 = Release|Any CPU + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}.Release|Any CPU.Build.0 = Release|Any CPU + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92C62F7B-8028-6EE1-B71B-F45F459B8E97}.Release|Any CPU.Build.0 = Release|Any CPU + {F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F664A948-E352-5808-E780-77A03F19E93E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F664A948-E352-5808-E780-77A03F19E93E}.Release|Any CPU.Build.0 = Release|Any CPU + {FA83F778-5252-0B80-5555-E69F790322EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA83F778-5252-0B80-5555-E69F790322EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA83F778-5252-0B80-5555-E69F790322EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA83F778-5252-0B80-5555-E69F790322EA}.Release|Any CPU.Build.0 = Release|Any CPU + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C53E0895-879A-D9E6-0A43-24AD17A2F270}.Release|Any CPU.Build.0 = Release|Any CPU + {0AED303F-69E6-238F-EF80-81985080EDB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0AED303F-69E6-238F-EF80-81985080EDB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AED303F-69E6-238F-EF80-81985080EDB7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0AED303F-69E6-238F-EF80-81985080EDB7}.Release|Any CPU.Build.0 = Release|Any CPU + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2904D288-CE64-A565-2C46-C2E85A96A1EE}.Release|Any CPU.Build.0 = Release|Any CPU + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6667CC3-B77F-023E-3A67-05F99E9FF46A}.Release|Any CPU.Build.0 = Release|Any CPU + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A26E2816-F787-F76B-1D6C-E086DD3E19CE}.Release|Any CPU.Build.0 = Release|Any CPU + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877}.Release|Any CPU.Build.0 = Release|Any CPU + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6}.Release|Any CPU.Build.0 = Release|Any CPU + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA}.Release|Any CPU.Build.0 = Release|Any CPU + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1}.Release|Any CPU.Build.0 = Release|Any CPU + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Debug|Any CPU.Build.0 = Debug|Any CPU + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Release|Any CPU.ActiveCfg = Release|Any CPU + {632A1F0D-1BA5-C84B-B716-2BE638A92780}.Release|Any CPU.Build.0 = Release|Any CPU + {F8CF01C2-3B5D-C488-C272-0B793C2321FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8CF01C2-3B5D-C488-C272-0B793C2321FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8CF01C2-3B5D-C488-C272-0B793C2321FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8CF01C2-3B5D-C488-C272-0B793C2321FC}.Release|Any CPU.Build.0 = Release|Any CPU + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66}.Release|Any CPU.Build.0 = Release|Any CPU + {52698305-D6F8-C13C-0882-48FC37726404}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52698305-D6F8-C13C-0882-48FC37726404}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52698305-D6F8-C13C-0882-48FC37726404}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52698305-D6F8-C13C-0882-48FC37726404}.Release|Any CPU.Build.0 = Release|Any CPU + {5567139C-0365-B6A0-5DD0-978A09B9F176}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5567139C-0365-B6A0-5DD0-978A09B9F176}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5567139C-0365-B6A0-5DD0-978A09B9F176}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5567139C-0365-B6A0-5DD0-978A09B9F176}.Release|Any CPU.Build.0 = Release|Any CPU + {256D269B-35EA-F833-2F1D-8E0058908DEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {256D269B-35EA-F833-2F1D-8E0058908DEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {256D269B-35EA-F833-2F1D-8E0058908DEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {256D269B-35EA-F833-2F1D-8E0058908DEE}.Release|Any CPU.Build.0 = Release|Any CPU + {6E9C9582-67FA-2EB1-C6BA-AD4CD326E276}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E9C9582-67FA-2EB1-C6BA-AD4CD326E276}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E9C9582-67FA-2EB1-C6BA-AD4CD326E276}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E9C9582-67FA-2EB1-C6BA-AD4CD326E276}.Release|Any CPU.Build.0 = Release|Any CPU + {A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A79CBC0C-5313-4ECF-A24E-27CE236BCF2C}.Release|Any CPU.Build.0 = Release|Any CPU + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF043113-CCE3-59C1-DF71-9804155F26A8}.Release|Any CPU.Build.0 = Release|Any CPU + {AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558}.Release|Any CPU.Build.0 = Release|Any CPU + {DA7634C2-9156-9B79-7A1D-90D8E605DC8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA7634C2-9156-9B79-7A1D-90D8E605DC8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA7634C2-9156-9B79-7A1D-90D8E605DC8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA7634C2-9156-9B79-7A1D-90D8E605DC8A}.Release|Any CPU.Build.0 = Release|Any CPU + {9AF9FFAF-DD68-DC74-1FB6-C63BE479F136}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AF9FFAF-DD68-DC74-1FB6-C63BE479F136}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AF9FFAF-DD68-DC74-1FB6-C63BE479F136}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AF9FFAF-DD68-DC74-1FB6-C63BE479F136}.Release|Any CPU.Build.0 = Release|Any CPU + {4F839682-8912-4BEB-8F70-D6E1333694EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F839682-8912-4BEB-8F70-D6E1333694EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F839682-8912-4BEB-8F70-D6E1333694EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F839682-8912-4BEB-8F70-D6E1333694EE}.Release|Any CPU.Build.0 = Release|Any CPU + {07853E17-1FB9-E258-2939-D89B37DCF588}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07853E17-1FB9-E258-2939-D89B37DCF588}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07853E17-1FB9-E258-2939-D89B37DCF588}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07853E17-1FB9-E258-2939-D89B37DCF588}.Release|Any CPU.Build.0 = Release|Any CPU + {2810366C-138B-1227-5FDB-E353A38674B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2810366C-138B-1227-5FDB-E353A38674B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2810366C-138B-1227-5FDB-E353A38674B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2810366C-138B-1227-5FDB-E353A38674B7}.Release|Any CPU.Build.0 = Release|Any CPU + {F13DBBD1-2D97-373D-2F00-C4C12E47665C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F13DBBD1-2D97-373D-2F00-C4C12E47665C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F13DBBD1-2D97-373D-2F00-C4C12E47665C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F13DBBD1-2D97-373D-2F00-C4C12E47665C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F310596E-88BB-9E54-885E-21C61971917E} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + {D9492ED1-A812-924B-65E4-F518592B49BB} = {F310596E-88BB-9E54-885E-21C61971917E} + {3823DE1E-2ACE-C956-99E1-00DB786D9E1D} = {D9492ED1-A812-924B-65E4-F518592B49BB} + {C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} = {C1DCEFBD-12A5-EAAE-632E-8EEB9BE491B6} + {F2E6CB0E-DF77-1FAA-582B-62B040DF3848} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + {C494ECBE-DEA5-3576-D2AF-200FF12BC144} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + {7E890DF9-B715-B6DF-2498-FD74DDA87D71} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + {64689413-46D7-8499-68A6-B6367ACBC597} = {A6928CBA-4D4D-AB2B-CA19-FEE6E73ECE70} + {5896C4B3-31D1-1EDD-11D0-C46DB178DC12} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + {D4D193A8-47D7-0B1A-1327-F9C580E7AD07} = {5896C4B3-31D1-1EDD-11D0-C46DB178DC12} + {336213F7-1241-D268-8EA5-1C73F0040714} = {D4D193A8-47D7-0B1A-1327-F9C580E7AD07} + {5693F73D-6707-6F86-65D6-654023205615} = {D4D193A8-47D7-0B1A-1327-F9C580E7AD07} + {593308D7-2453-DC66-4151-E983E4B3F422} = {D4D193A8-47D7-0B1A-1327-F9C580E7AD07} + {7D55A179-3CDB-8D44-C448-F502BF7ECB3D} = {D4D193A8-47D7-0B1A-1327-F9C580E7AD07} + {AD65DDE7-9FEA-7380-8C10-FA165F745354} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + {076B8074-5735-5367-1EEA-CA16A5B8ABD7} = {AD65DDE7-9FEA-7380-8C10-FA165F745354} + {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} = {5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA} + {9C2DD234-FA33-FDB6-86F0-EF9B75A13450} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {79E122F4-2325-3E92-438E-5825A307B594} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {538E2D98-5325-3F54-BE74-EFE5FC1ECBD8} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {66557252-B5C4-664B-D807-07018C627474} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {7203223D-FF02-7BEB-2798-D1639ACC01C4} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {3C69853C-90E3-D889-1960-3B9229882590} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {643E4D4C-BC96-A37F-E0EC-488127F0B127} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {F04B7DBB-77A5-C978-B2DE-8C189A32AA72} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {7C72F22A-20FF-DF5B-9191-6DFD0D497DB2} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {C896CC0A-F5E6-9AA4-C582-E691441F8D32} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {0AA3A418-AB45-CCA4-46D4-EEBFE011FECA} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {225D9926-4AE8-E539-70AD-8698E688F271} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {D6E8E69C-F721-BBCB-8C39-9716D53D72AD} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {589A43FD-8213-E9E3-6CFF-9CBA72D53E98} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {F13BD9B8-30E2-C0F1-F73B-5B5E8B381174} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {772B02B5-6280-E1D4-3E2E-248D0455C2FB} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {8380A20C-A5B8-EE91-1A58-270323688CB9} = {1345DD29-BB3A-FB5F-4B3D-E29F6045A27A} + {E56F19DE-990B-0DFA-84CD-E7D9E3D8E6E3} = {A5C98087-E847-D2C4-2143-20869479839D} + {19A31EDC-D634-74F9-0619-B157C79F6408} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + {7F1A0818-835A-3FBA-597A-A48858B41EF8} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + {3BDF66FB-66EE-50BC-E0AB-BF1D040118F6} = {BB76B5A5-14BA-E317-828D-110B711D71F5} + {AD31623A-BC43-52C2-D906-AC1D8784A541} = {3823DE1E-2ACE-C956-99E1-00DB786D9E1D} + {55D9B653-FB76-FCE8-1A3C-67B1BEDEC214} = {F2E6CB0E-DF77-1FAA-582B-62B040DF3848} + {DE5BF139-1E5C-D6EA-4FAA-661EF353A194} = {C494ECBE-DEA5-3576-D2AF-200FF12BC144} + {335E62C0-9E69-A952-680B-753B1B17C6D0} = {9C2DD234-FA33-FDB6-86F0-EF9B75A13450} + {ECA25786-A3A8-92C4-4AA3-D4A73C69FDCA} = {7E890DF9-B715-B6DF-2498-FD74DDA87D71} + {97F94029-5419-6187-5A63-5C8FD9232FAE} = {64689413-46D7-8499-68A6-B6367ACBC597} + {AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60} = {79E122F4-2325-3E92-438E-5825A307B594} + {92C62F7B-8028-6EE1-B71B-F45F459B8E97} = {538E2D98-5325-3F54-BE74-EFE5FC1ECBD8} + {F664A948-E352-5808-E780-77A03F19E93E} = {66557252-B5C4-664B-D807-07018C627474} + {FA83F778-5252-0B80-5555-E69F790322EA} = {7203223D-FF02-7BEB-2798-D1639ACC01C4} + {C53E0895-879A-D9E6-0A43-24AD17A2F270} = {3C69853C-90E3-D889-1960-3B9229882590} + {0AED303F-69E6-238F-EF80-81985080EDB7} = {643E4D4C-BC96-A37F-E0EC-488127F0B127} + {2904D288-CE64-A565-2C46-C2E85A96A1EE} = {6F2CA7F5-3E7C-C61B-94E6-E7DD1227B5B1} + {A6667CC3-B77F-023E-3A67-05F99E9FF46A} = {F04B7DBB-77A5-C978-B2DE-8C189A32AA72} + {A26E2816-F787-F76B-1D6C-E086DD3E19CE} = {7C72F22A-20FF-DF5B-9191-6DFD0D497DB2} + {B3DEC619-67AC-1B5A-4F3E-A1F24C3F6877} = {C896CC0A-F5E6-9AA4-C582-E691441F8D32} + {90DB65B4-8F6E-FB8E-0281-505AD8BC6BA6} = {0AA3A418-AB45-CCA4-46D4-EEBFE011FECA} + {059FBB86-DEE6-8207-3F23-2A1A3EC00DEA} = {225D9926-4AE8-E539-70AD-8698E688F271} + {8BBA3159-C4CC-F685-A28C-7FE6CBD3D2A1} = {D6E8E69C-F721-BBCB-8C39-9716D53D72AD} + {632A1F0D-1BA5-C84B-B716-2BE638A92780} = {589A43FD-8213-E9E3-6CFF-9CBA72D53E98} + {F8CF01C2-3B5D-C488-C272-0B793C2321FC} = {F13BD9B8-30E2-C0F1-F73B-5B5E8B381174} + {38A9EE9B-6FC8-93BC-0D43-2A906E678D66} = {772B02B5-6280-E1D4-3E2E-248D0455C2FB} + {52698305-D6F8-C13C-0882-48FC37726404} = {336213F7-1241-D268-8EA5-1C73F0040714} + {5567139C-0365-B6A0-5DD0-978A09B9F176} = {5693F73D-6707-6F86-65D6-654023205615} + {256D269B-35EA-F833-2F1D-8E0058908DEE} = {593308D7-2453-DC66-4151-E983E4B3F422} + {6E9C9582-67FA-2EB1-C6BA-AD4CD326E276} = {7D55A179-3CDB-8D44-C448-F502BF7ECB3D} + {A79CBC0C-5313-4ECF-A24E-27CE236BCF2C} = {076B8074-5735-5367-1EEA-CA16A5B8ABD7} + {AF043113-CCE3-59C1-DF71-9804155F26A8} = {8380A20C-A5B8-EE91-1A58-270323688CB9} + {AF5F6865-50BE-8D89-4AC6-D5EAF6EBD558} = {91DF1D43-A799-FBAC-9FAB-50805F3B8E95} + {DA7634C2-9156-9B79-7A1D-90D8E605DC8A} = {E56F19DE-990B-0DFA-84CD-E7D9E3D8E6E3} + {9AF9FFAF-DD68-DC74-1FB6-C63BE479F136} = {19A31EDC-D634-74F9-0619-B157C79F6408} + {4F839682-8912-4BEB-8F70-D6E1333694EE} = {077163DD-F675-2418-D9F6-1EE41D4A52F1} + {07853E17-1FB9-E258-2939-D89B37DCF588} = {7F1A0818-835A-3FBA-597A-A48858B41EF8} + {2810366C-138B-1227-5FDB-E353A38674B7} = {D822E254-8BCD-A471-A8EB-B89B793121BE} + {F13DBBD1-2D97-373D-2F00-C4C12E47665C} = {3BDF66FB-66EE-50BC-E0AB-BF1D040118F6} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EA1126A9-86E2-CA2A-5082-D0E689CF785A} + EndGlobalSection +EndGlobal diff --git a/src/Zastava/__Tests/StellaOps.Zastava.Core.Tests/StellaOps.Zastava.Core.Tests.csproj b/src/Zastava/__Tests/StellaOps.Zastava.Core.Tests/StellaOps.Zastava.Core.Tests.csproj index 87db81c23..d3ae822b9 100644 --- a/src/Zastava/__Tests/StellaOps.Zastava.Core.Tests/StellaOps.Zastava.Core.Tests.csproj +++ b/src/Zastava/__Tests/StellaOps.Zastava.Core.Tests/StellaOps.Zastava.Core.Tests.csproj @@ -10,9 +10,9 @@ false - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Zastava/__Tests/StellaOps.Zastava.Observer.Tests/StellaOps.Zastava.Observer.Tests.csproj b/src/Zastava/__Tests/StellaOps.Zastava.Observer.Tests/StellaOps.Zastava.Observer.Tests.csproj index 8a78b6f2f..c1446bef7 100644 --- a/src/Zastava/__Tests/StellaOps.Zastava.Observer.Tests/StellaOps.Zastava.Observer.Tests.csproj +++ b/src/Zastava/__Tests/StellaOps.Zastava.Observer.Tests/StellaOps.Zastava.Observer.Tests.csproj @@ -10,10 +10,10 @@ false - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Zastava/__Tests/StellaOps.Zastava.Webhook.Tests/StellaOps.Zastava.Webhook.Tests.csproj b/src/Zastava/__Tests/StellaOps.Zastava.Webhook.Tests/StellaOps.Zastava.Webhook.Tests.csproj index b473ad62e..8526457fe 100644 --- a/src/Zastava/__Tests/StellaOps.Zastava.Webhook.Tests/StellaOps.Zastava.Webhook.Tests.csproj +++ b/src/Zastava/__Tests/StellaOps.Zastava.Webhook.Tests/StellaOps.Zastava.Webhook.Tests.csproj @@ -10,9 +10,9 @@ false - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/__Analyzers/StellaOps.Determinism.Analyzers.Tests/StellaOps.Determinism.Analyzers.Tests.csproj b/src/__Analyzers/StellaOps.Determinism.Analyzers.Tests/StellaOps.Determinism.Analyzers.Tests.csproj index 415d022c9..53e2e5e98 100644 --- a/src/__Analyzers/StellaOps.Determinism.Analyzers.Tests/StellaOps.Determinism.Analyzers.Tests.csproj +++ b/src/__Analyzers/StellaOps.Determinism.Analyzers.Tests/StellaOps.Determinism.Analyzers.Tests.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/src/__Analyzers/StellaOps.Determinism.Analyzers/StellaOps.Determinism.Analyzers.csproj b/src/__Analyzers/StellaOps.Determinism.Analyzers/StellaOps.Determinism.Analyzers.csproj index 4fa369765..ef9852863 100644 --- a/src/__Analyzers/StellaOps.Determinism.Analyzers/StellaOps.Determinism.Analyzers.csproj +++ b/src/__Analyzers/StellaOps.Determinism.Analyzers/StellaOps.Determinism.Analyzers.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/src/__Libraries/StellaOps.AuditPack/Services/AuditPackExportService.cs b/src/__Libraries/StellaOps.AuditPack/Services/AuditPackExportService.cs new file mode 100644 index 000000000..cd8decead --- /dev/null +++ b/src/__Libraries/StellaOps.AuditPack/Services/AuditPackExportService.cs @@ -0,0 +1,420 @@ +// ----------------------------------------------------------------------------- +// AuditPackExportService.cs +// Sprint: SPRINT_1227_0005_0003_FE_copy_audit_export +// Task: T5 — Backend export service for audit packs +// ----------------------------------------------------------------------------- + +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using StellaOps.AuditPack.Models; + +namespace StellaOps.AuditPack.Services; + +/// +/// Service for exporting audit packs in various formats. +/// Supports ZIP bundle, JSON, and DSSE envelope formats. +/// +public sealed class AuditPackExportService : IAuditPackExportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private readonly IAuditBundleWriter _bundleWriter; + private readonly IAuditPackRepository? _repository; + + public AuditPackExportService( + IAuditBundleWriter bundleWriter, + IAuditPackRepository? repository = null) + { + _bundleWriter = bundleWriter; + _repository = repository; + } + + /// + /// Exports an audit pack based on the provided configuration. + /// + public async Task ExportAsync( + ExportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + return request.Format switch + { + ExportFormat.Zip => await ExportAsZipAsync(request, cancellationToken), + ExportFormat.Json => await ExportAsJsonAsync(request, cancellationToken), + ExportFormat.Dsse => await ExportAsDsseAsync(request, cancellationToken), + _ => ExportResult.Failed($"Unsupported export format: {request.Format}") + }; + } + + /// + /// Exports as a ZIP bundle containing all evidence segments. + /// + private async Task ExportAsZipAsync( + ExportRequest request, + CancellationToken ct) + { + using var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + // Create manifest + var manifest = CreateManifest(request); + await AddJsonToZipAsync(archive, "manifest.json", manifest, ct); + + // Add selected segments + foreach (var segment in request.Segments) + { + var segmentData = await GetSegmentDataAsync(request.ScanId, segment, ct); + if (segmentData is not null) + { + var path = GetSegmentPath(segment); + await AddBytesToZipAsync(archive, path, segmentData); + } + } + + // Add attestations if requested + if (request.IncludeAttestations) + { + var attestations = await GetAttestationsAsync(request.ScanId, ct); + if (attestations.Count > 0) + { + await AddJsonToZipAsync(archive, "attestations/attestations.json", attestations, ct); + } + } + + // Add proof chain if requested + if (request.IncludeProofChain) + { + var proofChain = await GetProofChainAsync(request.ScanId, ct); + if (proofChain is not null) + { + await AddJsonToZipAsync(archive, "proof/proof-chain.json", proofChain, ct); + } + } + } + + memoryStream.Position = 0; + var bytes = memoryStream.ToArray(); + + return new ExportResult + { + Success = true, + Data = bytes, + ContentType = "application/zip", + Filename = $"{request.Filename}.zip", + SizeBytes = bytes.Length + }; + } + + /// + /// Exports as a single JSON document. + /// + private async Task ExportAsJsonAsync( + ExportRequest request, + CancellationToken ct) + { + var exportDoc = new Dictionary + { + ["exportedAt"] = DateTimeOffset.UtcNow.ToString("O"), + ["scanId"] = request.ScanId, + ["format"] = "json", + ["version"] = "1.0" + }; + + // Add segments + var segments = new Dictionary(); + foreach (var segment in request.Segments) + { + var segmentData = await GetSegmentDataAsync(request.ScanId, segment, ct); + if (segmentData is not null) + { + try + { + var json = JsonDocument.Parse(segmentData); + segments[segment.ToString().ToLowerInvariant()] = json.RootElement; + } + catch + { + segments[segment.ToString().ToLowerInvariant()] = Convert.ToBase64String(segmentData); + } + } + } + exportDoc["segments"] = segments; + + // Add attestations + if (request.IncludeAttestations) + { + var attestations = await GetAttestationsAsync(request.ScanId, ct); + exportDoc["attestations"] = attestations; + } + + // Add proof chain + if (request.IncludeProofChain) + { + var proofChain = await GetProofChainAsync(request.ScanId, ct); + if (proofChain is not null) + { + exportDoc["proofChain"] = proofChain; + } + } + + var json = JsonSerializer.SerializeToUtf8Bytes(exportDoc, JsonOptions); + + return new ExportResult + { + Success = true, + Data = json, + ContentType = "application/json", + Filename = $"{request.Filename}.json", + SizeBytes = json.Length + }; + } + + /// + /// Exports as a DSSE envelope with signature. + /// + private async Task ExportAsDsseAsync( + ExportRequest request, + CancellationToken ct) + { + // First create the JSON payload + var jsonResult = await ExportAsJsonAsync(request, ct); + if (!jsonResult.Success) + { + return jsonResult; + } + + // Create DSSE envelope structure + var payload = Convert.ToBase64String(jsonResult.Data!); + var envelope = new DsseExportEnvelope + { + PayloadType = "application/vnd.stellaops.audit-pack+json", + Payload = payload, + Signatures = [] // Would be populated by actual signing in production + }; + + var envelopeBytes = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions); + + return new ExportResult + { + Success = true, + Data = envelopeBytes, + ContentType = "application/vnd.dsse+json", + Filename = $"{request.Filename}.dsse.json", + SizeBytes = envelopeBytes.Length + }; + } + + private static ExportManifest CreateManifest(ExportRequest request) + { + return new ExportManifest + { + ExportedAt = DateTimeOffset.UtcNow, + ScanId = request.ScanId, + FindingIds = request.FindingIds, + Format = request.Format.ToString(), + Segments = [.. request.Segments.Select(s => s.ToString())], + IncludesAttestations = request.IncludeAttestations, + IncludesProofChain = request.IncludeProofChain, + Version = "1.0" + }; + } + + private static string GetSegmentPath(ExportSegment segment) + { + return segment switch + { + ExportSegment.Sbom => "sbom/sbom.json", + ExportSegment.Match => "match/vulnerability-match.json", + ExportSegment.Reachability => "reachability/reachability-analysis.json", + ExportSegment.Guards => "guards/guard-analysis.json", + ExportSegment.Runtime => "runtime/runtime-signals.json", + ExportSegment.Policy => "policy/policy-evaluation.json", + _ => $"segments/{segment.ToString().ToLowerInvariant()}.json" + }; + } + + private async Task GetSegmentDataAsync( + string scanId, + ExportSegment segment, + CancellationToken ct) + { + if (_repository is null) + { + // Return mock data for testing + return CreateMockSegmentData(segment); + } + + return await _repository.GetSegmentDataAsync(scanId, segment, ct); + } + + private async Task> GetAttestationsAsync(string scanId, CancellationToken ct) + { + if (_repository is null) + { + return []; + } + + var attestations = await _repository.GetAttestationsAsync(scanId, ct); + return [.. attestations]; + } + + private async Task GetProofChainAsync(string scanId, CancellationToken ct) + { + if (_repository is null) + { + return null; + } + + return await _repository.GetProofChainAsync(scanId, ct); + } + + private static byte[] CreateMockSegmentData(ExportSegment segment) + { + var mockData = new Dictionary + { + ["segment"] = segment.ToString(), + ["generatedAt"] = DateTimeOffset.UtcNow.ToString("O"), + ["data"] = new { placeholder = true } + }; + return JsonSerializer.SerializeToUtf8Bytes(mockData, JsonOptions); + } + + private static async Task AddJsonToZipAsync( + ZipArchive archive, + string path, + T data, + CancellationToken ct) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + await using var stream = entry.Open(); + await JsonSerializer.SerializeAsync(stream, data, JsonOptions, ct); + } + + private static async Task AddBytesToZipAsync( + ZipArchive archive, + string path, + byte[] data) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + await using var stream = entry.Open(); + await stream.WriteAsync(data); + } +} + +/// +/// Interface for audit pack export service. +/// +public interface IAuditPackExportService +{ + Task ExportAsync(ExportRequest request, CancellationToken cancellationToken = default); +} + +/// +/// Repository interface for accessing audit pack data. +/// +public interface IAuditPackRepository +{ + Task GetSegmentDataAsync(string scanId, ExportSegment segment, CancellationToken ct); + Task> GetAttestationsAsync(string scanId, CancellationToken ct); + Task GetProofChainAsync(string scanId, CancellationToken ct); +} + +#region Models + +/// +/// Export format options. +/// +public enum ExportFormat +{ + Zip, + Json, + Dsse +} + +/// +/// Evidence segment types for export. +/// +public enum ExportSegment +{ + Sbom, + Match, + Reachability, + Guards, + Runtime, + Policy +} + +/// +/// Request for audit pack export. +/// +public sealed record ExportRequest +{ + public required string ScanId { get; init; } + public IReadOnlyList? FindingIds { get; init; } + public required ExportFormat Format { get; init; } + public required IReadOnlyList Segments { get; init; } + public bool IncludeAttestations { get; init; } + public bool IncludeProofChain { get; init; } + public required string Filename { get; init; } +} + +/// +/// Result of audit pack export. +/// +public sealed record ExportResult +{ + public bool Success { get; init; } + public byte[]? Data { get; init; } + public string? ContentType { get; init; } + public string? Filename { get; init; } + public long SizeBytes { get; init; } + public string? Error { get; init; } + + public static ExportResult Failed(string error) => new() + { + Success = false, + Error = error + }; +} + +/// +/// Export manifest included in ZIP bundles. +/// +public sealed record ExportManifest +{ + public DateTimeOffset ExportedAt { get; init; } + public required string ScanId { get; init; } + public IReadOnlyList? FindingIds { get; init; } + public required string Format { get; init; } + public required IReadOnlyList Segments { get; init; } + public bool IncludesAttestations { get; init; } + public bool IncludesProofChain { get; init; } + public required string Version { get; init; } +} + +/// +/// DSSE envelope for export. +/// +public sealed record DsseExportEnvelope +{ + public required string PayloadType { get; init; } + public required string Payload { get; init; } + public required IReadOnlyList Signatures { get; init; } +} + +/// +/// DSSE signature entry. +/// +public sealed record DsseSignature +{ + public required string KeyId { get; init; } + public required string Sig { get; init; } +} + +#endregion diff --git a/src/__Libraries/StellaOps.AuditPack/Services/ReplayAttestationService.cs b/src/__Libraries/StellaOps.AuditPack/Services/ReplayAttestationService.cs new file mode 100644 index 000000000..1ae825e2e --- /dev/null +++ b/src/__Libraries/StellaOps.AuditPack/Services/ReplayAttestationService.cs @@ -0,0 +1,420 @@ +// ----------------------------------------------------------------------------- +// ReplayAttestationService.cs +// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay +// Task: T7 — Replay attestation generation with DSSE signing +// ----------------------------------------------------------------------------- + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using StellaOps.AuditPack.Models; + +namespace StellaOps.AuditPack.Services; + +/// +/// Service for generating DSSE-signed attestations for replay executions. +/// Produces in-toto v1 statements with verdict replay predicates. +/// +public sealed class ReplayAttestationService : IReplayAttestationService +{ + private const string InTotoStatementType = "https://in-toto.io/Statement/v1"; + private const string VerdictReplayPredicateType = "https://stellaops.io/attestation/verdict-replay/v1"; + private const string DssePayloadType = "application/vnd.in-toto+json"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly IReplayAttestationSigner? _signer; + + public ReplayAttestationService(IReplayAttestationSigner? signer = null) + { + _signer = signer; + } + + /// + /// Generates a DSSE attestation for a replay execution result. + /// + public async Task GenerateAsync( + AuditBundleManifest manifest, + ReplayExecutionResult replayResult, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(replayResult); + + // Build the in-toto statement + var statement = CreateInTotoStatement(manifest, replayResult); + + // Serialize to canonical JSON + var statementBytes = JsonSerializer.SerializeToUtf8Bytes(statement, JsonOptions); + var statementDigest = ComputeSha256Digest(statementBytes); + + // Create DSSE envelope + var envelope = await CreateDsseEnvelopeAsync(statementBytes, cancellationToken); + + return new ReplayAttestation + { + AttestationId = Guid.NewGuid().ToString("N"), + ManifestId = manifest.BundleId, + CreatedAt = DateTimeOffset.UtcNow, + Statement = statement, + StatementDigest = statementDigest, + Envelope = envelope, + Match = replayResult.VerdictMatches && replayResult.DecisionMatches, + ReplayStatus = replayResult.Status.ToString() + }; + } + + /// + /// Verifies a replay attestation's integrity. + /// + public Task VerifyAsync( + ReplayAttestation attestation, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(attestation); + + var errors = new List(); + + // Verify statement digest + var statementBytes = JsonSerializer.SerializeToUtf8Bytes(attestation.Statement, JsonOptions); + var computedDigest = ComputeSha256Digest(statementBytes); + + if (computedDigest != attestation.StatementDigest) + { + errors.Add($"Statement digest mismatch: expected {attestation.StatementDigest}, got {computedDigest}"); + } + + // Verify envelope payload matches statement + if (attestation.Envelope is not null) + { + try + { + var payloadBytes = Convert.FromBase64String(attestation.Envelope.Payload); + var payloadDigest = ComputeSha256Digest(payloadBytes); + + if (payloadDigest != computedDigest) + { + errors.Add("Envelope payload digest does not match statement"); + } + } + catch (FormatException) + { + errors.Add("Invalid base64 in envelope payload"); + } + } + + // Verify signatures if signer is available + var signatureValid = attestation.Envelope?.Signatures.Count > 0; + + return Task.FromResult(new AttestationVerificationResult + { + IsValid = errors.Count == 0, + Errors = [.. errors], + SignatureVerified = signatureValid, + VerifiedAt = DateTimeOffset.UtcNow + }); + } + + /// + /// Generates a batch of attestations for multiple replay results. + /// + public async Task> GenerateBatchAsync( + IEnumerable<(AuditBundleManifest Manifest, ReplayExecutionResult Result)> replays, + CancellationToken cancellationToken = default) + { + var attestations = new List(); + + foreach (var (manifest, result) in replays) + { + cancellationToken.ThrowIfCancellationRequested(); + var attestation = await GenerateAsync(manifest, result, cancellationToken); + attestations.Add(attestation); + } + + return attestations; + } + + private InTotoStatement CreateInTotoStatement( + AuditBundleManifest manifest, + ReplayExecutionResult replayResult) + { + return new InTotoStatement + { + Type = InTotoStatementType, + Subject = + [ + new InTotoSubject + { + Name = $"verdict:{manifest.BundleId}", + Digest = new Dictionary + { + ["sha256"] = manifest.VerdictDigest.Replace("sha256:", "") + } + } + ], + PredicateType = VerdictReplayPredicateType, + Predicate = new VerdictReplayAttestation + { + ManifestId = manifest.BundleId, + ScanId = manifest.ScanId, + ImageRef = manifest.ImageRef, + ImageDigest = manifest.ImageDigest, + InputsDigest = ComputeInputsDigest(manifest.Inputs), + OriginalVerdictDigest = manifest.VerdictDigest, + ReplayedVerdictDigest = replayResult.ReplayedVerdictDigest, + OriginalDecision = manifest.Decision, + ReplayedDecision = replayResult.ReplayedDecision, + Match = replayResult.VerdictMatches && replayResult.DecisionMatches, + Status = replayResult.Status.ToString(), + DriftCount = replayResult.Drifts.Count, + Drifts = replayResult.Drifts.Select(d => new DriftAttestation + { + Type = d.Type.ToString(), + Field = d.Field, + Message = d.Message + }).ToList(), + EvaluatedAt = replayResult.EvaluatedAt, + ReplayedAt = DateTimeOffset.UtcNow, + DurationMs = replayResult.DurationMs + } + }; + } + + private async Task CreateDsseEnvelopeAsync( + byte[] payload, + CancellationToken cancellationToken) + { + var payloadBase64 = Convert.ToBase64String(payload); + var signatures = new List(); + + if (_signer is not null) + { + var signature = await _signer.SignAsync(payload, cancellationToken); + signatures.Add(new ReplayDsseSignature + { + KeyId = signature.KeyId, + Sig = signature.Signature + }); + } + + return new ReplayDsseEnvelope + { + PayloadType = DssePayloadType, + Payload = payloadBase64, + Signatures = signatures + }; + } + + private static string ComputeSha256Digest(byte[] data) + { + var hash = SHA256.HashData(data); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static string ComputeInputsDigest(InputDigests inputs) + { + var combined = $"{inputs.SbomDigest}|{inputs.FeedsDigest}|{inputs.PolicyDigest}|{inputs.VexDigest}"; + var bytes = Encoding.UTF8.GetBytes(combined); + var hash = SHA256.HashData(bytes); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } +} + +/// +/// Interface for replay attestation generation. +/// +public interface IReplayAttestationService +{ + Task GenerateAsync( + AuditBundleManifest manifest, + ReplayExecutionResult replayResult, + CancellationToken cancellationToken = default); + + Task VerifyAsync( + ReplayAttestation attestation, + CancellationToken cancellationToken = default); + + Task> GenerateBatchAsync( + IEnumerable<(AuditBundleManifest Manifest, ReplayExecutionResult Result)> replays, + CancellationToken cancellationToken = default); +} + +/// +/// Interface for signing replay attestations. +/// +public interface IReplayAttestationSigner +{ + Task SignAsync(byte[] payload, CancellationToken cancellationToken = default); +} + +#region Models + +/// +/// Generated replay attestation. +/// +public sealed record ReplayAttestation +{ + public required string AttestationId { get; init; } + public required string ManifestId { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required InTotoStatement Statement { get; init; } + public required string StatementDigest { get; init; } + public ReplayDsseEnvelope? Envelope { get; init; } + public bool Match { get; init; } + public required string ReplayStatus { get; init; } +} + +/// +/// In-toto v1 statement structure. +/// +public sealed record InTotoStatement +{ + [JsonPropertyName("_type")] + public required string Type { get; init; } + + [JsonPropertyName("subject")] + public required IReadOnlyList Subject { get; init; } + + [JsonPropertyName("predicateType")] + public required string PredicateType { get; init; } + + [JsonPropertyName("predicate")] + public required VerdictReplayAttestation Predicate { get; init; } +} + +/// +/// In-toto subject with name and digest. +/// +public sealed record InTotoSubject +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("digest")] + public required IReadOnlyDictionary Digest { get; init; } +} + +/// +/// Verdict replay predicate for attestation. +/// +public sealed record VerdictReplayAttestation +{ + [JsonPropertyName("manifestId")] + public required string ManifestId { get; init; } + + [JsonPropertyName("scanId")] + public required string ScanId { get; init; } + + [JsonPropertyName("imageRef")] + public required string ImageRef { get; init; } + + [JsonPropertyName("imageDigest")] + public required string ImageDigest { get; init; } + + [JsonPropertyName("inputsDigest")] + public required string InputsDigest { get; init; } + + [JsonPropertyName("originalVerdictDigest")] + public required string OriginalVerdictDigest { get; init; } + + [JsonPropertyName("replayedVerdictDigest")] + public string? ReplayedVerdictDigest { get; init; } + + [JsonPropertyName("originalDecision")] + public required string OriginalDecision { get; init; } + + [JsonPropertyName("replayedDecision")] + public string? ReplayedDecision { get; init; } + + [JsonPropertyName("match")] + public bool Match { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("driftCount")] + public int DriftCount { get; init; } + + [JsonPropertyName("drifts")] + public IReadOnlyList? Drifts { get; init; } + + [JsonPropertyName("evaluatedAt")] + public DateTimeOffset EvaluatedAt { get; init; } + + [JsonPropertyName("replayedAt")] + public DateTimeOffset ReplayedAt { get; init; } + + [JsonPropertyName("durationMs")] + public long DurationMs { get; init; } +} + +/// +/// Drift item in attestation. +/// +public sealed record DriftAttestation +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("field")] + public string? Field { get; init; } + + [JsonPropertyName("message")] + public string? Message { get; init; } +} + +/// +/// DSSE envelope for replay attestation. +/// +public sealed record ReplayDsseEnvelope +{ + [JsonPropertyName("payloadType")] + public required string PayloadType { get; init; } + + [JsonPropertyName("payload")] + public required string Payload { get; init; } + + [JsonPropertyName("signatures")] + public required IReadOnlyList Signatures { get; init; } +} + +/// +/// DSSE signature entry. +/// +public sealed record ReplayDsseSignature +{ + [JsonPropertyName("keyid")] + public required string KeyId { get; init; } + + [JsonPropertyName("sig")] + public required string Sig { get; init; } +} + +/// +/// Result of signing operation. +/// +public sealed record SignatureResult +{ + public required string KeyId { get; init; } + public required string Signature { get; init; } + public string? Algorithm { get; init; } +} + +/// +/// Result of attestation verification. +/// +public sealed record AttestationVerificationResult +{ + public bool IsValid { get; init; } + public IReadOnlyList Errors { get; init; } = []; + public bool SignatureVerified { get; init; } + public DateTimeOffset VerifiedAt { get; init; } +} + +#endregion diff --git a/src/__Libraries/StellaOps.AuditPack/Services/ReplayTelemetry.cs b/src/__Libraries/StellaOps.AuditPack/Services/ReplayTelemetry.cs new file mode 100644 index 000000000..4e88c6213 --- /dev/null +++ b/src/__Libraries/StellaOps.AuditPack/Services/ReplayTelemetry.cs @@ -0,0 +1,399 @@ +// ----------------------------------------------------------------------------- +// ReplayTelemetry.cs +// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay +// Task: T10 — Telemetry for replay outcomes +// ----------------------------------------------------------------------------- + +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace StellaOps.AuditPack.Services; + +/// +/// OpenTelemetry instrumentation for verdict replay operations. +/// Provides metrics, traces, and structured logging support. +/// +public sealed class ReplayTelemetry : IDisposable +{ + /// + /// Service name for telemetry identification. + /// + public const string ServiceName = "StellaOps.Replay"; + + /// + /// Meter name for replay metrics. + /// + public const string MeterName = "StellaOps.Replay"; + + /// + /// Activity source name for replay tracing. + /// + public const string ActivitySourceName = "StellaOps.Replay"; + + private readonly Meter _meter; + + // Counters + private readonly Counter _replayExecutionsTotal; + private readonly Counter _replayMatchesTotal; + private readonly Counter _replayDivergencesTotal; + private readonly Counter _replayErrorsTotal; + private readonly Counter _attestationsGeneratedTotal; + private readonly Counter _attestationsVerifiedTotal; + private readonly Counter _eligibilityChecksTotal; + + // Histograms + private readonly Histogram _replayDurationMs; + private readonly Histogram _attestationGenerationDurationMs; + private readonly Histogram _driftCount; + private readonly Histogram _confidenceScore; + + // Gauges + private readonly UpDownCounter _replaysInProgress; + + /// + /// Activity source for distributed tracing. + /// + public static readonly ActivitySource ActivitySource = new(ActivitySourceName); + + /// + /// Initializes a new instance of the ReplayTelemetry class. + /// + public ReplayTelemetry(IMeterFactory? meterFactory = null) + { + _meter = meterFactory?.Create(MeterName) ?? new Meter(MeterName); + + // Counters + _replayExecutionsTotal = _meter.CreateCounter( + "stellaops.replay.executions.total", + unit: "{execution}", + description: "Total number of replay executions"); + + _replayMatchesTotal = _meter.CreateCounter( + "stellaops.replay.matches.total", + unit: "{match}", + description: "Total number of replay matches (verdict unchanged)"); + + _replayDivergencesTotal = _meter.CreateCounter( + "stellaops.replay.divergences.total", + unit: "{divergence}", + description: "Total number of replay divergences detected"); + + _replayErrorsTotal = _meter.CreateCounter( + "stellaops.replay.errors.total", + unit: "{error}", + description: "Total number of replay errors"); + + _attestationsGeneratedTotal = _meter.CreateCounter( + "stellaops.replay.attestations.generated.total", + unit: "{attestation}", + description: "Total number of replay attestations generated"); + + _attestationsVerifiedTotal = _meter.CreateCounter( + "stellaops.replay.attestations.verified.total", + unit: "{verification}", + description: "Total number of replay attestations verified"); + + _eligibilityChecksTotal = _meter.CreateCounter( + "stellaops.replay.eligibility.checks.total", + unit: "{check}", + description: "Total number of replay eligibility checks"); + + // Histograms + _replayDurationMs = _meter.CreateHistogram( + "stellaops.replay.duration.ms", + unit: "ms", + description: "Replay execution duration in milliseconds"); + + _attestationGenerationDurationMs = _meter.CreateHistogram( + "stellaops.replay.attestation.generation.duration.ms", + unit: "ms", + description: "Attestation generation duration in milliseconds"); + + _driftCount = _meter.CreateHistogram( + "stellaops.replay.drift.count", + unit: "{drift}", + description: "Number of drifts detected per replay"); + + _confidenceScore = _meter.CreateHistogram( + "stellaops.replay.eligibility.confidence", + unit: "1", + description: "Replay eligibility confidence score distribution"); + + // Gauges + _replaysInProgress = _meter.CreateUpDownCounter( + "stellaops.replay.in_progress", + unit: "{replay}", + description: "Number of replays currently in progress"); + } + + #region Replay Execution Metrics + + /// + /// Records the start of a replay execution. + /// + public void RecordReplayStarted(string manifestId, string scanId) + { + _replaysInProgress.Add(1, new TagList + { + { ReplayTelemetryTags.ManifestId, manifestId }, + { ReplayTelemetryTags.ScanId, scanId } + }); + } + + /// + /// Records the completion of a replay execution. + /// + public void RecordReplayCompleted( + string manifestId, + string scanId, + ReplayOutcome outcome, + int driftCount, + TimeSpan duration) + { + var tags = new TagList + { + { ReplayTelemetryTags.ManifestId, manifestId }, + { ReplayTelemetryTags.ScanId, scanId }, + { ReplayTelemetryTags.Outcome, outcome.ToString().ToLowerInvariant() } + }; + + _replaysInProgress.Add(-1, new TagList + { + { ReplayTelemetryTags.ManifestId, manifestId }, + { ReplayTelemetryTags.ScanId, scanId } + }); + + _replayExecutionsTotal.Add(1, tags); + _replayDurationMs.Record(duration.TotalMilliseconds, tags); + + switch (outcome) + { + case ReplayOutcome.Match: + _replayMatchesTotal.Add(1, tags); + break; + case ReplayOutcome.Divergence: + _replayDivergencesTotal.Add(1, tags); + _driftCount.Record(driftCount, tags); + break; + case ReplayOutcome.Error: + _replayErrorsTotal.Add(1, tags); + break; + } + } + + /// + /// Records a replay error. + /// + public void RecordReplayError( + string manifestId, + string scanId, + string errorCode) + { + var tags = new TagList + { + { ReplayTelemetryTags.ManifestId, manifestId }, + { ReplayTelemetryTags.ScanId, scanId }, + { ReplayTelemetryTags.ErrorCode, errorCode } + }; + + _replaysInProgress.Add(-1, new TagList + { + { ReplayTelemetryTags.ManifestId, manifestId }, + { ReplayTelemetryTags.ScanId, scanId } + }); + + _replayErrorsTotal.Add(1, tags); + } + + #endregion + + #region Attestation Metrics + + /// + /// Records attestation generation. + /// + public void RecordAttestationGenerated( + string manifestId, + bool match, + TimeSpan duration) + { + var tags = new TagList + { + { ReplayTelemetryTags.ManifestId, manifestId }, + { ReplayTelemetryTags.Match, match.ToString().ToLowerInvariant() } + }; + + _attestationsGeneratedTotal.Add(1, tags); + _attestationGenerationDurationMs.Record(duration.TotalMilliseconds, tags); + } + + /// + /// Records attestation verification. + /// + public void RecordAttestationVerified( + string attestationId, + bool valid) + { + var tags = new TagList + { + { ReplayTelemetryTags.AttestationId, attestationId }, + { ReplayTelemetryTags.Valid, valid.ToString().ToLowerInvariant() } + }; + + _attestationsVerifiedTotal.Add(1, tags); + } + + #endregion + + #region Eligibility Metrics + + /// + /// Records an eligibility check. + /// + public void RecordEligibilityCheck( + string manifestId, + bool eligible, + double confidenceScore) + { + var tags = new TagList + { + { ReplayTelemetryTags.ManifestId, manifestId }, + { ReplayTelemetryTags.Eligible, eligible.ToString().ToLowerInvariant() } + }; + + _eligibilityChecksTotal.Add(1, tags); + _confidenceScore.Record(confidenceScore, tags); + } + + #endregion + + #region Activity Helpers + + /// + /// Starts an activity for replay execution. + /// + public static Activity? StartReplayActivity(string manifestId, string scanId) + { + var activity = ActivitySource.StartActivity("Replay.Execute"); + activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId); + activity?.SetTag(ReplayTelemetryTags.ScanId, scanId); + return activity; + } + + /// + /// Starts an activity for attestation generation. + /// + public static Activity? StartAttestationActivity(string manifestId) + { + var activity = ActivitySource.StartActivity("Replay.GenerateAttestation"); + activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId); + return activity; + } + + /// + /// Starts an activity for eligibility check. + /// + public static Activity? StartEligibilityActivity(string manifestId) + { + var activity = ActivitySource.StartActivity("Replay.CheckEligibility"); + activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId); + return activity; + } + + /// + /// Starts an activity for divergence detection. + /// + public static Activity? StartDivergenceActivity(string manifestId) + { + var activity = ActivitySource.StartActivity("Replay.DetectDivergence"); + activity?.SetTag(ReplayTelemetryTags.ManifestId, manifestId); + return activity; + } + + #endregion + + /// + public void Dispose() + { + _meter.Dispose(); + } +} + +/// +/// Tag names for replay telemetry. +/// +public static class ReplayTelemetryTags +{ + public const string ManifestId = "manifest_id"; + public const string ScanId = "scan_id"; + public const string BundleId = "bundle_id"; + public const string AttestationId = "attestation_id"; + public const string Outcome = "outcome"; + public const string Match = "match"; + public const string Valid = "valid"; + public const string Eligible = "eligible"; + public const string ErrorCode = "error_code"; + public const string DivergenceType = "divergence_type"; + public const string DriftType = "drift_type"; + public const string Severity = "severity"; +} + +/// +/// Replay outcome values. +/// +public enum ReplayOutcome +{ + /// Verdict matched the original. + Match, + + /// Divergence detected between original and replayed verdict. + Divergence, + + /// Replay execution failed with error. + Error, + + /// Replay was cancelled. + Cancelled +} + +/// +/// Divergence severity levels. +/// +public static class DivergenceSeverities +{ + public const string Critical = "critical"; + public const string High = "high"; + public const string Medium = "medium"; + public const string Low = "low"; + public const string Info = "info"; +} + +/// +/// Divergence type values. +/// +public static class DivergenceTypes +{ + public const string VerdictDigest = "verdict_digest"; + public const string Decision = "decision"; + public const string Confidence = "confidence"; + public const string Input = "input"; + public const string Policy = "policy"; + public const string Evidence = "evidence"; +} + +/// +/// Extension methods for adding replay telemetry. +/// +public static class ReplayTelemetryExtensions +{ + /// + /// Adds replay OpenTelemetry instrumentation. + /// + public static IServiceCollection AddReplayTelemetry(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } +} diff --git a/src/__Libraries/StellaOps.AuditPack/Services/VerdictReplayPredicate.cs b/src/__Libraries/StellaOps.AuditPack/Services/VerdictReplayPredicate.cs new file mode 100644 index 000000000..524964e6d --- /dev/null +++ b/src/__Libraries/StellaOps.AuditPack/Services/VerdictReplayPredicate.cs @@ -0,0 +1,502 @@ +// ----------------------------------------------------------------------------- +// VerdictReplayPredicate.cs +// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay +// Task: T4 — Verdict replay predicate for determining replay eligibility +// ----------------------------------------------------------------------------- + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using StellaOps.AuditPack.Models; + +namespace StellaOps.AuditPack.Services; + +/// +/// Evaluates whether a verdict is eligible for replay and +/// determines expected outcomes based on input analysis. +/// +public sealed class VerdictReplayPredicate : IVerdictReplayPredicate +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + /// + /// Evaluates whether a verdict can be replayed given the current inputs. + /// + public ReplayEligibility Evaluate( + AuditBundleManifest manifest, + ReplayInputState? currentInputState = null) + { + ArgumentNullException.ThrowIfNull(manifest); + + var reasons = new List(); + var warnings = new List(); + + // Check 1: Manifest must have required fields + if (string.IsNullOrEmpty(manifest.VerdictDigest)) + { + reasons.Add("Manifest is missing verdict digest"); + } + + if (manifest.Inputs is null) + { + reasons.Add("Manifest is missing input digests"); + } + + // Check 2: Time anchor must be present for deterministic replay + if (manifest.TimeAnchor is null) + { + warnings.Add("No time anchor - replay may produce different results due to time-sensitive data"); + } + + // Check 3: Verify replay support version + if (!string.IsNullOrEmpty(manifest.ReplayVersion)) + { + if (!IsSupportedReplayVersion(manifest.ReplayVersion)) + { + reasons.Add($"Unsupported replay version: {manifest.ReplayVersion}"); + } + } + + // Check 4: Compare against current input state if provided + if (currentInputState is not null && manifest.Inputs is not null) + { + var inputDivergence = DetectInputDivergence(manifest.Inputs, currentInputState); + if (inputDivergence.HasDivergence) + { + warnings.AddRange(inputDivergence.Warnings); + if (inputDivergence.IsCritical) + { + reasons.AddRange(inputDivergence.CriticalReasons); + } + } + } + + // Check 5: Verify policy bundle compatibility + if (!string.IsNullOrEmpty(manifest.PolicyVersion)) + { + var policyCheck = CheckPolicyCompatibility(manifest.PolicyVersion); + if (!policyCheck.IsCompatible) + { + reasons.Add(policyCheck.Reason!); + } + } + + var isEligible = reasons.Count == 0; + + return new ReplayEligibility + { + IsEligible = isEligible, + Reasons = [.. reasons], + Warnings = [.. warnings], + ExpectedOutcome = isEligible + ? PredictOutcome(manifest, currentInputState) + : null, + ConfidenceScore = isEligible + ? ComputeConfidence(manifest, currentInputState, warnings) + : 0 + }; + } + + /// + /// Predicts the expected outcome of a replay based on input analysis. + /// + public ReplayOutcomePrediction PredictOutcome( + AuditBundleManifest manifest, + ReplayInputState? currentInputState) + { + // Default to expecting a match if inputs haven't changed + if (currentInputState is null) + { + return new ReplayOutcomePrediction + { + ExpectedStatus = ReplayStatus.Match, + Confidence = 0.5, + ExpectedDecision = manifest.Decision, + Rationale = "Input state unknown - assuming match" + }; + } + + // Analyze input differences + var divergence = DetectInputDivergence(manifest.Inputs!, currentInputState); + + if (!divergence.HasDivergence) + { + return new ReplayOutcomePrediction + { + ExpectedStatus = ReplayStatus.Match, + Confidence = 0.95, + ExpectedDecision = manifest.Decision, + Rationale = "All inputs match - expecting identical verdict" + }; + } + + // Predict based on divergence type + if (divergence.FeedsChanged) + { + // Feeds changes most likely to cause verdict changes + return new ReplayOutcomePrediction + { + ExpectedStatus = ReplayStatus.Drift, + Confidence = 0.7, + ExpectedDecision = null, // Unknown - depends on new advisories + Rationale = "Vulnerability feeds have changed - verdict may differ", + ExpectedDriftTypes = [DriftType.VerdictField, DriftType.Decision] + }; + } + + if (divergence.PolicyChanged) + { + return new ReplayOutcomePrediction + { + ExpectedStatus = ReplayStatus.Drift, + Confidence = 0.6, + ExpectedDecision = null, + Rationale = "Policy rules have changed - decision may differ", + ExpectedDriftTypes = [DriftType.Decision] + }; + } + + if (divergence.VexChanged) + { + return new ReplayOutcomePrediction + { + ExpectedStatus = ReplayStatus.Drift, + Confidence = 0.5, + ExpectedDecision = manifest.Decision, // VEX typically doesn't change decision + Rationale = "VEX statements have changed - some findings may differ", + ExpectedDriftTypes = [DriftType.VerdictField] + }; + } + + // SBOM changes are typically stable + return new ReplayOutcomePrediction + { + ExpectedStatus = ReplayStatus.Match, + Confidence = 0.8, + ExpectedDecision = manifest.Decision, + Rationale = "Minor input differences - expecting similar verdict" + }; + } + + /// + /// Compares two replay execution results and detects divergences. + /// + public ReplayDivergenceReport CompareDivergence( + ReplayExecutionResult original, + ReplayExecutionResult replayed) + { + ArgumentNullException.ThrowIfNull(original); + ArgumentNullException.ThrowIfNull(replayed); + + var divergences = new List(); + + // Compare decisions + if (original.OriginalDecision != replayed.ReplayedDecision) + { + divergences.Add(new DivergenceItem + { + Category = DivergenceCategory.Decision, + Field = "decision", + OriginalValue = original.OriginalDecision, + ReplayedValue = replayed.ReplayedDecision, + Severity = DivergenceSeverity.High, + Explanation = "Policy decision changed between evaluations" + }); + } + + // Compare verdict digests + if (original.OriginalVerdictDigest != replayed.ReplayedVerdictDigest) + { + divergences.Add(new DivergenceItem + { + Category = DivergenceCategory.VerdictHash, + Field = "verdictDigest", + OriginalValue = original.OriginalVerdictDigest, + ReplayedValue = replayed.ReplayedVerdictDigest, + Severity = DivergenceSeverity.Medium, + Explanation = "Verdict content differs (may include new findings or different field values)" + }); + } + + // Include drift items from replay + foreach (var drift in replayed.Drifts) + { + var severity = drift.Type switch + { + DriftType.Decision => DivergenceSeverity.High, + DriftType.VerdictDigest => DivergenceSeverity.Medium, + DriftType.InputDigest => DivergenceSeverity.Low, + _ => DivergenceSeverity.Low + }; + + divergences.Add(new DivergenceItem + { + Category = MapDriftTypeToCategory(drift.Type), + Field = drift.Field ?? "unknown", + OriginalValue = drift.Expected, + ReplayedValue = drift.Actual, + Severity = severity, + Explanation = drift.Message ?? "Value mismatch detected" + }); + } + + return new ReplayDivergenceReport + { + HasDivergence = divergences.Count > 0, + Divergences = [.. divergences], + OverallSeverity = divergences.Count == 0 + ? DivergenceSeverity.None + : divergences.Max(d => d.Severity), + Summary = GenerateDivergenceSummary(divergences) + }; + } + + private static bool IsSupportedReplayVersion(string version) + { + // Support replay format versions 1.0 through 2.x + return version.StartsWith("1.") || version.StartsWith("2."); + } + + private static InputDivergenceResult DetectInputDivergence( + InputDigests expected, + ReplayInputState current) + { + var warnings = new List(); + var criticalReasons = new List(); + var hasDivergence = false; + + bool feedsChanged = false, policyChanged = false, vexChanged = false; + + if (current.FeedsDigest is not null && current.FeedsDigest != expected.FeedsDigest) + { + warnings.Add("Vulnerability feeds have been updated since original evaluation"); + hasDivergence = true; + feedsChanged = true; + } + + if (current.PolicyDigest is not null && current.PolicyDigest != expected.PolicyDigest) + { + warnings.Add("Policy bundle has changed since original evaluation"); + hasDivergence = true; + policyChanged = true; + } + + if (current.VexDigest is not null && current.VexDigest != expected.VexDigest) + { + warnings.Add("VEX statements have been updated since original evaluation"); + hasDivergence = true; + vexChanged = true; + } + + if (current.SbomDigest is not null && current.SbomDigest != expected.SbomDigest) + { + criticalReasons.Add("SBOM differs from original - this is a different artifact"); + hasDivergence = true; + } + + return new InputDivergenceResult + { + HasDivergence = hasDivergence, + IsCritical = criticalReasons.Count > 0, + Warnings = warnings, + CriticalReasons = criticalReasons, + FeedsChanged = feedsChanged, + PolicyChanged = policyChanged, + VexChanged = vexChanged + }; + } + + private static PolicyCompatibility CheckPolicyCompatibility(string policyVersion) + { + // For now, accept all policy versions + // In production, this would check against the policy engine capabilities + return new PolicyCompatibility { IsCompatible = true }; + } + + private static double ComputeConfidence( + AuditBundleManifest manifest, + ReplayInputState? currentInputState, + List warnings) + { + var confidence = 1.0; + + // Reduce confidence for each warning + confidence -= warnings.Count * 0.1; + + // Reduce confidence if no time anchor + if (manifest.TimeAnchor is null) + { + confidence -= 0.15; + } + + // Reduce confidence if input state is unknown + if (currentInputState is null) + { + confidence -= 0.2; + } + + return Math.Max(0.1, confidence); + } + + private static DivergenceCategory MapDriftTypeToCategory(DriftType driftType) + { + return driftType switch + { + DriftType.Decision => DivergenceCategory.Decision, + DriftType.VerdictDigest => DivergenceCategory.VerdictHash, + DriftType.VerdictField => DivergenceCategory.VerdictField, + DriftType.InputDigest => DivergenceCategory.Input, + _ => DivergenceCategory.Other + }; + } + + private static string GenerateDivergenceSummary(List divergences) + { + if (divergences.Count == 0) + { + return "Replay matched original verdict exactly."; + } + + var hasDecisionChange = divergences.Any(d => d.Category == DivergenceCategory.Decision); + var hasVerdictChange = divergences.Any(d => d.Category == DivergenceCategory.VerdictHash); + var hasInputChange = divergences.Any(d => d.Category == DivergenceCategory.Input); + + if (hasDecisionChange) + { + return "Replay produced a different policy decision."; + } + + if (hasVerdictChange) + { + return "Replay verdict differs in content but decision is the same."; + } + + if (hasInputChange) + { + return "Input digests differ but verdict is unchanged."; + } + + return $"Replay detected {divergences.Count} divergence(s)."; + } +} + +/// +/// Interface for verdict replay predicate. +/// +public interface IVerdictReplayPredicate +{ + ReplayEligibility Evaluate(AuditBundleManifest manifest, ReplayInputState? currentInputState = null); + ReplayOutcomePrediction PredictOutcome(AuditBundleManifest manifest, ReplayInputState? currentInputState); + ReplayDivergenceReport CompareDivergence(ReplayExecutionResult original, ReplayExecutionResult replayed); +} + +#region Models + +/// +/// Result of evaluating replay eligibility. +/// +public sealed record ReplayEligibility +{ + public bool IsEligible { get; init; } + public IReadOnlyList Reasons { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; + public ReplayOutcomePrediction? ExpectedOutcome { get; init; } + public double ConfidenceScore { get; init; } +} + +/// +/// Prediction of replay outcome. +/// +public sealed record ReplayOutcomePrediction +{ + public ReplayStatus ExpectedStatus { get; init; } + public double Confidence { get; init; } + public string? ExpectedDecision { get; init; } + public string? Rationale { get; init; } + public IReadOnlyList? ExpectedDriftTypes { get; init; } +} + +/// +/// Current state of replay inputs for comparison. +/// +public sealed record ReplayInputState +{ + public string? SbomDigest { get; init; } + public string? FeedsDigest { get; init; } + public string? PolicyDigest { get; init; } + public string? VexDigest { get; init; } +} + +/// +/// Report of divergences between original and replayed evaluations. +/// +public sealed record ReplayDivergenceReport +{ + public bool HasDivergence { get; init; } + public IReadOnlyList Divergences { get; init; } = []; + public DivergenceSeverity OverallSeverity { get; init; } + public string? Summary { get; init; } +} + +/// +/// Individual divergence item. +/// +public sealed record DivergenceItem +{ + public DivergenceCategory Category { get; init; } + public required string Field { get; init; } + public string? OriginalValue { get; init; } + public string? ReplayedValue { get; init; } + public DivergenceSeverity Severity { get; init; } + public string? Explanation { get; init; } +} + +/// +/// Category of divergence. +/// +public enum DivergenceCategory +{ + Decision, + VerdictHash, + VerdictField, + Input, + Other +} + +/// +/// Severity of divergence. +/// +public enum DivergenceSeverity +{ + None, + Low, + Medium, + High +} + +/// +/// Result of input divergence detection. +/// +internal sealed record InputDivergenceResult +{ + public bool HasDivergence { get; init; } + public bool IsCritical { get; init; } + public List Warnings { get; init; } = []; + public List CriticalReasons { get; init; } = []; + public bool FeedsChanged { get; init; } + public bool PolicyChanged { get; init; } + public bool VexChanged { get; init; } +} + +/// +/// Result of policy compatibility check. +/// +internal sealed record PolicyCompatibility +{ + public bool IsCompatible { get; init; } + public string? Reason { get; init; } +} + +#endregion diff --git a/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj b/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj index 5a78514c2..4df33f1fb 100644 --- a/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj +++ b/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj @@ -28,8 +28,8 @@ - - + + diff --git a/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj b/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj index 64bf4398e..acf6c8393 100644 --- a/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj +++ b/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj @@ -8,11 +8,11 @@ - + - - - + + + diff --git a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj index 9ade5af40..10cc9bf43 100644 --- a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj +++ b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj b/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj index 979f6dea0..159c7a5ef 100644 --- a/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj @@ -5,8 +5,8 @@ enable - - + + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj index f983c6028..67781c53a 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj index 82f15fb9c..f3ddd19e5 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj @@ -12,9 +12,9 @@ - - - + + + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.EIDAS/StellaOps.Cryptography.Plugin.EIDAS.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.EIDAS/StellaOps.Cryptography.Plugin.EIDAS.csproj index 862116944..b1862ebd6 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.EIDAS/StellaOps.Cryptography.Plugin.EIDAS.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.EIDAS/StellaOps.Cryptography.Plugin.EIDAS.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote.Tests/StellaOps.Cryptography.Plugin.SmRemote.Tests.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote.Tests/StellaOps.Cryptography.Plugin.SmRemote.Tests.csproj index 349e46e2e..b2626f8a5 100644 --- a/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote.Tests/StellaOps.Cryptography.Plugin.SmRemote.Tests.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Plugin.SmRemote.Tests/StellaOps.Cryptography.Plugin.SmRemote.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/__Libraries/StellaOps.Cryptography.PluginLoader.Tests/StellaOps.Cryptography.PluginLoader.Tests.csproj b/src/__Libraries/StellaOps.Cryptography.PluginLoader.Tests/StellaOps.Cryptography.PluginLoader.Tests.csproj index bbd8bbc4e..aa8d141cf 100644 --- a/src/__Libraries/StellaOps.Cryptography.PluginLoader.Tests/StellaOps.Cryptography.PluginLoader.Tests.csproj +++ b/src/__Libraries/StellaOps.Cryptography.PluginLoader.Tests/StellaOps.Cryptography.PluginLoader.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/__Libraries/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj b/src/__Libraries/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj index 98cb5f4dd..1be0d770c 100644 --- a/src/__Libraries/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj +++ b/src/__Libraries/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj @@ -9,10 +9,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj b/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj index 25dfbc09a..cf00ecd6d 100644 --- a/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj +++ b/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.csproj b/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.csproj index 2d9506d19..f3a7d6b03 100644 --- a/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.csproj +++ b/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.csproj @@ -14,9 +14,9 @@ - - - + + + diff --git a/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj b/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj index 003eae593..147fcb50f 100644 --- a/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj +++ b/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/__Libraries/StellaOps.Infrastructure.EfCore/StellaOps.Infrastructure.EfCore.csproj b/src/__Libraries/StellaOps.Infrastructure.EfCore/StellaOps.Infrastructure.EfCore.csproj index 4e9879b3a..c283a300d 100644 --- a/src/__Libraries/StellaOps.Infrastructure.EfCore/StellaOps.Infrastructure.EfCore.csproj +++ b/src/__Libraries/StellaOps.Infrastructure.EfCore/StellaOps.Infrastructure.EfCore.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj b/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj index 3e8e9e490..359673687 100644 --- a/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj +++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj @@ -20,7 +20,7 @@ - + diff --git a/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj b/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj index 6416f8c14..73f1b7021 100644 --- a/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj +++ b/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj @@ -7,8 +7,8 @@ false - - + + diff --git a/src/__Libraries/StellaOps.Messaging.Transport.Postgres/StellaOps.Messaging.Transport.Postgres.csproj b/src/__Libraries/StellaOps.Messaging.Transport.Postgres/StellaOps.Messaging.Transport.Postgres.csproj index 62950b5c1..e40816780 100644 --- a/src/__Libraries/StellaOps.Messaging.Transport.Postgres/StellaOps.Messaging.Transport.Postgres.csproj +++ b/src/__Libraries/StellaOps.Messaging.Transport.Postgres/StellaOps.Messaging.Transport.Postgres.csproj @@ -13,15 +13,15 @@ - + - - + + diff --git a/src/__Libraries/StellaOps.Messaging.Transport.Valkey/StellaOps.Messaging.Transport.Valkey.csproj b/src/__Libraries/StellaOps.Messaging.Transport.Valkey/StellaOps.Messaging.Transport.Valkey.csproj index 069f5e690..2c32dda6e 100644 --- a/src/__Libraries/StellaOps.Messaging.Transport.Valkey/StellaOps.Messaging.Transport.Valkey.csproj +++ b/src/__Libraries/StellaOps.Messaging.Transport.Valkey/StellaOps.Messaging.Transport.Valkey.csproj @@ -19,8 +19,8 @@ - - + + diff --git a/src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.csproj b/src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.csproj index b1e449030..6aa1a1b2b 100644 --- a/src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.csproj +++ b/src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.csproj @@ -7,6 +7,6 @@ - + diff --git a/src/__Libraries/StellaOps.Microservice.SourceGen/StellaOps.Microservice.SourceGen.csproj b/src/__Libraries/StellaOps.Microservice.SourceGen/StellaOps.Microservice.SourceGen.csproj index cc9246039..f84e77f67 100644 --- a/src/__Libraries/StellaOps.Microservice.SourceGen/StellaOps.Microservice.SourceGen.csproj +++ b/src/__Libraries/StellaOps.Microservice.SourceGen/StellaOps.Microservice.SourceGen.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj b/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj index a6fe26ed6..1725ec144 100644 --- a/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj +++ b/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj @@ -11,8 +11,8 @@ - - + + diff --git a/src/__Libraries/StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj b/src/__Libraries/StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj index 2f8b3f392..d8a33fc7a 100644 --- a/src/__Libraries/StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj +++ b/src/__Libraries/StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/__Libraries/StellaOps.Provcache.Postgres/StellaOps.Provcache.Postgres.csproj b/src/__Libraries/StellaOps.Provcache.Postgres/StellaOps.Provcache.Postgres.csproj index c8b22fc22..a0ae2d62d 100644 --- a/src/__Libraries/StellaOps.Provcache.Postgres/StellaOps.Provcache.Postgres.csproj +++ b/src/__Libraries/StellaOps.Provcache.Postgres/StellaOps.Provcache.Postgres.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/__Libraries/StellaOps.Provcache.Valkey/StellaOps.Provcache.Valkey.csproj b/src/__Libraries/StellaOps.Provcache.Valkey/StellaOps.Provcache.Valkey.csproj index 54095c6ea..a9fe2d53a 100644 --- a/src/__Libraries/StellaOps.Provcache.Valkey/StellaOps.Provcache.Valkey.csproj +++ b/src/__Libraries/StellaOps.Provcache.Valkey/StellaOps.Provcache.Valkey.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj b/src/__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj index 247346ff2..b38a78784 100644 --- a/src/__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj +++ b/src/__Libraries/StellaOps.Provcache/StellaOps.Provcache.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/__Libraries/StellaOps.ReachGraph.Cache/IReachGraphCache.cs b/src/__Libraries/StellaOps.ReachGraph.Cache/IReachGraphCache.cs new file mode 100644 index 000000000..0e095c5fe --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Cache/IReachGraphCache.cs @@ -0,0 +1,59 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using StellaOps.ReachGraph.Schema; + +namespace StellaOps.ReachGraph.Cache; + +/// +/// Cache interface for reachability graphs and slices. +/// +public interface IReachGraphCache +{ + /// + /// Get a cached graph by its digest. + /// + Task GetAsync( + string digest, + CancellationToken cancellationToken = default); + + /// + /// Cache a graph with optional TTL. + /// + Task SetAsync( + string digest, + ReachGraphMinimal graph, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default); + + /// + /// Get a cached slice by digest and slice key. + /// + Task GetSliceAsync( + string digest, + string sliceKey, + CancellationToken cancellationToken = default); + + /// + /// Cache a slice with optional TTL. + /// + Task SetSliceAsync( + string digest, + string sliceKey, + byte[] slice, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default); + + /// + /// Invalidate all cached data for a digest. + /// + Task InvalidateAsync( + string digest, + CancellationToken cancellationToken = default); + + /// + /// Check if a graph is cached. + /// + Task ExistsAsync( + string digest, + CancellationToken cancellationToken = default); +} diff --git a/src/__Libraries/StellaOps.ReachGraph.Cache/ReachGraphCacheOptions.cs b/src/__Libraries/StellaOps.ReachGraph.Cache/ReachGraphCacheOptions.cs new file mode 100644 index 000000000..b76ef6dfa --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Cache/ReachGraphCacheOptions.cs @@ -0,0 +1,40 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +namespace StellaOps.ReachGraph.Cache; + +/// +/// Configuration options for the ReachGraph cache. +/// +public sealed class ReachGraphCacheOptions +{ + /// + /// Default TTL for cached full graphs. + /// + public TimeSpan DefaultTtl { get; init; } = TimeSpan.FromHours(24); + + /// + /// TTL for cached slices (shorter as they're query-dependent). + /// + public TimeSpan SliceTtl { get; init; } = TimeSpan.FromMinutes(30); + + /// + /// Maximum size in bytes for caching a graph. + /// Graphs larger than this are not cached. + /// + public int MaxGraphSizeBytes { get; init; } = 10 * 1024 * 1024; // 10 MB + + /// + /// Whether to compress data before caching. + /// + public bool CompressInCache { get; init; } = true; + + /// + /// Key prefix for namespacing. + /// + public string KeyPrefix { get; init; } = "reachgraph"; + + /// + /// Valkey/Redis database number to use. + /// + public int Database { get; init; } = 0; +} diff --git a/src/__Libraries/StellaOps.ReachGraph.Cache/ReachGraphValkeyCache.cs b/src/__Libraries/StellaOps.ReachGraph.Cache/ReachGraphValkeyCache.cs new file mode 100644 index 000000000..1bbf5d79a --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Cache/ReachGraphValkeyCache.cs @@ -0,0 +1,261 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.IO.Compression; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; +using StackExchange.Redis; + +namespace StellaOps.ReachGraph.Cache; + +/// +/// Valkey/Redis-based cache implementation for reachability graphs. +/// +/// Key patterns: +/// {prefix}:{tenant}:{digest} - Full graph (compressed JSON) +/// {prefix}:{tenant}:{digest}:slice:{hash} - Slice cache (compressed JSON) +/// +public sealed class ReachGraphValkeyCache : IReachGraphCache +{ + private readonly IConnectionMultiplexer _redis; + private readonly CanonicalReachGraphSerializer _serializer; + private readonly ReachGraphCacheOptions _options; + private readonly ILogger _logger; + private readonly string _tenantId; + + public ReachGraphValkeyCache( + IConnectionMultiplexer redis, + CanonicalReachGraphSerializer serializer, + IOptions options, + ILogger logger, + string tenantId) + { + _redis = redis ?? throw new ArgumentNullException(nameof(redis)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + } + + /// + public async Task GetAsync( + string digest, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + + var key = BuildGraphKey(digest); + var db = _redis.GetDatabase(_options.Database); + + try + { + var value = await db.StringGetAsync(key); + if (value.IsNullOrEmpty) + { + _logger.LogDebug("Cache miss for graph {Digest}", digest); + return null; + } + + _logger.LogDebug("Cache hit for graph {Digest}", digest); + + var bytes = (byte[])value!; + var json = _options.CompressInCache ? DecompressGzip(bytes) : bytes; + return _serializer.Deserialize(json); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get graph {Digest} from cache", digest); + return null; + } + } + + /// + public async Task SetAsync( + string digest, + ReachGraphMinimal graph, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + ArgumentNullException.ThrowIfNull(graph); + + var json = _serializer.SerializeMinimal(graph); + + if (json.Length > _options.MaxGraphSizeBytes) + { + _logger.LogDebug( + "Graph {Digest} exceeds max cache size ({Size} > {Max}), skipping cache", + digest, json.Length, _options.MaxGraphSizeBytes); + return; + } + + var key = BuildGraphKey(digest); + var value = _options.CompressInCache ? CompressGzip(json) : json; + var expiry = ttl ?? _options.DefaultTtl; + + var db = _redis.GetDatabase(_options.Database); + + try + { + await db.StringSetAsync(key, value, expiry); + _logger.LogDebug( + "Cached graph {Digest} ({Size} bytes, TTL: {Ttl})", + digest, value.Length, expiry); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache graph {Digest}", digest); + } + } + + /// + public async Task GetSliceAsync( + string digest, + string sliceKey, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + ArgumentException.ThrowIfNullOrEmpty(sliceKey); + + var key = BuildSliceKey(digest, sliceKey); + var db = _redis.GetDatabase(_options.Database); + + try + { + var value = await db.StringGetAsync(key); + if (value.IsNullOrEmpty) + { + _logger.LogDebug("Cache miss for slice {Digest}:{SliceKey}", digest, sliceKey); + return null; + } + + _logger.LogDebug("Cache hit for slice {Digest}:{SliceKey}", digest, sliceKey); + + var bytes = (byte[])value!; + return _options.CompressInCache ? DecompressGzip(bytes) : bytes; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get slice {Digest}:{SliceKey} from cache", digest, sliceKey); + return null; + } + } + + /// + public async Task SetSliceAsync( + string digest, + string sliceKey, + byte[] slice, + TimeSpan? ttl = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + ArgumentException.ThrowIfNullOrEmpty(sliceKey); + ArgumentNullException.ThrowIfNull(slice); + + var key = BuildSliceKey(digest, sliceKey); + var value = _options.CompressInCache ? CompressGzip(slice) : slice; + var expiry = ttl ?? _options.SliceTtl; + + var db = _redis.GetDatabase(_options.Database); + + try + { + await db.StringSetAsync(key, value, expiry); + _logger.LogDebug( + "Cached slice {Digest}:{SliceKey} ({Size} bytes, TTL: {Ttl})", + digest, sliceKey, value.Length, expiry); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache slice {Digest}:{SliceKey}", digest, sliceKey); + } + } + + /// + public async Task InvalidateAsync( + string digest, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + + var db = _redis.GetDatabase(_options.Database); + var server = _redis.GetServers().FirstOrDefault(); + + if (server is null) + { + _logger.LogWarning("No Redis server available for key enumeration"); + return; + } + + try + { + // Delete the main graph key + var graphKey = BuildGraphKey(digest); + await db.KeyDeleteAsync(graphKey); + + // Delete all slice keys for this graph + var slicePattern = $"{_options.KeyPrefix}:{_tenantId}:{digest}:slice:*"; + var sliceKeys = server.Keys(_options.Database, slicePattern).ToArray(); + + if (sliceKeys.Length > 0) + { + await db.KeyDeleteAsync(sliceKeys); + _logger.LogDebug("Invalidated {Count} slice keys for graph {Digest}", sliceKeys.Length, digest); + } + + _logger.LogInformation("Invalidated cache for graph {Digest}", digest); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to invalidate cache for graph {Digest}", digest); + } + } + + /// + public async Task ExistsAsync( + string digest, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + + var key = BuildGraphKey(digest); + var db = _redis.GetDatabase(_options.Database); + + try + { + return await db.KeyExistsAsync(key); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check existence for graph {Digest}", digest); + return false; + } + } + + private string BuildGraphKey(string digest) => + $"{_options.KeyPrefix}:{_tenantId}:{digest}"; + + private string BuildSliceKey(string digest, string sliceKey) => + $"{_options.KeyPrefix}:{_tenantId}:{digest}:slice:{sliceKey}"; + + private static byte[] CompressGzip(byte[] data) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + gzip.Write(data); + } + return output.ToArray(); + } + + private static byte[] DecompressGzip(byte[] compressed) + { + using var input = new MemoryStream(compressed); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } +} diff --git a/src/__Libraries/StellaOps.ReachGraph.Cache/StellaOps.ReachGraph.Cache.csproj b/src/__Libraries/StellaOps.ReachGraph.Cache/StellaOps.ReachGraph.Cache.csproj new file mode 100644 index 000000000..d429bac2c --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Cache/StellaOps.ReachGraph.Cache.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + preview + StellaOps.ReachGraph.Cache + Valkey/Redis cache layer for StellaOps ReachGraph + StellaOps + StellaOps.ReachGraph.Cache + + + + + + + + + + + + + + + + + diff --git a/src/__Libraries/StellaOps.ReachGraph.Persistence/IReachGraphRepository.cs b/src/__Libraries/StellaOps.ReachGraph.Persistence/IReachGraphRepository.cs new file mode 100644 index 000000000..b440a6467 --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Persistence/IReachGraphRepository.cs @@ -0,0 +1,135 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using StellaOps.ReachGraph.Schema; + +namespace StellaOps.ReachGraph.Persistence; + +/// +/// Repository for persisting and retrieving reachability graphs. +/// +public interface IReachGraphRepository +{ + /// + /// Store a reachability graph. Idempotent by digest. + /// + /// The graph to store. + /// The tenant identifier. + /// Cancellation token. + /// Result indicating if newly created or already existed. + Task StoreAsync( + ReachGraphMinimal graph, + string tenantId, + CancellationToken cancellationToken = default); + + /// + /// Retrieve a graph by its digest. + /// + /// The BLAKE3 digest. + /// The tenant identifier. + /// Cancellation token. + /// The graph if found, null otherwise. + Task GetByDigestAsync( + string digest, + string tenantId, + CancellationToken cancellationToken = default); + + /// + /// List graphs for an artifact. + /// + /// The artifact digest. + /// The tenant identifier. + /// Maximum number of results. + /// Cancellation token. + /// List of graph summaries. + Task> ListByArtifactAsync( + string artifactDigest, + string tenantId, + int limit = 50, + CancellationToken cancellationToken = default); + + /// + /// Find graphs containing a specific CVE. + /// + /// The CVE identifier. + /// The tenant identifier. + /// Maximum number of results. + /// Cancellation token. + /// List of graph summaries. + Task> FindByCveAsync( + string cveId, + string tenantId, + int limit = 50, + CancellationToken cancellationToken = default); + + /// + /// Delete a graph by digest (soft delete for audit trail). + /// + /// The BLAKE3 digest. + /// The tenant identifier. + /// Cancellation token. + /// True if deleted, false if not found. + Task DeleteAsync( + string digest, + string tenantId, + CancellationToken cancellationToken = default); + + /// + /// Record a replay verification result. + /// + /// The replay log entry. + /// Cancellation token. + Task RecordReplayAsync( + ReplayLogEntry entry, + CancellationToken cancellationToken = default); +} + +/// +/// Result of storing a graph. +/// +public sealed record StoreResult +{ + public required string Digest { get; init; } + public required bool Created { get; init; } + public required string ArtifactDigest { get; init; } + public required int NodeCount { get; init; } + public required int EdgeCount { get; init; } + public required DateTimeOffset StoredAt { get; init; } +} + +/// +/// Summary of a stored graph (without full blob). +/// +public sealed record ReachGraphSummary +{ + public required string Digest { get; init; } + public required string ArtifactDigest { get; init; } + public required int NodeCount { get; init; } + public required int EdgeCount { get; init; } + public required int BlobSizeBytes { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required ReachGraphScope Scope { get; init; } +} + +/// +/// Entry in the replay verification log. +/// +public sealed record ReplayLogEntry +{ + public required string SubgraphDigest { get; init; } + public required ReachGraphInputs InputDigests { get; init; } + public required string ComputedDigest { get; init; } + public required bool Matches { get; init; } + public required string TenantId { get; init; } + public required int DurationMs { get; init; } + public ReplayDivergence? Divergence { get; init; } +} + +/// +/// Describes divergence when replay doesn't match. +/// +public sealed record ReplayDivergence +{ + public required int NodesAdded { get; init; } + public required int NodesRemoved { get; init; } + public required int EdgesChanged { get; init; } +} diff --git a/src/__Libraries/StellaOps.ReachGraph.Persistence/Migrations/001_reachgraph_store.sql b/src/__Libraries/StellaOps.ReachGraph.Persistence/Migrations/001_reachgraph_store.sql new file mode 100644 index 000000000..117d714aa --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Persistence/Migrations/001_reachgraph_store.sql @@ -0,0 +1,141 @@ +-- ReachGraph Store Schema +-- Content-addressed storage for reachability subgraphs +-- Version: 1.0.0 +-- Created: 2025-12-27 + +BEGIN; + +-- Create schema if not exists +CREATE SCHEMA IF NOT EXISTS reachgraph; + +-- Main subgraph storage +-- Stores compressed reachgraph.min.json with content-addressed digest +CREATE TABLE reachgraph.subgraphs ( + digest TEXT PRIMARY KEY, -- BLAKE3 of canonical JSON + artifact_digest TEXT NOT NULL, -- Image/artifact this applies to + tenant_id TEXT NOT NULL, -- Tenant isolation + scope JSONB NOT NULL, -- {entrypoints, selectors, cves} + node_count INTEGER NOT NULL, + edge_count INTEGER NOT NULL, + blob BYTEA NOT NULL, -- Compressed reachgraph.min.json (gzip) + blob_size_bytes INTEGER NOT NULL, + provenance JSONB NOT NULL, -- {intoto, inputs, computedAt, analyzer} + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT chk_digest_format CHECK (digest ~ '^blake3:[a-f0-9]{64}$'), + CONSTRAINT chk_artifact_digest_format CHECK (artifact_digest ~ '^(sha256|sha512):[a-f0-9]+$'), + CONSTRAINT chk_node_count_positive CHECK (node_count >= 0), + CONSTRAINT chk_edge_count_positive CHECK (edge_count >= 0), + CONSTRAINT chk_blob_size_positive CHECK (blob_size_bytes > 0) +); + +-- Composite index for tenant + artifact lookup (most common query pattern) +CREATE INDEX idx_subgraphs_tenant_artifact + ON reachgraph.subgraphs (tenant_id, artifact_digest, created_at DESC); + +-- Index for fast artifact lookup across tenants (admin queries) +CREATE INDEX idx_subgraphs_artifact + ON reachgraph.subgraphs (artifact_digest, created_at DESC); + +-- Index for CVE-based queries using GIN on scope->'cves' +CREATE INDEX idx_subgraphs_cves + ON reachgraph.subgraphs USING GIN ((scope->'cves') jsonb_path_ops); + +-- Index for entrypoint-based queries +CREATE INDEX idx_subgraphs_entrypoints + ON reachgraph.subgraphs USING GIN ((scope->'entrypoints') jsonb_path_ops); + +-- Index for provenance input lookup (find graphs by source SBOM/VEX/callgraph) +CREATE INDEX idx_subgraphs_provenance_inputs + ON reachgraph.subgraphs USING GIN ((provenance->'inputs') jsonb_path_ops); + +-- Slice cache (precomputed slices for hot queries) +CREATE TABLE reachgraph.slice_cache ( + cache_key TEXT PRIMARY KEY, -- {digest}:{queryType}:{queryHash} + subgraph_digest TEXT NOT NULL REFERENCES reachgraph.subgraphs(digest) ON DELETE CASCADE, + slice_blob BYTEA NOT NULL, -- Compressed slice JSON + query_type TEXT NOT NULL, -- 'package', 'cve', 'entrypoint', 'file' + query_params JSONB NOT NULL, -- Original query parameters + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, -- TTL for cache expiration + hit_count INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT chk_query_type CHECK (query_type IN ('package', 'cve', 'entrypoint', 'file')) +); + +-- Index for cache expiry cleanup +CREATE INDEX idx_slice_cache_expiry + ON reachgraph.slice_cache (expires_at); + +-- Index for cache lookup by subgraph +CREATE INDEX idx_slice_cache_subgraph + ON reachgraph.slice_cache (subgraph_digest, created_at DESC); + +-- Audit log for replay verification +CREATE TABLE reachgraph.replay_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subgraph_digest TEXT NOT NULL, + input_digests JSONB NOT NULL, -- {sbom, vex, callgraph, runtimeFacts} + computed_digest TEXT NOT NULL, -- Result of replay + matches BOOLEAN NOT NULL, -- Did it match expected digest? + divergence JSONB, -- {nodesAdded, nodesRemoved, edgesChanged} if mismatch + tenant_id TEXT NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + duration_ms INTEGER NOT NULL, + + CONSTRAINT chk_duration_positive CHECK (duration_ms >= 0) +); + +-- Index for replay log lookups +CREATE INDEX idx_replay_log_digest + ON reachgraph.replay_log (subgraph_digest, computed_at DESC); + +-- Index for replay log by tenant +CREATE INDEX idx_replay_log_tenant + ON reachgraph.replay_log (tenant_id, computed_at DESC); + +-- Index for finding replay failures +CREATE INDEX idx_replay_log_failures + ON reachgraph.replay_log (matches, computed_at DESC) + WHERE matches = false; + +-- Enable Row Level Security +ALTER TABLE reachgraph.subgraphs ENABLE ROW LEVEL SECURITY; +ALTER TABLE reachgraph.slice_cache ENABLE ROW LEVEL SECURITY; +ALTER TABLE reachgraph.replay_log ENABLE ROW LEVEL SECURITY; + +-- RLS policies (tenant isolation) +-- Note: current_setting('app.tenant_id', true) returns NULL if not set, which blocks all access +CREATE POLICY tenant_isolation_subgraphs ON reachgraph.subgraphs + FOR ALL + USING (tenant_id = current_setting('app.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); + +-- Slice cache inherits tenant from parent subgraph (via foreign key) +CREATE POLICY tenant_isolation_slice_cache ON reachgraph.slice_cache + FOR ALL + USING ( + subgraph_digest IN ( + SELECT digest FROM reachgraph.subgraphs + WHERE tenant_id = current_setting('app.tenant_id', true) + ) + ); + +CREATE POLICY tenant_isolation_replay_log ON reachgraph.replay_log + FOR ALL + USING (tenant_id = current_setting('app.tenant_id', true)) + WITH CHECK (tenant_id = current_setting('app.tenant_id', true)); + +-- Comments for documentation +COMMENT ON SCHEMA reachgraph IS 'ReachGraph store schema for content-addressed reachability subgraphs'; +COMMENT ON TABLE reachgraph.subgraphs IS 'Content-addressed storage for reachability subgraphs with DSSE signing support'; +COMMENT ON TABLE reachgraph.slice_cache IS 'Cache for precomputed subgraph slices (package/CVE/entrypoint/file queries)'; +COMMENT ON TABLE reachgraph.replay_log IS 'Audit log for deterministic replay verification'; + +COMMENT ON COLUMN reachgraph.subgraphs.digest IS 'BLAKE3-256 hash of canonical JSON (format: blake3:{hex})'; +COMMENT ON COLUMN reachgraph.subgraphs.artifact_digest IS 'Container image or artifact digest this graph applies to'; +COMMENT ON COLUMN reachgraph.subgraphs.blob IS 'Gzip-compressed reachgraph.min.json'; +COMMENT ON COLUMN reachgraph.subgraphs.scope IS 'Analysis scope: entrypoints, selectors, and optional CVE filters'; +COMMENT ON COLUMN reachgraph.subgraphs.provenance IS 'Provenance info: intoto links, input digests, analyzer metadata'; + +COMMIT; diff --git a/src/__Libraries/StellaOps.ReachGraph.Persistence/PostgresReachGraphRepository.cs b/src/__Libraries/StellaOps.ReachGraph.Persistence/PostgresReachGraphRepository.cs new file mode 100644 index 000000000..5cc28564a --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Persistence/PostgresReachGraphRepository.cs @@ -0,0 +1,345 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Collections.Immutable; +using System.IO.Compression; +using System.Text.Json; +using Dapper; +using Microsoft.Extensions.Logging; +using Npgsql; +using StellaOps.ReachGraph.Hashing; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; + +namespace StellaOps.ReachGraph.Persistence; + +/// +/// PostgreSQL implementation of the ReachGraph repository. +/// +public sealed class PostgresReachGraphRepository : IReachGraphRepository +{ + private readonly NpgsqlDataSource _dataSource; + private readonly CanonicalReachGraphSerializer _serializer; + private readonly ReachGraphDigestComputer _digestComputer; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public PostgresReachGraphRepository( + NpgsqlDataSource dataSource, + CanonicalReachGraphSerializer serializer, + ReachGraphDigestComputer digestComputer, + ILogger logger) + { + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task StoreAsync( + ReachGraphMinimal graph, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrEmpty(tenantId); + + var digest = _digestComputer.ComputeDigest(graph); + var canonicalBytes = _serializer.SerializeMinimal(graph); + var compressedBlob = CompressGzip(canonicalBytes); + + var scopeJson = JsonSerializer.Serialize(new + { + entrypoints = graph.Scope.Entrypoints, + selectors = graph.Scope.Selectors, + cves = graph.Scope.Cves + }, JsonOptions); + + var provenanceJson = JsonSerializer.Serialize(new + { + intoto = graph.Provenance.Intoto, + inputs = graph.Provenance.Inputs, + computedAt = graph.Provenance.ComputedAt, + analyzer = graph.Provenance.Analyzer + }, JsonOptions); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await SetTenantContext(connection, tenantId, cancellationToken); + + const string sql = """ + INSERT INTO reachgraph.subgraphs ( + digest, artifact_digest, tenant_id, scope, node_count, edge_count, + blob, blob_size_bytes, provenance + ) + VALUES ( + @Digest, @ArtifactDigest, @TenantId, @Scope::jsonb, @NodeCount, @EdgeCount, + @Blob, @BlobSizeBytes, @Provenance::jsonb + ) + ON CONFLICT (digest) DO NOTHING + RETURNING created_at + """; + + var result = await connection.QuerySingleOrDefaultAsync(sql, new + { + Digest = digest, + ArtifactDigest = graph.Artifact.Digest, + TenantId = tenantId, + Scope = scopeJson, + NodeCount = graph.Nodes.Length, + EdgeCount = graph.Edges.Length, + Blob = compressedBlob, + BlobSizeBytes = compressedBlob.Length, + Provenance = provenanceJson + }); + + var created = result.HasValue; + var storedAt = result ?? DateTimeOffset.UtcNow; + + _logger.LogInformation( + "{Action} reachability graph {Digest} for artifact {Artifact}", + created ? "Stored" : "Found existing", + digest, + graph.Artifact.Digest); + + return new StoreResult + { + Digest = digest, + Created = created, + ArtifactDigest = graph.Artifact.Digest, + NodeCount = graph.Nodes.Length, + EdgeCount = graph.Edges.Length, + StoredAt = storedAt + }; + } + + /// + public async Task GetByDigestAsync( + string digest, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + ArgumentException.ThrowIfNullOrEmpty(tenantId); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await SetTenantContext(connection, tenantId, cancellationToken); + + const string sql = """ + SELECT blob + FROM reachgraph.subgraphs + WHERE digest = @Digest + """; + + var blob = await connection.QuerySingleOrDefaultAsync(sql, new { Digest = digest }); + + if (blob is null) + { + return null; + } + + var decompressed = DecompressGzip(blob); + return _serializer.Deserialize(decompressed); + } + + /// + public async Task> ListByArtifactAsync( + string artifactDigest, + string tenantId, + int limit = 50, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(artifactDigest); + ArgumentException.ThrowIfNullOrEmpty(tenantId); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await SetTenantContext(connection, tenantId, cancellationToken); + + const string sql = """ + SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope + FROM reachgraph.subgraphs + WHERE artifact_digest = @ArtifactDigest + ORDER BY created_at DESC + LIMIT @Limit + """; + + var rows = await connection.QueryAsync(sql, new { ArtifactDigest = artifactDigest, Limit = limit }); + + return rows.Select(row => new ReachGraphSummary + { + Digest = row.digest, + ArtifactDigest = row.artifact_digest, + NodeCount = row.node_count, + EdgeCount = row.edge_count, + BlobSizeBytes = row.blob_size_bytes, + CreatedAt = row.created_at, + Scope = ParseScope((string)row.scope) + }).ToList(); + } + + /// + public async Task> FindByCveAsync( + string cveId, + string tenantId, + int limit = 50, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(cveId); + ArgumentException.ThrowIfNullOrEmpty(tenantId); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await SetTenantContext(connection, tenantId, cancellationToken); + + const string sql = """ + SELECT digest, artifact_digest, node_count, edge_count, blob_size_bytes, created_at, scope + FROM reachgraph.subgraphs + WHERE scope->'cves' @> @CveJson::jsonb + ORDER BY created_at DESC + LIMIT @Limit + """; + + var cveJson = JsonSerializer.Serialize(new[] { cveId }); + var rows = await connection.QueryAsync(sql, new { CveJson = cveJson, Limit = limit }); + + return rows.Select(row => new ReachGraphSummary + { + Digest = row.digest, + ArtifactDigest = row.artifact_digest, + NodeCount = row.node_count, + EdgeCount = row.edge_count, + BlobSizeBytes = row.blob_size_bytes, + CreatedAt = row.created_at, + Scope = ParseScope((string)row.scope) + }).ToList(); + } + + /// + public async Task DeleteAsync( + string digest, + string tenantId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(digest); + ArgumentException.ThrowIfNullOrEmpty(tenantId); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await SetTenantContext(connection, tenantId, cancellationToken); + + // Using DELETE for now; could be soft-delete with deleted_at column + const string sql = """ + DELETE FROM reachgraph.subgraphs + WHERE digest = @Digest + RETURNING digest + """; + + var deleted = await connection.QuerySingleOrDefaultAsync(sql, new { Digest = digest }); + + if (deleted is not null) + { + _logger.LogInformation("Deleted reachability graph {Digest}", digest); + return true; + } + + return false; + } + + /// + public async Task RecordReplayAsync( + ReplayLogEntry entry, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entry); + + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken); + await SetTenantContext(connection, entry.TenantId, cancellationToken); + + var inputsJson = JsonSerializer.Serialize(entry.InputDigests, JsonOptions); + var divergenceJson = entry.Divergence is not null + ? JsonSerializer.Serialize(entry.Divergence, JsonOptions) + : null; + + const string sql = """ + INSERT INTO reachgraph.replay_log ( + subgraph_digest, input_digests, computed_digest, matches, + divergence, tenant_id, duration_ms + ) + VALUES ( + @SubgraphDigest, @InputDigests::jsonb, @ComputedDigest, @Matches, + @Divergence::jsonb, @TenantId, @DurationMs + ) + """; + + await connection.ExecuteAsync(sql, new + { + entry.SubgraphDigest, + InputDigests = inputsJson, + entry.ComputedDigest, + entry.Matches, + Divergence = divergenceJson, + entry.TenantId, + entry.DurationMs + }); + + _logger.LogInformation( + "Recorded replay {Result} for {Digest} (computed: {Computed}, {Duration}ms)", + entry.Matches ? "MATCH" : "MISMATCH", + entry.SubgraphDigest, + entry.ComputedDigest, + entry.DurationMs); + } + + private static async Task SetTenantContext( + NpgsqlConnection connection, + string tenantId, + CancellationToken cancellationToken) + { + await using var cmd = connection.CreateCommand(); + cmd.CommandText = "SET LOCAL app.tenant_id = @TenantId"; + cmd.Parameters.AddWithValue("TenantId", tenantId); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + private static byte[] CompressGzip(byte[] data) + { + using var output = new MemoryStream(); + using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + { + gzip.Write(data); + } + return output.ToArray(); + } + + private static byte[] DecompressGzip(byte[] compressed) + { + using var input = new MemoryStream(compressed); + using var gzip = new GZipStream(input, CompressionMode.Decompress); + using var output = new MemoryStream(); + gzip.CopyTo(output); + return output.ToArray(); + } + + private static ReachGraphScope ParseScope(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var entrypoints = root.TryGetProperty("entrypoints", out var ep) + ? ep.EnumerateArray().Select(e => e.GetString()!).ToImmutableArray() + : ImmutableArray.Empty; + + var selectors = root.TryGetProperty("selectors", out var sel) + ? sel.EnumerateArray().Select(s => s.GetString()!).ToImmutableArray() + : ImmutableArray.Empty; + + ImmutableArray? cves = null; + if (root.TryGetProperty("cves", out var cvesElem) && cvesElem.ValueKind == JsonValueKind.Array) + { + cves = cvesElem.EnumerateArray().Select(c => c.GetString()!).ToImmutableArray(); + } + + return new ReachGraphScope(entrypoints, selectors, cves); + } +} diff --git a/src/__Libraries/StellaOps.ReachGraph.Persistence/StellaOps.ReachGraph.Persistence.csproj b/src/__Libraries/StellaOps.ReachGraph.Persistence/StellaOps.ReachGraph.Persistence.csproj new file mode 100644 index 000000000..23140aab0 --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph.Persistence/StellaOps.ReachGraph.Persistence.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + preview + StellaOps.ReachGraph.Persistence + PostgreSQL persistence layer for StellaOps ReachGraph + StellaOps + StellaOps.ReachGraph.Persistence + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/__Libraries/StellaOps.ReachGraph/Hashing/ReachGraphDigestComputer.cs b/src/__Libraries/StellaOps.ReachGraph/Hashing/ReachGraphDigestComputer.cs new file mode 100644 index 000000000..8e9f54c61 --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph/Hashing/ReachGraphDigestComputer.cs @@ -0,0 +1,113 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using Blake3; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; + +namespace StellaOps.ReachGraph.Hashing; + +/// +/// Computes BLAKE3-256 digests for reachability graphs using canonical serialization. +/// +public sealed class ReachGraphDigestComputer +{ + private readonly CanonicalReachGraphSerializer _serializer; + + public ReachGraphDigestComputer() + : this(new CanonicalReachGraphSerializer()) + { + } + + public ReachGraphDigestComputer(CanonicalReachGraphSerializer serializer) + { + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + } + + /// + /// Compute BLAKE3-256 digest of canonical JSON (excluding signatures). + /// + /// The reachability graph to hash. + /// Digest in format "blake3:{hex}". + public string ComputeDigest(ReachGraphMinimal graph) + { + ArgumentNullException.ThrowIfNull(graph); + + // Remove signatures before hashing (avoid circular dependency) + var unsigned = graph with { Signatures = null }; + var canonical = _serializer.SerializeMinimal(unsigned); + + using var hasher = Hasher.New(); + hasher.Update(canonical); + var hash = hasher.Finalize(); + + return $"blake3:{Convert.ToHexString(hash.AsSpan()).ToLowerInvariant()}"; + } + + /// + /// Compute BLAKE3-256 digest from raw canonical JSON bytes. + /// + /// The canonical JSON bytes to hash. + /// Digest in format "blake3:{hex}". + public static string ComputeDigest(ReadOnlySpan canonicalJson) + { + using var hasher = Hasher.New(); + hasher.Update(canonicalJson); + var hash = hasher.Finalize(); + + return $"blake3:{Convert.ToHexString(hash.AsSpan()).ToLowerInvariant()}"; + } + + /// + /// Verify digest matches graph content. + /// + /// The reachability graph to verify. + /// The expected digest. + /// True if digest matches, false otherwise. + public bool VerifyDigest(ReachGraphMinimal graph, string expectedDigest) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrEmpty(expectedDigest); + + var computed = ComputeDigest(graph); + return string.Equals(computed, expectedDigest, StringComparison.Ordinal); + } + + /// + /// Parse a digest string into its algorithm and hash components. + /// + /// The digest string (e.g., "blake3:abc123..."). + /// Tuple of (algorithm, hash) or null if invalid format. + public static (string Algorithm, string Hash)? ParseDigest(string digest) + { + if (string.IsNullOrEmpty(digest)) + return null; + + var colonIndex = digest.IndexOf(':'); + if (colonIndex <= 0 || colonIndex >= digest.Length - 1) + return null; + + var algorithm = digest[..colonIndex]; + var hash = digest[(colonIndex + 1)..]; + + return (algorithm, hash); + } + + /// + /// Validate that a digest string has the correct format for BLAKE3. + /// + /// The digest string to validate. + /// True if valid BLAKE3 digest format, false otherwise. + public static bool IsValidBlake3Digest(string digest) + { + var parsed = ParseDigest(digest); + if (parsed is null) + return false; + + var (algorithm, hash) = parsed.Value; + + // BLAKE3-256 produces 64 hex characters (32 bytes) + return string.Equals(algorithm, "blake3", StringComparison.OrdinalIgnoreCase) && + hash.Length == 64 && + hash.All(c => char.IsAsciiHexDigit(c)); + } +} diff --git a/src/__Libraries/StellaOps.ReachGraph/Serialization/CanonicalReachGraphSerializer.cs b/src/__Libraries/StellaOps.ReachGraph/Serialization/CanonicalReachGraphSerializer.cs new file mode 100644 index 000000000..27a0b1399 --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph/Serialization/CanonicalReachGraphSerializer.cs @@ -0,0 +1,462 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Collections.Immutable; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using StellaOps.ReachGraph.Schema; + +namespace StellaOps.ReachGraph.Serialization; + +/// +/// Serializer for canonical (deterministic) ReachGraph JSON. +/// Guarantees: +/// - Lexicographically sorted object keys +/// - Arrays sorted by deterministic field (Nodes by Id, Edges by From/To) +/// - UTC ISO-8601 timestamps with millisecond precision +/// - Null fields omitted +/// - Minified output for storage, prettified for debugging +/// +public sealed class CanonicalReachGraphSerializer +{ + private readonly JsonSerializerOptions _minifiedOptions; + private readonly JsonSerializerOptions _prettyOptions; + + public CanonicalReachGraphSerializer() + { + _minifiedOptions = CreateSerializerOptions(writeIndented: false); + _prettyOptions = CreateSerializerOptions(writeIndented: true); + } + + /// + /// Serialize to canonical minified JSON bytes. + /// + public byte[] SerializeMinimal(ReachGraphMinimal graph) + { + ArgumentNullException.ThrowIfNull(graph); + + var canonical = Canonicalize(graph); + return JsonSerializer.SerializeToUtf8Bytes(canonical, _minifiedOptions); + } + + /// + /// Serialize to canonical prettified JSON for debugging. + /// + public string SerializePretty(ReachGraphMinimal graph) + { + ArgumentNullException.ThrowIfNull(graph); + + var canonical = Canonicalize(graph); + return JsonSerializer.Serialize(canonical, _prettyOptions); + } + + /// + /// Deserialize from JSON bytes. + /// + public ReachGraphMinimal Deserialize(ReadOnlySpan json) + { + var dto = JsonSerializer.Deserialize(json, _minifiedOptions) + ?? throw new JsonException("Failed to deserialize ReachGraphMinimal"); + + return FromDto(dto); + } + + /// + /// Deserialize from JSON string. + /// + public ReachGraphMinimal Deserialize(string json) + { + ArgumentException.ThrowIfNullOrEmpty(json); + + var dto = JsonSerializer.Deserialize(json, _minifiedOptions) + ?? throw new JsonException("Failed to deserialize ReachGraphMinimal"); + + return FromDto(dto); + } + + private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented) + { + return new JsonSerializerOptions + { + WriteIndented = writeIndented, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), + new Iso8601MillisecondConverter() + }, + // Ensure consistent ordering + PropertyNameCaseInsensitive = true + }; + } + + /// + /// Create a canonical DTO with sorted arrays. + /// + private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph) + { + // Sort nodes by Id lexicographically + var sortedNodes = graph.Nodes + .OrderBy(n => n.Id, StringComparer.Ordinal) + .Select(n => new ReachGraphNodeDto + { + Id = n.Id, + Kind = n.Kind, + Ref = n.Ref, + File = n.File, + Line = n.Line, + ModuleHash = n.ModuleHash, + Addr = n.Addr, + IsEntrypoint = n.IsEntrypoint, + IsSink = n.IsSink + }) + .ToList(); + + // Sort edges by From, then To + var sortedEdges = graph.Edges + .OrderBy(e => e.From, StringComparer.Ordinal) + .ThenBy(e => e.To, StringComparer.Ordinal) + .Select(e => new ReachGraphEdgeDto + { + From = e.From, + To = e.To, + Why = new EdgeExplanationDto + { + Type = e.Why.Type, + Loc = e.Why.Loc, + Guard = e.Why.Guard, + Confidence = e.Why.Confidence, + Metadata = e.Why.Metadata?.Count > 0 + ? new SortedDictionary(e.Why.Metadata, StringComparer.Ordinal) + : null + } + }) + .ToList(); + + // Sort signatures by KeyId if present + List? sortedSignatures = null; + if (graph.Signatures is { Length: > 0 } sigs) + { + sortedSignatures = sigs + .OrderBy(s => s.KeyId, StringComparer.Ordinal) + .Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig }) + .ToList(); + } + + // Sort entrypoints and selectors + var sortedEntrypoints = graph.Scope.Entrypoints + .OrderBy(e => e, StringComparer.Ordinal) + .ToList(); + var sortedSelectors = graph.Scope.Selectors + .OrderBy(s => s, StringComparer.Ordinal) + .ToList(); + List? sortedCves = graph.Scope.Cves is { Length: > 0 } cves + ? cves.OrderBy(c => c, StringComparer.Ordinal).ToList() + : null; + + // Sort env array + var sortedEnv = graph.Artifact.Env + .OrderBy(e => e, StringComparer.Ordinal) + .ToList(); + + // Sort intoto array if present + List? sortedIntoto = graph.Provenance.Intoto is { Length: > 0 } intoto + ? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList() + : null; + + return new ReachGraphMinimalDto + { + SchemaVersion = graph.SchemaVersion, + Artifact = new ReachGraphArtifactDto + { + Name = graph.Artifact.Name, + Digest = graph.Artifact.Digest, + Env = sortedEnv + }, + Scope = new ReachGraphScopeDto + { + Entrypoints = sortedEntrypoints, + Selectors = sortedSelectors, + Cves = sortedCves + }, + Nodes = sortedNodes, + Edges = sortedEdges, + Provenance = new ReachGraphProvenanceDto + { + Intoto = sortedIntoto, + Inputs = new ReachGraphInputsDto + { + Sbom = graph.Provenance.Inputs.Sbom, + Vex = graph.Provenance.Inputs.Vex, + Callgraph = graph.Provenance.Inputs.Callgraph, + RuntimeFacts = graph.Provenance.Inputs.RuntimeFacts, + Policy = graph.Provenance.Inputs.Policy + }, + ComputedAt = graph.Provenance.ComputedAt, + Analyzer = new ReachGraphAnalyzerDto + { + Name = graph.Provenance.Analyzer.Name, + Version = graph.Provenance.Analyzer.Version, + ToolchainDigest = graph.Provenance.Analyzer.ToolchainDigest + } + }, + Signatures = sortedSignatures + }; + } + + /// + /// Convert DTO back to domain record. + /// + private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto) + { + return new ReachGraphMinimal + { + SchemaVersion = dto.SchemaVersion, + Artifact = new ReachGraphArtifact( + dto.Artifact.Name, + dto.Artifact.Digest, + [.. dto.Artifact.Env] + ), + Scope = new ReachGraphScope( + [.. dto.Scope.Entrypoints], + [.. dto.Scope.Selectors], + dto.Scope.Cves is { Count: > 0 } ? [.. dto.Scope.Cves] : null + ), + Nodes = [.. dto.Nodes.Select(n => new ReachGraphNode + { + Id = n.Id, + Kind = n.Kind, + Ref = n.Ref, + File = n.File, + Line = n.Line, + ModuleHash = n.ModuleHash, + Addr = n.Addr, + IsEntrypoint = n.IsEntrypoint, + IsSink = n.IsSink + })], + Edges = [.. dto.Edges.Select(e => new ReachGraphEdge + { + From = e.From, + To = e.To, + Why = new EdgeExplanation + { + Type = e.Why.Type, + Loc = e.Why.Loc, + Guard = e.Why.Guard, + Confidence = e.Why.Confidence, + Metadata = e.Why.Metadata?.Count > 0 + ? e.Why.Metadata.ToImmutableDictionary() + : null + } + })], + Provenance = new ReachGraphProvenance + { + Intoto = dto.Provenance.Intoto is { Count: > 0 } ? [.. dto.Provenance.Intoto] : null, + Inputs = new ReachGraphInputs + { + Sbom = dto.Provenance.Inputs.Sbom, + Vex = dto.Provenance.Inputs.Vex, + Callgraph = dto.Provenance.Inputs.Callgraph, + RuntimeFacts = dto.Provenance.Inputs.RuntimeFacts, + Policy = dto.Provenance.Inputs.Policy + }, + ComputedAt = dto.Provenance.ComputedAt, + Analyzer = new ReachGraphAnalyzer( + dto.Provenance.Analyzer.Name, + dto.Provenance.Analyzer.Version, + dto.Provenance.Analyzer.ToolchainDigest + ) + }, + Signatures = dto.Signatures is { Count: > 0 } + ? [.. dto.Signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))] + : null + }; + } + + #region DTOs for canonical serialization (alphabetically ordered properties) + + private sealed class ReachGraphMinimalDto + { + [JsonPropertyOrder(1)] + public required string SchemaVersion { get; init; } + + [JsonPropertyOrder(2)] + public required ReachGraphArtifactDto Artifact { get; init; } + + [JsonPropertyOrder(3)] + public required ReachGraphScopeDto Scope { get; init; } + + [JsonPropertyOrder(4)] + public required List Nodes { get; init; } + + [JsonPropertyOrder(5)] + public required List Edges { get; init; } + + [JsonPropertyOrder(6)] + public required ReachGraphProvenanceDto Provenance { get; init; } + + [JsonPropertyOrder(7)] + public List? Signatures { get; init; } + } + + private sealed class ReachGraphArtifactDto + { + [JsonPropertyOrder(1)] + public required string Name { get; init; } + + [JsonPropertyOrder(2)] + public required string Digest { get; init; } + + [JsonPropertyOrder(3)] + public required List Env { get; init; } + } + + private sealed class ReachGraphScopeDto + { + [JsonPropertyOrder(1)] + public required List Entrypoints { get; init; } + + [JsonPropertyOrder(2)] + public required List Selectors { get; init; } + + [JsonPropertyOrder(3)] + public List? Cves { get; init; } + } + + private sealed class ReachGraphNodeDto + { + [JsonPropertyOrder(1)] + public required string Id { get; init; } + + [JsonPropertyOrder(2)] + public required ReachGraphNodeKind Kind { get; init; } + + [JsonPropertyOrder(3)] + public required string Ref { get; init; } + + [JsonPropertyOrder(4)] + public string? File { get; init; } + + [JsonPropertyOrder(5)] + public int? Line { get; init; } + + [JsonPropertyOrder(6)] + public string? ModuleHash { get; init; } + + [JsonPropertyOrder(7)] + public string? Addr { get; init; } + + [JsonPropertyOrder(8)] + public bool? IsEntrypoint { get; init; } + + [JsonPropertyOrder(9)] + public bool? IsSink { get; init; } + } + + private sealed class ReachGraphEdgeDto + { + [JsonPropertyOrder(1)] + public required string From { get; init; } + + [JsonPropertyOrder(2)] + public required string To { get; init; } + + [JsonPropertyOrder(3)] + public required EdgeExplanationDto Why { get; init; } + } + + private sealed class EdgeExplanationDto + { + [JsonPropertyOrder(1)] + public required EdgeExplanationType Type { get; init; } + + [JsonPropertyOrder(2)] + public string? Loc { get; init; } + + [JsonPropertyOrder(3)] + public string? Guard { get; init; } + + [JsonPropertyOrder(4)] + public required double Confidence { get; init; } + + [JsonPropertyOrder(5)] + public SortedDictionary? Metadata { get; init; } + } + + private sealed class ReachGraphProvenanceDto + { + [JsonPropertyOrder(1)] + public List? Intoto { get; init; } + + [JsonPropertyOrder(2)] + public required ReachGraphInputsDto Inputs { get; init; } + + [JsonPropertyOrder(3)] + public required DateTimeOffset ComputedAt { get; init; } + + [JsonPropertyOrder(4)] + public required ReachGraphAnalyzerDto Analyzer { get; init; } + } + + private sealed class ReachGraphInputsDto + { + [JsonPropertyOrder(1)] + public required string Sbom { get; init; } + + [JsonPropertyOrder(2)] + public string? Vex { get; init; } + + [JsonPropertyOrder(3)] + public string? Callgraph { get; init; } + + [JsonPropertyOrder(4)] + public string? RuntimeFacts { get; init; } + + [JsonPropertyOrder(5)] + public string? Policy { get; init; } + } + + private sealed class ReachGraphAnalyzerDto + { + [JsonPropertyOrder(1)] + public required string Name { get; init; } + + [JsonPropertyOrder(2)] + public required string Version { get; init; } + + [JsonPropertyOrder(3)] + public required string ToolchainDigest { get; init; } + } + + private sealed class ReachGraphSignatureDto + { + [JsonPropertyOrder(1)] + public required string KeyId { get; init; } + + [JsonPropertyOrder(2)] + public required string Sig { get; init; } + } + + #endregion +} + +/// +/// JSON converter for ISO-8601 timestamps with millisecond precision. +/// +internal sealed class Iso8601MillisecondConverter : JsonConverter +{ + private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ"; + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset"); + return DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + // Always output in UTC with millisecond precision + writer.WriteStringValue(value.UtcDateTime.ToString(Format, System.Globalization.CultureInfo.InvariantCulture)); + } +} diff --git a/src/__Libraries/StellaOps.ReachGraph/Signing/IReachGraphKeyStore.cs b/src/__Libraries/StellaOps.ReachGraph/Signing/IReachGraphKeyStore.cs new file mode 100644 index 000000000..0959171ae --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph/Signing/IReachGraphKeyStore.cs @@ -0,0 +1,45 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +namespace StellaOps.ReachGraph.Signing; + +/// +/// Key store abstraction for ReachGraph signing operations. +/// Wraps the underlying cryptographic key management (Attestor, Signer module, etc.). +/// +public interface IReachGraphKeyStore +{ + /// + /// Sign data with the specified key. + /// + /// The key identifier. + /// The data to sign (typically PAE-encoded). + /// Cancellation token. + /// The signature bytes. + Task SignAsync(string keyId, byte[] data, CancellationToken cancellationToken = default); + + /// + /// Verify a signature with the specified key. + /// + /// The key identifier. + /// The data that was signed. + /// The signature to verify. + /// Cancellation token. + /// True if signature is valid, false otherwise. + Task VerifyAsync(string keyId, byte[] data, byte[] signature, CancellationToken cancellationToken = default); + + /// + /// Check if a key exists and is available for signing. + /// + /// The key identifier. + /// Cancellation token. + /// True if key exists and can sign, false otherwise. + Task CanSignAsync(string keyId, CancellationToken cancellationToken = default); + + /// + /// Check if a key exists and is available for verification. + /// + /// The key identifier. + /// Cancellation token. + /// True if key exists and can verify, false otherwise. + Task CanVerifyAsync(string keyId, CancellationToken cancellationToken = default); +} diff --git a/src/__Libraries/StellaOps.ReachGraph/Signing/IReachGraphSignerService.cs b/src/__Libraries/StellaOps.ReachGraph/Signing/IReachGraphSignerService.cs new file mode 100644 index 000000000..abb65bd4b --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph/Signing/IReachGraphSignerService.cs @@ -0,0 +1,98 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Collections.Immutable; +using StellaOps.ReachGraph.Schema; + +namespace StellaOps.ReachGraph.Signing; + +/// +/// Service for signing and verifying reachability graphs using DSSE envelopes. +/// +public interface IReachGraphSignerService +{ + /// + /// Sign a reachability graph using DSSE envelope format. + /// + /// The graph to sign. + /// The key identifier to use for signing. + /// Cancellation token. + /// The graph with signature attached. + Task SignAsync( + ReachGraphMinimal graph, + string keyId, + CancellationToken cancellationToken = default); + + /// + /// Verify signatures on a reachability graph. + /// + /// The graph to verify. + /// Cancellation token. + /// Verification result with valid/invalid key IDs. + Task VerifyAsync( + ReachGraphMinimal graph, + CancellationToken cancellationToken = default); + + /// + /// Create a DSSE envelope for a reachability graph. + /// + /// The graph to envelope. + /// The key identifier to use for signing. + /// Cancellation token. + /// Serialized DSSE envelope bytes. + Task CreateDsseEnvelopeAsync( + ReachGraphMinimal graph, + string keyId, + CancellationToken cancellationToken = default); +} + +/// +/// Result of reachability graph signature verification. +/// +public sealed record ReachGraphVerificationResult +{ + /// + /// Gets whether all signatures are valid. + /// + public required bool IsValid { get; init; } + + /// + /// Gets the key IDs with valid signatures. + /// + public required ImmutableArray ValidKeyIds { get; init; } + + /// + /// Gets the key IDs with invalid signatures. + /// + public required ImmutableArray InvalidKeyIds { get; init; } + + /// + /// Gets the error message if verification failed. + /// + public string? Error { get; init; } + + /// + /// Creates a successful verification result. + /// + public static ReachGraphVerificationResult Success(ImmutableArray validKeyIds) => + new() + { + IsValid = true, + ValidKeyIds = validKeyIds, + InvalidKeyIds = [] + }; + + /// + /// Creates a failed verification result. + /// + public static ReachGraphVerificationResult Failure( + ImmutableArray validKeyIds, + ImmutableArray invalidKeyIds, + string? error = null) => + new() + { + IsValid = false, + ValidKeyIds = validKeyIds, + InvalidKeyIds = invalidKeyIds, + Error = error + }; +} diff --git a/src/__Libraries/StellaOps.ReachGraph/Signing/ReachGraphSignerService.cs b/src/__Libraries/StellaOps.ReachGraph/Signing/ReachGraphSignerService.cs new file mode 100644 index 000000000..e6257eecb --- /dev/null +++ b/src/__Libraries/StellaOps.ReachGraph/Signing/ReachGraphSignerService.cs @@ -0,0 +1,223 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Collections.Immutable; +using System.Text; +using Microsoft.Extensions.Logging; +using StellaOps.ReachGraph.Hashing; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; + +namespace StellaOps.ReachGraph.Signing; + +/// +/// DSSE-based signing service for reachability graphs. +/// Wraps the Attestor envelope signing infrastructure. +/// +public sealed class ReachGraphSignerService : IReachGraphSignerService +{ + private const string PayloadType = "application/vnd.stellaops.reachgraph.min+json"; + + private readonly IReachGraphKeyStore _keyStore; + private readonly CanonicalReachGraphSerializer _serializer; + private readonly ReachGraphDigestComputer _digestComputer; + private readonly ILogger _logger; + + public ReachGraphSignerService( + IReachGraphKeyStore keyStore, + CanonicalReachGraphSerializer serializer, + ReachGraphDigestComputer digestComputer, + ILogger logger) + { + _keyStore = keyStore ?? throw new ArgumentNullException(nameof(keyStore)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task SignAsync( + ReachGraphMinimal graph, + string keyId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrEmpty(keyId); + + _logger.LogDebug("Signing reachability graph with key {KeyId}", keyId); + + // Get canonical JSON (without existing signatures) + var unsigned = graph with { Signatures = null }; + var canonicalBytes = _serializer.SerializeMinimal(unsigned); + + // Compute PAE (Pre-Authentication Encoding) for DSSE + var pae = ComputePae(PayloadType, canonicalBytes); + + // Sign with the key + var signatureBytes = await _keyStore.SignAsync(keyId, pae, cancellationToken); + var signatureBase64 = Convert.ToBase64String(signatureBytes); + + // Create new signature entry + var newSignature = new ReachGraphSignature(keyId, signatureBase64); + + // Append to existing signatures (if any) and return + var existingSignatures = graph.Signatures ?? []; + var allSignatures = existingSignatures.Add(newSignature); + + // Sort signatures by KeyId for determinism + allSignatures = [.. allSignatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)]; + + _logger.LogInformation( + "Signed reachability graph with key {KeyId}, digest {Digest}", + keyId, _digestComputer.ComputeDigest(unsigned)); + + return graph with { Signatures = allSignatures }; + } + + /// + public async Task VerifyAsync( + ReachGraphMinimal graph, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + + if (graph.Signatures is null or { Length: 0 }) + { + return ReachGraphVerificationResult.Failure([], [], "No signatures to verify"); + } + + _logger.LogDebug("Verifying {Count} signature(s) on reachability graph", graph.Signatures.Value.Length); + + // Get canonical JSON (without signatures) + var unsigned = graph with { Signatures = null }; + var canonicalBytes = _serializer.SerializeMinimal(unsigned); + + // Compute PAE + var pae = ComputePae(PayloadType, canonicalBytes); + + var validKeyIds = new List(); + var invalidKeyIds = new List(); + + foreach (var signature in graph.Signatures.Value) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var signatureBytes = Convert.FromBase64String(signature.Sig); + var isValid = await _keyStore.VerifyAsync(signature.KeyId, pae, signatureBytes, cancellationToken); + + if (isValid) + { + validKeyIds.Add(signature.KeyId); + } + else + { + invalidKeyIds.Add(signature.KeyId); + } + } + catch (Exception ex) when (ex is FormatException or ArgumentException) + { + _logger.LogWarning(ex, "Invalid signature format for key {KeyId}", signature.KeyId); + invalidKeyIds.Add(signature.KeyId); + } + } + + var isAllValid = invalidKeyIds.Count == 0 && validKeyIds.Count > 0; + + _logger.LogInformation( + "Verification result: {Valid} valid, {Invalid} invalid signatures", + validKeyIds.Count, invalidKeyIds.Count); + + return isAllValid + ? ReachGraphVerificationResult.Success([.. validKeyIds]) + : ReachGraphVerificationResult.Failure( + [.. validKeyIds], + [.. invalidKeyIds], + invalidKeyIds.Count > 0 ? $"{invalidKeyIds.Count} signature(s) failed verification" : null); + } + + /// + public async Task CreateDsseEnvelopeAsync( + ReachGraphMinimal graph, + string keyId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrEmpty(keyId); + + // Sign the graph first + var signedGraph = await SignAsync(graph, keyId, cancellationToken); + + // Get canonical JSON for the signed graph + var canonicalBytes = _serializer.SerializeMinimal(signedGraph); + + // Build DSSE envelope JSON + return BuildDsseEnvelopeJson(canonicalBytes, signedGraph.Signatures ?? []); + } + + /// + /// Compute DSSE Pre-Authentication Encoding (PAE). + /// PAE(type, payload) = "DSSEv1" || len(type) || type || len(payload) || payload + /// + private static byte[] ComputePae(string payloadType, byte[] payload) + { + // PAE format: "DSSEv1" + 8-byte LE length of type + type bytes + 8-byte LE length of payload + payload + var typeBytes = Encoding.UTF8.GetBytes(payloadType); + var prefix = Encoding.UTF8.GetBytes("DSSEv1 "); + + var result = new byte[prefix.Length + 8 + typeBytes.Length + 8 + payload.Length]; + var offset = 0; + + // Copy "DSSEv1 " + Buffer.BlockCopy(prefix, 0, result, offset, prefix.Length); + offset += prefix.Length; + + // Write type length as 8-byte LE + BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)typeBytes.Length); + offset += 8; + + // Copy type bytes + Buffer.BlockCopy(typeBytes, 0, result, offset, typeBytes.Length); + offset += typeBytes.Length; + + // Write payload length as 8-byte LE + BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)payload.Length); + offset += 8; + + // Copy payload + Buffer.BlockCopy(payload, 0, result, offset, payload.Length); + + return result; + } + + /// + /// Build a DSSE envelope JSON document. + /// + private static byte[] BuildDsseEnvelopeJson(byte[] payload, ImmutableArray signatures) + { + var payloadBase64 = Convert.ToBase64String(payload); + + using var ms = new MemoryStream(); + using var writer = new System.Text.Json.Utf8JsonWriter(ms); + + writer.WriteStartObject(); + writer.WriteString("payloadType", PayloadType); + writer.WriteString("payload", payloadBase64); + writer.WritePropertyName("signatures"); + writer.WriteStartArray(); + + foreach (var sig in signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)) + { + writer.WriteStartObject(); + writer.WriteString("keyid", sig.KeyId); + writer.WriteString("sig", sig.Sig); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + writer.Flush(); + + return ms.ToArray(); + } +} diff --git a/src/__Libraries/StellaOps.ReachGraph/StellaOps.ReachGraph.csproj b/src/__Libraries/StellaOps.ReachGraph/StellaOps.ReachGraph.csproj index 726515779..f0ff73975 100644 --- a/src/__Libraries/StellaOps.ReachGraph/StellaOps.ReachGraph.csproj +++ b/src/__Libraries/StellaOps.ReachGraph/StellaOps.ReachGraph.csproj @@ -13,8 +13,8 @@ - - + + diff --git a/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj b/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj index 15a9ca081..c18dd0f26 100644 --- a/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj +++ b/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj @@ -9,10 +9,10 @@ $(NoWarn);NETSDK1188 - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj b/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj index 8ba9b98d2..db7a20e4f 100644 --- a/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj +++ b/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj @@ -5,7 +5,7 @@ enable - + diff --git a/src/__Libraries/StellaOps.Resolver.Tests/StellaOps.Resolver.Tests.csproj b/src/__Libraries/StellaOps.Resolver.Tests/StellaOps.Resolver.Tests.csproj index dd52a8c35..b383fed7f 100644 --- a/src/__Libraries/StellaOps.Resolver.Tests/StellaOps.Resolver.Tests.csproj +++ b/src/__Libraries/StellaOps.Resolver.Tests/StellaOps.Resolver.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj b/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj index 26a3216a9..8a303666e 100644 --- a/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj +++ b/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj @@ -13,15 +13,15 @@ - + - - + + - - + + diff --git a/src/__Libraries/StellaOps.Router.Gateway/StellaOps.Router.Gateway.csproj b/src/__Libraries/StellaOps.Router.Gateway/StellaOps.Router.Gateway.csproj index 57182540b..50bcc6b30 100644 --- a/src/__Libraries/StellaOps.Router.Gateway/StellaOps.Router.Gateway.csproj +++ b/src/__Libraries/StellaOps.Router.Gateway/StellaOps.Router.Gateway.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/src/__Libraries/StellaOps.Router.sln b/src/__Libraries/StellaOps.Router.sln index 57104ad12..34fcff365 100644 --- a/src/__Libraries/StellaOps.Router.sln +++ b/src/__Libraries/StellaOps.Router.sln @@ -1,3 +1,5 @@ +# STELLAOPS-MANUAL-SOLUTION +# This solution is manually maintained. Do not regenerate with sln_generator.py Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 diff --git a/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs b/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs index 56d2f92ae..7ac2f8efd 100644 --- a/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs +++ b/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs @@ -85,7 +85,8 @@ public sealed class ValkeyFixture : IAsyncLifetime, IDisposable _container = new ContainerBuilder() .WithImage("redis:7-alpine") .WithPortBinding(6379, true) // Bind to random host port - .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379)) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilCommandIsCompleted("redis-cli", "ping")) .Build(); await _container.StartAsync(); diff --git a/src/__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj b/src/__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj index c757f0a32..d701b7000 100644 --- a/src/__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj +++ b/src/__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj @@ -9,15 +9,15 @@ - - - - - - - + + + + + + + - + diff --git a/src/__Libraries/StellaOps.Verdict/AGENTS.md b/src/__Libraries/StellaOps.Verdict/AGENTS.md new file mode 100644 index 000000000..b0856a6cf --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/AGENTS.md @@ -0,0 +1,144 @@ +# AGENTS.md — StellaOps.Verdict Module + +## Overview + +The StellaOps.Verdict module provides a **unified StellaVerdict artifact** that consolidates vulnerability disposition decisions into a single, signed, portable proof. It combines PolicyVerdict, ProofBundle, and KnowledgeSnapshot into a content-addressable artifact with JSON-LD support. + +## Module Structure + +``` +src/__Libraries/StellaOps.Verdict/ +├── Schema/ +│ └── StellaVerdict.cs # Core verdict schema and supporting types +├── Contexts/ +│ └── verdict-1.0.jsonld # JSON-LD context for standards interop +├── Services/ +│ ├── VerdictAssemblyService.cs # Assembles verdicts from components +│ ├── VerdictSigningService.cs # DSSE signing integration +│ └── IVerdictAssemblyService.cs +├── Persistence/ +│ ├── PostgresVerdictStore.cs # PostgreSQL storage implementation +│ ├── IVerdictStore.cs # Storage interface +│ ├── VerdictRow.cs # EF Core entity +│ └── Migrations/ +│ └── 001_create_verdicts.sql +├── Api/ +│ ├── VerdictEndpoints.cs # REST API endpoints +│ └── VerdictContracts.cs # Request/response DTOs +├── Oci/ +│ └── OciAttestationPublisher.cs # OCI registry attestation +├── Export/ +│ └── VerdictBundleExporter.cs # Replay bundle export +└── StellaOps.Verdict.csproj +``` + +## Key Concepts + +### StellaVerdict Schema +The core `StellaVerdict` record contains: +- `VerdictId`: Content-addressable ID (`urn:stella:verdict:sha256:...`) +- `Subject`: Vulnerability ID + Component PURL +- `Claim`: Status, confidence, reason +- `Inputs`: Advisory sources, VEX, CVSS, EPSS, KEV, reachability +- `EvidenceGraph`: Proof nodes and edges from ProofBundle +- `PolicyPath`: Rule evaluation trace +- `Result`: Disposition, score, expiration +- `Provenance`: Generator, run ID, timestamp +- `Signatures`: DSSE signatures + +### Verdict Status Values +- `Pass`: Component passed all policy checks +- `Blocked`: Component blocked by policy +- `Ignored`: Finding ignored per policy +- `Warned`: Warning issued but not blocking +- `Deferred`: Decision deferred for manual review +- `Escalated`: Escalated for security team +- `RequiresVex`: Needs VEX statement to resolve + +## Usage Patterns + +### Assembling a Verdict +```csharp +var context = new VerdictAssemblyContext +{ + VulnerabilityId = "CVE-2024-1234", + Purl = "pkg:npm/lodash@4.17.15", + PolicyVerdict = policyResult, + Knowledge = knowledgeInputs, + Generator = "StellaOps Scanner", + RunId = scanId +}; + +var verdict = assemblyService.AssembleVerdict(context); +``` + +### Storing and Querying +```csharp +await store.StoreAsync(verdict, tenantId, cancellationToken); + +var query = new VerdictQuery { Purl = "pkg:npm/lodash@*", Status = VerdictStatus.Blocked }; +var results = await store.QueryAsync(query, cancellationToken); +``` + +### CLI Verification +```bash +# Verify by ID (fetches from API) +stella verify verdict --verdict urn:stella:verdict:sha256:abc123 + +# Verify from file with replay bundle +stella verify verdict --verdict ./verdict.json --replay ./bundle/ + +# Show full policy trace +stella verify verdict --verdict ./verdict.json --show-trace +``` + +### OCI Attestation +```csharp +var result = await publisher.PublishAsync(verdict, "registry.io/app:latest@sha256:..."); +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/verdicts` | POST | Create and store verdict | +| `/v1/verdicts/{id}` | GET | Get verdict by ID | +| `/v1/verdicts` | GET | Query verdicts (purl, cve, status) | +| `/v1/verdicts/{id}/verify` | POST | Verify signature and content ID | +| `/v1/verdicts/{id}/download` | GET | Download as JSON-LD | +| `/v1/verdicts/latest` | GET | Get latest for purl+cve | +| `/v1/verdicts/expired` | DELETE | Clean up expired verdicts | + +## Dependencies + +- `StellaOps.Policy`: PolicyVerdict, PolicyExplanation +- `StellaOps.Attestor.Envelope`: DSSE signing +- `StellaOps.Cryptography`: BLAKE3/SHA256 hashing +- `StellaOps.Replay.Core`: Bundle structures + +## Testing + +Unit tests should cover: +- Schema serialization determinism (sorted keys) +- Content-addressable ID computation +- Assembly from various input combinations +- Signature verification +- Query filtering and pagination + +Integration tests should cover: +- Full assembly → sign → store → query → verify flow +- OCI publish/fetch cycle +- Replay bundle export and verification + +## Coding Guidelines + +1. **Determinism**: All JSON output must be deterministic (sorted keys, stable ordering) +2. **Content Addressing**: VerdictId must match `ComputeVerdictId()` output +3. **Immutability**: Use records with `init` properties +4. **Tenant Isolation**: All store operations must include tenantId +5. **Offline Support**: OCI publisher and CLI must handle offline mode + +## Related Sprints + +- SPRINT_1227_0014_0001: StellaVerdict Unified Artifact Consolidation +- SPRINT_1227_0014_0002: Verdict UI Components (pending) diff --git a/src/__Libraries/StellaOps.Verdict/Api/VerdictContracts.cs b/src/__Libraries/StellaOps.Verdict/Api/VerdictContracts.cs new file mode 100644 index 000000000..7b0352120 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Api/VerdictContracts.cs @@ -0,0 +1,159 @@ +using StellaOps.Policy; +using StellaOps.Verdict.Services; + +namespace StellaOps.Verdict.Api; + +/// +/// Request to create a new verdict. +/// +public sealed record VerdictCreateRequest +{ + /// Vulnerability ID (CVE, GHSA, etc.). + public required string VulnerabilityId { get; init; } + + /// Component PURL. + public required string Purl { get; init; } + + /// Component name. + public string? ComponentName { get; init; } + + /// Component version. + public string? ComponentVersion { get; init; } + + /// Image digest if in container context. + public string? ImageDigest { get; init; } + + /// Policy verdict result. + public required PolicyVerdict PolicyVerdict { get; init; } + + /// Knowledge inputs. + public VerdictKnowledgeInputs? Knowledge { get; init; } + + /// Generator name. + public string? Generator { get; init; } + + /// Generator version. + public string? GeneratorVersion { get; init; } + + /// Scan/run ID. + public string? RunId { get; init; } +} + +/// +/// Response for verdict creation. +/// +public sealed record VerdictResponse +{ + /// The generated verdict ID. + public required string VerdictId { get; init; } + + /// Verdict status. + public required string Status { get; init; } + + /// Result disposition. + public required string Disposition { get; init; } + + /// Risk score. + public double Score { get; init; } + + /// Creation timestamp. + public required string CreatedAt { get; init; } +} + +/// +/// Summary of a verdict for list queries. +/// +public sealed record VerdictSummary +{ + /// Verdict ID. + public required string VerdictId { get; init; } + + /// Vulnerability ID. + public required string VulnerabilityId { get; init; } + + /// Component PURL. + public required string Purl { get; init; } + + /// Verdict status. + public required string Status { get; init; } + + /// Result disposition. + public required string Disposition { get; init; } + + /// Risk score. + public double Score { get; init; } + + /// Creation timestamp. + public required string CreatedAt { get; init; } +} + +/// +/// Response for verdict queries. +/// +public sealed record VerdictQueryResponse +{ + /// List of verdict summaries. + public required List Verdicts { get; init; } + + /// Total count of matching verdicts. + public int TotalCount { get; init; } + + /// Offset used in query. + public int Offset { get; init; } + + /// Limit used in query. + public int Limit { get; init; } + + /// Whether there are more results. + public bool HasMore { get; init; } +} + +/// +/// Request to verify a verdict. +/// +public sealed record VerdictVerifyRequest +{ + /// Optional trusted key IDs for signature verification. + public List? TrustedKeyIds { get; init; } + + /// Optional inputs hash to verify against. + public string? ExpectedInputsHash { get; init; } +} + +/// +/// Response for verdict verification. +/// +public sealed record VerdictVerifyResponse +{ + /// Verdict ID that was verified. + public required string VerdictId { get; init; } + + /// Whether the verdict has signatures. + public bool HasSignatures { get; set; } + + /// Number of signatures on the verdict. + public int SignatureCount { get; set; } + + /// Whether all signatures are valid. + public bool Verified { get; set; } + + /// Whether the content-addressable ID is valid. + public bool ContentIdValid { get; set; } + + /// Verification message or error. + public string? VerificationMessage { get; set; } +} + +/// +/// Response for deleting expired verdicts. +/// +public sealed record ExpiredDeleteResponse +{ + /// Number of deleted verdicts. + public int DeletedCount { get; init; } +} + +/// +/// Generic error response. +/// +public sealed record ErrorResponse(string Message); diff --git a/src/__Libraries/StellaOps.Verdict/Api/VerdictEndpoints.cs b/src/__Libraries/StellaOps.Verdict/Api/VerdictEndpoints.cs new file mode 100644 index 000000000..1789d3b47 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Api/VerdictEndpoints.cs @@ -0,0 +1,351 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using StellaOps.Verdict.Persistence; +using StellaOps.Verdict.Schema; +using StellaOps.Verdict.Services; + +namespace StellaOps.Verdict.Api; + +/// +/// REST API endpoints for StellaVerdict operations. +/// +public static class VerdictEndpoints +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + private static readonly JsonSerializerOptions JsonLdOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + /// + /// Maps verdict endpoints to the route builder. + /// + public static void MapVerdictEndpoints(this IEndpointRouteBuilder endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var group = endpoints.MapGroup("/v1/verdicts"); + + // POST /v1/verdicts - Create and store a verdict + group.MapPost("/", HandleCreate) + .WithName("verdict.create") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .RequireAuthorization(); + + // GET /v1/verdicts/{id} - Get verdict by ID + group.MapGet("/{id}", HandleGet) + .WithName("verdict.get") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + // GET /v1/verdicts - Query verdicts + group.MapGet("/", HandleQuery) + .WithName("verdict.query") + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(); + + // POST /v1/verdicts/{id}/verify - Verify verdict signature + group.MapPost("/{id}/verify", HandleVerify) + .WithName("verdict.verify") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + // GET /v1/verdicts/{id}/download - Download signed JSON-LD + group.MapGet("/{id}/download", HandleDownload) + .WithName("verdict.download") + .Produces(StatusCodes.Status200OK, "application/ld+json") + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + // GET /v1/verdicts/latest - Get latest verdict for PURL+CVE + group.MapGet("/latest", HandleGetLatest) + .WithName("verdict.latest") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(); + + // DELETE /v1/verdicts/expired - Clean up expired verdicts + group.MapDelete("/expired", HandleDeleteExpired) + .WithName("verdict.deleteExpired") + .Produces(StatusCodes.Status200OK) + .RequireAuthorization("verdict:admin"); + } + + private static async Task HandleCreate( + VerdictCreateRequest request, + IVerdictAssemblyService assemblyService, + IVerdictStore store, + HttpContext context, + ILogger logger, + CancellationToken cancellationToken) + { + if (request is null) + { + return Results.BadRequest(new ErrorResponse("Request body is required")); + } + + var tenantId = GetTenantId(context); + + try + { + // Assemble the verdict from the request + var assemblyContext = new VerdictAssemblyContext + { + VulnerabilityId = request.VulnerabilityId, + Purl = request.Purl, + ComponentName = request.ComponentName, + ComponentVersion = request.ComponentVersion, + ImageDigest = request.ImageDigest, + PolicyVerdict = request.PolicyVerdict, + ProofBundle = null, // Could be enhanced to accept proof bundle reference + Knowledge = request.Knowledge, + Generator = request.Generator ?? "StellaOps", + GeneratorVersion = request.GeneratorVersion, + RunId = request.RunId, + }; + + var verdict = assemblyService.AssembleVerdict(assemblyContext); + + // Store the verdict + var storeResult = await store.StoreAsync(verdict, tenantId, cancellationToken); + if (!storeResult.Success) + { + logger.LogError("Failed to store verdict: {Error}", storeResult.Error); + return Results.BadRequest(new ErrorResponse(storeResult.Error ?? "Storage failed")); + } + + var response = new VerdictResponse + { + VerdictId = verdict.VerdictId, + Status = verdict.Claim.Status.ToString(), + Disposition = verdict.Result.Disposition, + Score = verdict.Result.Score, + CreatedAt = verdict.Provenance.CreatedAt, + }; + + return Results.Created($"/v1/verdicts/{Uri.EscapeDataString(verdict.VerdictId)}", response); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create verdict for {Purl}/{Cve}", request.Purl, request.VulnerabilityId); + return Results.BadRequest(new ErrorResponse($"Failed to create verdict: {ex.Message}")); + } + } + + private static async Task HandleGet( + string id, + IVerdictStore store, + HttpContext context, + CancellationToken cancellationToken) + { + var tenantId = GetTenantId(context); + var verdictId = Uri.UnescapeDataString(id); + + var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken); + if (verdict is null) + { + return Results.NotFound(); + } + + return Json(verdict, StatusCodes.Status200OK); + } + + private static async Task HandleQuery( + string? purl, + string? cve, + string? status, + string? imageDigest, + int? limit, + int? offset, + IVerdictStore store, + HttpContext context, + CancellationToken cancellationToken) + { + var tenantId = GetTenantId(context); + + VerdictStatus? statusFilter = null; + if (!string.IsNullOrEmpty(status) && Enum.TryParse(status, true, out var parsed)) + { + statusFilter = parsed; + } + + var query = new VerdictQuery + { + TenantId = tenantId, + Purl = purl, + CveId = cve, + Status = statusFilter, + ImageDigest = imageDigest, + Limit = Math.Min(limit ?? 50, 100), + Offset = offset ?? 0, + }; + + var result = await store.QueryAsync(query, cancellationToken); + + var response = new VerdictQueryResponse + { + Verdicts = result.Verdicts.Select(v => new VerdictSummary + { + VerdictId = v.VerdictId, + VulnerabilityId = v.Subject.VulnerabilityId, + Purl = v.Subject.Purl, + Status = v.Claim.Status.ToString(), + Disposition = v.Result.Disposition, + Score = v.Result.Score, + CreatedAt = v.Provenance.CreatedAt, + }).ToList(), + TotalCount = result.TotalCount, + Offset = result.Offset, + Limit = result.Limit, + HasMore = result.HasMore, + }; + + return Json(response, StatusCodes.Status200OK); + } + + private static async Task HandleVerify( + string id, + VerdictVerifyRequest? request, + IVerdictStore store, + IVerdictSigningService signingService, + HttpContext context, + ILogger logger, + CancellationToken cancellationToken) + { + var tenantId = GetTenantId(context); + var verdictId = Uri.UnescapeDataString(id); + + var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken); + if (verdict is null) + { + return Results.NotFound(); + } + + var response = new VerdictVerifyResponse + { + VerdictId = verdictId, + HasSignatures = !verdict.Signatures.IsDefaultOrEmpty, + SignatureCount = verdict.Signatures.IsDefaultOrEmpty ? 0 : verdict.Signatures.Length, + // Full verification would require trusted keys from request or key store + Verified = false, + VerificationMessage = verdict.Signatures.IsDefaultOrEmpty + ? "Verdict has no signatures" + : "Signature verification requires trusted keys", + }; + + // Verify content-addressable ID + var expectedId = verdict.ComputeVerdictId(); + response.ContentIdValid = string.Equals(verdict.VerdictId, expectedId, StringComparison.Ordinal); + + if (!response.ContentIdValid) + { + response.VerificationMessage = "Content ID mismatch - verdict may have been tampered with"; + } + + return Json(response, StatusCodes.Status200OK); + } + + private static async Task HandleDownload( + string id, + IVerdictStore store, + HttpContext context, + CancellationToken cancellationToken) + { + var tenantId = GetTenantId(context); + var verdictId = Uri.UnescapeDataString(id); + + var verdict = await store.GetAsync(verdictId, tenantId, cancellationToken); + if (verdict is null) + { + return Results.NotFound(); + } + + var json = JsonSerializer.Serialize(verdict, JsonLdOptions); + + context.Response.Headers.ContentDisposition = + $"attachment; filename=\"verdict-{verdict.Subject.VulnerabilityId}.jsonld\""; + + return Results.Content(json, "application/ld+json", Encoding.UTF8, StatusCodes.Status200OK); + } + + private static async Task HandleGetLatest( + string purl, + string cve, + IVerdictStore store, + HttpContext context, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(purl) || string.IsNullOrEmpty(cve)) + { + return Results.BadRequest(new ErrorResponse("Both purl and cve query parameters are required")); + } + + var tenantId = GetTenantId(context); + var verdict = await store.GetLatestAsync(purl, cve, tenantId, cancellationToken); + + if (verdict is null) + { + return Results.NotFound(); + } + + return Json(verdict, StatusCodes.Status200OK); + } + + private static async Task HandleDeleteExpired( + IVerdictStore store, + HttpContext context, + ILogger logger, + CancellationToken cancellationToken) + { + var tenantId = GetTenantId(context); + var deletedCount = await store.DeleteExpiredAsync(tenantId, DateTimeOffset.UtcNow, cancellationToken); + + logger.LogInformation("Deleted {Count} expired verdicts for tenant {TenantId}", deletedCount, tenantId); + + return Json(new ExpiredDeleteResponse { DeletedCount = deletedCount }, StatusCodes.Status200OK); + } + + private static Guid GetTenantId(HttpContext context) + { + // Try to get tenant ID from claims or header + var tenantClaim = context.User.FindFirst("tenant_id")?.Value; + if (!string.IsNullOrEmpty(tenantClaim) && Guid.TryParse(tenantClaim, out var claimTenantId)) + { + return claimTenantId; + } + + // Fallback to header + if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) && + Guid.TryParse(headerValue.FirstOrDefault(), out var headerTenantId)) + { + return headerTenantId; + } + + // Default tenant for development + return Guid.Empty; + } + + private static IResult Json(T value, int statusCode) + { + var payload = JsonSerializer.Serialize(value, JsonOptions); + return Results.Content(payload, "application/json", Encoding.UTF8, statusCode); + } +} + +/// +/// Marker class for logger category. +/// +internal sealed class VerdictEndpointsLogger { } diff --git a/src/__Libraries/StellaOps.Verdict/Contexts/verdict-1.0.jsonld b/src/__Libraries/StellaOps.Verdict/Contexts/verdict-1.0.jsonld new file mode 100644 index 000000000..3d4e057b7 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Contexts/verdict-1.0.jsonld @@ -0,0 +1,471 @@ +{ + "@context": { + "@version": 1.1, + "stella": "https://stella-ops.org/schemas/verdict/1.0#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "schema": "http://schema.org/", + "sec": "https://w3id.org/security#", + "intoto": "https://in-toto.io/attestation/v1#", + + "StellaVerdict": { + "@id": "stella:StellaVerdict", + "@context": { + "verdictId": { + "@id": "stella:verdictId", + "@type": "xsd:anyURI" + }, + "version": { + "@id": "stella:version", + "@type": "xsd:string" + }, + "subject": { + "@id": "stella:subject", + "@type": "@id" + }, + "claim": { + "@id": "stella:claim", + "@type": "@id" + }, + "inputs": { + "@id": "stella:inputs", + "@type": "@id" + }, + "evidenceGraph": { + "@id": "stella:evidenceGraph", + "@type": "@id" + }, + "policyPath": { + "@id": "stella:policyPath", + "@type": "@id", + "@container": "@list" + }, + "result": { + "@id": "stella:result", + "@type": "@id" + }, + "provenance": { + "@id": "stella:provenance", + "@type": "@id" + }, + "signatures": { + "@id": "sec:signature", + "@type": "@id", + "@container": "@set" + } + } + }, + + "VerdictSubject": { + "@id": "stella:VerdictSubject", + "@context": { + "vulnerabilityId": { + "@id": "stella:vulnerabilityId", + "@type": "xsd:string" + }, + "purl": { + "@id": "stella:purl", + "@type": "xsd:anyURI" + }, + "componentName": { + "@id": "schema:name", + "@type": "xsd:string" + }, + "componentVersion": { + "@id": "schema:version", + "@type": "xsd:string" + }, + "imageDigest": { + "@id": "intoto:digest", + "@type": "xsd:string" + }, + "subjectDigest": { + "@id": "stella:subjectDigest", + "@type": "xsd:string" + } + } + }, + + "VerdictClaim": { + "@id": "stella:VerdictClaim", + "@context": { + "status": { + "@id": "stella:status", + "@type": "xsd:string" + }, + "confidence": { + "@id": "stella:confidence", + "@type": "xsd:decimal" + }, + "confidenceBand": { + "@id": "stella:confidenceBand", + "@type": "xsd:string" + }, + "reason": { + "@id": "schema:description", + "@type": "xsd:string" + }, + "vexStatus": { + "@id": "stella:vexStatus", + "@type": "xsd:string" + }, + "vexJustification": { + "@id": "stella:vexJustification", + "@type": "xsd:string" + } + } + }, + + "VerdictInputs": { + "@id": "stella:VerdictInputs", + "@context": { + "advisorySources": { + "@id": "stella:advisorySources", + "@type": "@id", + "@container": "@set" + }, + "vexStatements": { + "@id": "stella:vexStatements", + "@type": "@id", + "@container": "@set" + }, + "cvssScores": { + "@id": "stella:cvssScores", + "@type": "@id", + "@container": "@set" + }, + "epss": { + "@id": "stella:epss", + "@type": "@id" + }, + "kev": { + "@id": "stella:kev", + "@type": "@id" + }, + "reachability": { + "@id": "stella:reachability", + "@type": "@id" + } + } + }, + + "VerdictEvidenceGraph": { + "@id": "stella:VerdictEvidenceGraph", + "@context": { + "nodes": { + "@id": "stella:nodes", + "@type": "@id", + "@container": "@set" + }, + "edges": { + "@id": "stella:edges", + "@type": "@id", + "@container": "@set" + }, + "root": { + "@id": "stella:root", + "@type": "xsd:string" + } + } + }, + + "VerdictEvidenceNode": { + "@id": "stella:VerdictEvidenceNode", + "@context": { + "id": { + "@id": "@id" + }, + "type": { + "@id": "stella:evidenceType", + "@type": "xsd:string" + }, + "label": { + "@id": "schema:name", + "@type": "xsd:string" + }, + "hashAlgorithm": { + "@id": "sec:digestAlgorithm", + "@type": "xsd:string" + }, + "capturedAt": { + "@id": "schema:dateCreated", + "@type": "xsd:dateTime" + }, + "uri": { + "@id": "schema:url", + "@type": "xsd:anyURI" + }, + "metadata": { + "@id": "stella:metadata", + "@type": "@id" + } + } + }, + + "VerdictEvidenceEdge": { + "@id": "stella:VerdictEvidenceEdge", + "@context": { + "from": { + "@id": "stella:fromNode", + "@type": "xsd:string" + }, + "to": { + "@id": "stella:toNode", + "@type": "xsd:string" + }, + "relationship": { + "@id": "stella:relationship", + "@type": "xsd:string" + } + } + }, + + "VerdictPolicyStep": { + "@id": "stella:VerdictPolicyStep", + "@context": { + "ruleId": { + "@id": "stella:ruleId", + "@type": "xsd:string" + }, + "ruleName": { + "@id": "schema:name", + "@type": "xsd:string" + }, + "matched": { + "@id": "stella:matched", + "@type": "xsd:boolean" + }, + "action": { + "@id": "stella:action", + "@type": "xsd:string" + }, + "reason": { + "@id": "schema:description", + "@type": "xsd:string" + }, + "order": { + "@id": "stella:order", + "@type": "xsd:integer" + } + } + }, + + "VerdictResult": { + "@id": "stella:VerdictResult", + "@context": { + "disposition": { + "@id": "stella:disposition", + "@type": "xsd:string" + }, + "score": { + "@id": "stella:score", + "@type": "xsd:decimal" + }, + "matchedRule": { + "@id": "stella:matchedRule", + "@type": "xsd:string" + }, + "ruleAction": { + "@id": "stella:ruleAction", + "@type": "xsd:string" + }, + "quiet": { + "@id": "stella:quiet", + "@type": "xsd:boolean" + }, + "quietedBy": { + "@id": "stella:quietedBy", + "@type": "xsd:string" + }, + "expiresAt": { + "@id": "schema:expires", + "@type": "xsd:dateTime" + } + } + }, + + "VerdictProvenance": { + "@id": "stella:VerdictProvenance", + "@context": { + "generator": { + "@id": "schema:creator", + "@type": "xsd:string" + }, + "generatorVersion": { + "@id": "schema:softwareVersion", + "@type": "xsd:string" + }, + "runId": { + "@id": "stella:runId", + "@type": "xsd:string" + }, + "createdAt": { + "@id": "schema:dateCreated", + "@type": "xsd:dateTime" + }, + "policyBundleId": { + "@id": "stella:policyBundleId", + "@type": "xsd:string" + }, + "policyBundleVersion": { + "@id": "stella:policyBundleVersion", + "@type": "xsd:string" + } + } + }, + + "VerdictSignature": { + "@id": "sec:Signature", + "@context": { + "keyid": { + "@id": "sec:keyId", + "@type": "xsd:string" + }, + "sig": { + "@id": "sec:signatureValue", + "@type": "xsd:string" + }, + "cert": { + "@id": "sec:certificate", + "@type": "xsd:string" + } + } + }, + + "VerdictAdvisorySource": { + "@id": "stella:VerdictAdvisorySource", + "@context": { + "source": { + "@id": "schema:publisher", + "@type": "xsd:string" + }, + "advisoryId": { + "@id": "stella:advisoryId", + "@type": "xsd:string" + }, + "fetchedAt": { + "@id": "schema:datePublished", + "@type": "xsd:dateTime" + }, + "contentHash": { + "@id": "sec:digestValue", + "@type": "xsd:string" + } + } + }, + + "VerdictVexInput": { + "@id": "stella:VerdictVexInput", + "@context": { + "vexId": { + "@id": "stella:vexId", + "@type": "xsd:string" + }, + "issuer": { + "@id": "schema:publisher", + "@type": "xsd:string" + }, + "status": { + "@id": "stella:vexStatus", + "@type": "xsd:string" + }, + "justification": { + "@id": "stella:justification", + "@type": "xsd:string" + }, + "timestamp": { + "@id": "schema:dateCreated", + "@type": "xsd:dateTime" + } + } + }, + + "VerdictCvssInput": { + "@id": "stella:VerdictCvssInput", + "@context": { + "version": { + "@id": "stella:cvssVersion", + "@type": "xsd:string" + }, + "vector": { + "@id": "stella:cvssVector", + "@type": "xsd:string" + }, + "baseScore": { + "@id": "stella:baseScore", + "@type": "xsd:decimal" + }, + "temporalScore": { + "@id": "stella:temporalScore", + "@type": "xsd:decimal" + }, + "environmentalScore": { + "@id": "stella:environmentalScore", + "@type": "xsd:decimal" + }, + "source": { + "@id": "schema:publisher", + "@type": "xsd:string" + } + } + }, + + "VerdictEpssInput": { + "@id": "stella:VerdictEpssInput", + "@context": { + "probability": { + "@id": "stella:epssProbability", + "@type": "xsd:decimal" + }, + "percentile": { + "@id": "stella:epssPercentile", + "@type": "xsd:decimal" + }, + "date": { + "@id": "schema:datePublished", + "@type": "xsd:date" + } + } + }, + + "VerdictKevInput": { + "@id": "stella:VerdictKevInput", + "@context": { + "inKev": { + "@id": "stella:inKev", + "@type": "xsd:boolean" + }, + "dateAdded": { + "@id": "stella:kevDateAdded", + "@type": "xsd:date" + }, + "dueDate": { + "@id": "stella:kevDueDate", + "@type": "xsd:date" + } + } + }, + + "VerdictReachabilityInput": { + "@id": "stella:VerdictReachabilityInput", + "@context": { + "isReachable": { + "@id": "stella:isReachable", + "@type": "xsd:boolean" + }, + "confidence": { + "@id": "stella:reachabilityConfidence", + "@type": "xsd:decimal" + }, + "method": { + "@id": "stella:analysisMethod", + "@type": "xsd:string" + }, + "callPath": { + "@id": "stella:callPath", + "@type": "xsd:string", + "@container": "@list" + } + } + } + } +} diff --git a/src/__Libraries/StellaOps.Verdict/Export/VerdictBundleExporter.cs b/src/__Libraries/StellaOps.Verdict/Export/VerdictBundleExporter.cs new file mode 100644 index 000000000..5d1d8dd1a --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Export/VerdictBundleExporter.cs @@ -0,0 +1,445 @@ +// VerdictBundleExporter - Export replay bundle for offline verification +// Sprint: SPRINT_1227_0014_0001_BE_stellaverdict_consolidation +// Task 9: Verdict Replay Bundle Exporter + +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StellaOps.Verdict.Schema; + +namespace StellaOps.Verdict.Export; + +/// +/// Service for exporting verdict replay bundles for offline verification. +/// +public interface IVerdictBundleExporter +{ + /// + /// Export a verdict with all inputs to a replay bundle. + /// + /// The verdict to export. + /// Additional context for the bundle. + /// Output path for the bundle (.tar.zst or directory). + /// Cancellation token. + /// Result of the export operation. + Task ExportAsync( + StellaVerdict verdict, + VerdictBundleContext context, + string outputPath, + CancellationToken cancellationToken = default); + + /// + /// Export a verdict bundle to a stream. + /// + Task ExportToStreamAsync( + StellaVerdict verdict, + VerdictBundleContext context, + Stream outputStream, + CancellationToken cancellationToken = default); +} + +/// +/// Additional context for bundle export. +/// +public sealed class VerdictBundleContext +{ + /// + /// SBOM slice relevant to the verdict. + /// + public string? SbomSliceJson { get; set; } + + /// + /// Advisory feed snapshots. + /// + public IList FeedSnapshots { get; set; } = new List(); + + /// + /// Policy bundle used for evaluation. + /// + public string? PolicyBundleJson { get; set; } + + /// + /// Policy bundle version. + /// + public string? PolicyBundleVersion { get; set; } + + /// + /// Reachability analysis data. + /// + public string? ReachabilityJson { get; set; } + + /// + /// Runtime configuration. + /// + public string? RuntimeConfigJson { get; set; } + + /// + /// Include full signatures in bundle. + /// + public bool IncludeSignatures { get; set; } = true; + + /// + /// Compress the bundle. + /// + public bool Compress { get; set; } = true; +} + +/// +/// Advisory feed snapshot for replay. +/// +public sealed class VerdictFeedSnapshot +{ + /// + /// Feed source name (e.g., "nvd", "debian-vex"). + /// + public required string Source { get; set; } + + /// + /// Feed date. + /// + public required DateOnly Date { get; set; } + + /// + /// Feed content (JSON). + /// + public required string ContentJson { get; set; } + + /// + /// Content hash for verification. + /// + public string? ContentHash { get; set; } +} + +/// +/// Result of bundle export. +/// +public sealed record VerdictBundleExportResult +{ + public required bool Success { get; init; } + public string? OutputPath { get; init; } + public string? ManifestHash { get; init; } + public long SizeBytes { get; init; } + public int FileCount { get; init; } + public string? ErrorMessage { get; init; } +} + +/// +/// Default implementation of verdict bundle exporter. +/// +public sealed class VerdictBundleExporter : IVerdictBundleExporter +{ + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public VerdictBundleExporter( + ILogger logger, + TimeProvider? timeProvider = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task ExportAsync( + StellaVerdict verdict, + VerdictBundleContext context, + string outputPath, + CancellationToken cancellationToken = default) + { + try + { + // Create output directory if exporting to directory + if (!outputPath.EndsWith(".tar.zst", StringComparison.OrdinalIgnoreCase) && + !outputPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) && + !outputPath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + return await ExportToDirectoryAsync(verdict, context, outputPath, cancellationToken); + } + + // Export to compressed archive + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); + var result = await ExportToStreamAsync(verdict, context, fileStream, cancellationToken); + + return result with { OutputPath = outputPath }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export verdict bundle to {Path}", outputPath); + return new VerdictBundleExportResult + { + Success = false, + ErrorMessage = ex.Message + }; + } + } + + public async Task ExportToStreamAsync( + StellaVerdict verdict, + VerdictBundleContext context, + Stream outputStream, + CancellationToken cancellationToken = default) + { + try + { + var manifest = new BundleManifest + { + Version = "1.0", + CreatedAt = _timeProvider.GetUtcNow().ToString("O"), + VerdictId = verdict.VerdictId, + VulnerabilityId = verdict.Subject.VulnerabilityId, + Purl = verdict.Subject.Purl, + Files = new List() + }; + + int fileCount = 0; + long totalSize = 0; + + using var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); + + // Add verdict.json + var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions); + await AddEntryAsync(archive, "verdict.json", verdictJson, manifest); + fileCount++; + totalSize += verdictJson.Length; + + // Add SBOM slice if available + if (!string.IsNullOrEmpty(context.SbomSliceJson)) + { + await AddEntryAsync(archive, "sbom-slice.json", context.SbomSliceJson, manifest); + fileCount++; + totalSize += context.SbomSliceJson.Length; + } + + // Add feed snapshots + foreach (var feed in context.FeedSnapshots) + { + var feedPath = $"feeds/{feed.Source}-{feed.Date:yyyy-MM-dd}.json"; + await AddEntryAsync(archive, feedPath, feed.ContentJson, manifest); + fileCount++; + totalSize += feed.ContentJson.Length; + } + + // Add policy bundle + if (!string.IsNullOrEmpty(context.PolicyBundleJson)) + { + var policyPath = $"policy/bundle-{context.PolicyBundleVersion ?? "latest"}.json"; + await AddEntryAsync(archive, policyPath, context.PolicyBundleJson, manifest); + fileCount++; + totalSize += context.PolicyBundleJson.Length; + } + + // Add reachability data + if (!string.IsNullOrEmpty(context.ReachabilityJson)) + { + await AddEntryAsync(archive, "callgraph/reachability.json", context.ReachabilityJson, manifest); + fileCount++; + totalSize += context.ReachabilityJson.Length; + } + + // Add runtime config + if (!string.IsNullOrEmpty(context.RuntimeConfigJson)) + { + await AddEntryAsync(archive, "config/runtime.json", context.RuntimeConfigJson, manifest); + fileCount++; + totalSize += context.RuntimeConfigJson.Length; + } + + // Add manifest as last file + var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); + var manifestEntry = archive.CreateEntry("manifest.json", CompressionLevel.Optimal); + await using (var manifestStream = manifestEntry.Open()) + { + await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson), cancellationToken); + } + fileCount++; + totalSize += manifestJson.Length; + + var manifestHash = ComputeHash(manifestJson); + + _logger.LogInformation( + "Exported verdict bundle {VerdictId} with {FileCount} files ({Size} bytes)", + verdict.VerdictId, fileCount, totalSize); + + return new VerdictBundleExportResult + { + Success = true, + ManifestHash = manifestHash, + SizeBytes = totalSize, + FileCount = fileCount + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to export verdict bundle to stream"); + return new VerdictBundleExportResult + { + Success = false, + ErrorMessage = ex.Message + }; + } + } + + private async Task ExportToDirectoryAsync( + StellaVerdict verdict, + VerdictBundleContext context, + string outputPath, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(outputPath); + + var manifest = new BundleManifest + { + Version = "1.0", + CreatedAt = _timeProvider.GetUtcNow().ToString("O"), + VerdictId = verdict.VerdictId, + VulnerabilityId = verdict.Subject.VulnerabilityId, + Purl = verdict.Subject.Purl, + Files = new List() + }; + + int fileCount = 0; + long totalSize = 0; + + // Write verdict.json + var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions); + await WriteFileAsync(outputPath, "verdict.json", verdictJson, manifest, cancellationToken); + fileCount++; + totalSize += verdictJson.Length; + + // Write SBOM slice + if (!string.IsNullOrEmpty(context.SbomSliceJson)) + { + await WriteFileAsync(outputPath, "sbom-slice.json", context.SbomSliceJson, manifest, cancellationToken); + fileCount++; + totalSize += context.SbomSliceJson.Length; + } + + // Write feed snapshots + if (context.FeedSnapshots.Count > 0) + { + Directory.CreateDirectory(Path.Combine(outputPath, "feeds")); + foreach (var feed in context.FeedSnapshots) + { + var feedPath = $"feeds/{feed.Source}-{feed.Date:yyyy-MM-dd}.json"; + await WriteFileAsync(outputPath, feedPath, feed.ContentJson, manifest, cancellationToken); + fileCount++; + totalSize += feed.ContentJson.Length; + } + } + + // Write policy bundle + if (!string.IsNullOrEmpty(context.PolicyBundleJson)) + { + Directory.CreateDirectory(Path.Combine(outputPath, "policy")); + var policyPath = $"policy/bundle-{context.PolicyBundleVersion ?? "latest"}.json"; + await WriteFileAsync(outputPath, policyPath, context.PolicyBundleJson, manifest, cancellationToken); + fileCount++; + totalSize += context.PolicyBundleJson.Length; + } + + // Write reachability data + if (!string.IsNullOrEmpty(context.ReachabilityJson)) + { + Directory.CreateDirectory(Path.Combine(outputPath, "callgraph")); + await WriteFileAsync(outputPath, "callgraph/reachability.json", context.ReachabilityJson, manifest, cancellationToken); + fileCount++; + totalSize += context.ReachabilityJson.Length; + } + + // Write runtime config + if (!string.IsNullOrEmpty(context.RuntimeConfigJson)) + { + Directory.CreateDirectory(Path.Combine(outputPath, "config")); + await WriteFileAsync(outputPath, "config/runtime.json", context.RuntimeConfigJson, manifest, cancellationToken); + fileCount++; + totalSize += context.RuntimeConfigJson.Length; + } + + // Write manifest + var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); + await File.WriteAllTextAsync(Path.Combine(outputPath, "manifest.json"), manifestJson, cancellationToken); + fileCount++; + totalSize += manifestJson.Length; + + var manifestHash = ComputeHash(manifestJson); + + _logger.LogInformation( + "Exported verdict bundle {VerdictId} to {Path} with {FileCount} files", + verdict.VerdictId, outputPath, fileCount); + + return new VerdictBundleExportResult + { + Success = true, + OutputPath = outputPath, + ManifestHash = manifestHash, + SizeBytes = totalSize, + FileCount = fileCount + }; + } + + private static async Task AddEntryAsync( + ZipArchive archive, + string path, + string content, + BundleManifest manifest) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + await using var stream = entry.Open(); + await stream.WriteAsync(Encoding.UTF8.GetBytes(content)); + + manifest.Files.Add(new BundleFileEntry + { + Path = path, + Hash = ComputeHash(content), + Size = content.Length + }); + } + + private static async Task WriteFileAsync( + string basePath, + string relativePath, + string content, + BundleManifest manifest, + CancellationToken cancellationToken) + { + var fullPath = Path.Combine(basePath, relativePath); + await File.WriteAllTextAsync(fullPath, content, cancellationToken); + + manifest.Files.Add(new BundleFileEntry + { + Path = relativePath, + Hash = ComputeHash(content), + Size = content.Length + }); + } + + private static string ComputeHash(string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private sealed class BundleManifest + { + public required string Version { get; set; } + public required string CreatedAt { get; set; } + public required string VerdictId { get; set; } + public required string VulnerabilityId { get; set; } + public required string Purl { get; set; } + public required List Files { get; set; } + } + + private sealed class BundleFileEntry + { + public required string Path { get; set; } + public required string Hash { get; set; } + public required long Size { get; set; } + } +} diff --git a/src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs b/src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs new file mode 100644 index 000000000..95867c053 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Oci/OciAttestationPublisher.cs @@ -0,0 +1,825 @@ +// OciAttestationPublisher - OCI registry attachment for StellaVerdict attestations +// Sprint: SPRINT_1227_0014_0001_BE_stellaverdict_consolidation +// Task 6: OCI Attestation Publisher + +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StellaOps.Verdict.Schema; + +namespace StellaOps.Verdict.Oci; + +/// +/// Service for publishing StellaVerdict attestations to OCI registries. +/// +public interface IOciAttestationPublisher +{ + /// + /// Publish a StellaVerdict attestation to an OCI artifact as a referrer. + /// + /// The verdict to publish. + /// OCI image reference (registry/repo:tag@sha256:digest). + /// Cancellation token. + /// Result of the publish operation. + Task PublishAsync( + StellaVerdict verdict, + string imageReference, + CancellationToken cancellationToken = default); + + /// + /// Fetch a StellaVerdict attestation from an OCI artifact. + /// + /// OCI image reference. + /// Optional verdict ID to filter. + /// Cancellation token. + /// The fetched verdict or null if not found. + Task FetchAsync( + string imageReference, + string? verdictId = null, + CancellationToken cancellationToken = default); + + /// + /// List all StellaVerdict attestations for an OCI artifact. + /// + Task> ListAsync( + string imageReference, + CancellationToken cancellationToken = default); + + /// + /// Remove a StellaVerdict attestation from an OCI artifact. + /// + Task RemoveAsync( + string imageReference, + string verdictId, + CancellationToken cancellationToken = default); +} + +/// +/// Result of publishing a verdict to OCI. +/// +public sealed record OciPublishResult +{ + public required bool Success { get; init; } + public string? OciDigest { get; init; } + public string? ManifestDigest { get; init; } + public string? ErrorMessage { get; init; } + public TimeSpan Duration { get; init; } + public bool WasSkippedOffline { get; init; } +} + +/// +/// Entry in the list of OCI verdict attachments. +/// +public sealed record OciVerdictEntry +{ + public required string VerdictId { get; init; } + public required string VulnerabilityId { get; init; } + public required string Purl { get; init; } + public required string OciDigest { get; init; } + public required DateTimeOffset AttachedAt { get; init; } + public required long SizeBytes { get; init; } +} + +/// +/// Default implementation using ORAS/OCI referrers API patterns. +/// +public sealed class OciAttestationPublisher : IOciAttestationPublisher +{ + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly HttpClient _httpClient; + + /// + /// ORAS artifact type for StellaVerdict attestations. + /// + public const string ArtifactType = "application/vnd.stellaops.verdict+json"; + + /// + /// Media type for DSSE envelope containing verdict. + /// + public const string DsseMediaType = "application/vnd.dsse.envelope.v1+json"; + + /// + /// Media type for JSON-LD verdict. + /// + public const string JsonLdMediaType = "application/ld+json"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + public OciAttestationPublisher( + IOptionsMonitor options, + ILogger logger, + HttpClient? httpClient = null, + TimeProvider? timeProvider = null) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClient ?? new HttpClient(); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task PublishAsync( + StellaVerdict verdict, + string imageReference, + CancellationToken cancellationToken = default) + { + var startTime = _timeProvider.GetUtcNow(); + var opts = _options.CurrentValue; + + // Handle offline/air-gap mode + if (opts.OfflineMode) + { + _logger.LogInformation( + "Offline mode enabled, skipping OCI publish for verdict {VerdictId}", + verdict.VerdictId); + + // Store locally if configured + if (!string.IsNullOrEmpty(opts.OfflineStoragePath)) + { + await StoreOfflineAsync(verdict, opts.OfflineStoragePath, cancellationToken); + } + + return new OciPublishResult + { + Success = true, + WasSkippedOffline = true, + Duration = _timeProvider.GetUtcNow() - startTime + }; + } + + if (!opts.Enabled) + { + _logger.LogDebug("OCI publishing disabled, skipping for {Reference}", imageReference); + return new OciPublishResult + { + Success = false, + ErrorMessage = "OCI publishing is disabled", + Duration = _timeProvider.GetUtcNow() - startTime + }; + } + + try + { + // Parse reference + var parsed = ParseReference(imageReference); + if (parsed is null) + { + return new OciPublishResult + { + Success = false, + ErrorMessage = $"Invalid OCI reference: {imageReference}", + Duration = _timeProvider.GetUtcNow() - startTime + }; + } + + // Serialize verdict to JSON + var verdictJson = JsonSerializer.Serialize(verdict, JsonOptions); + var verdictBytes = Encoding.UTF8.GetBytes(verdictJson); + var verdictDigest = ComputeSha256(verdictBytes); + + _logger.LogInformation( + "Publishing StellaVerdict {VerdictId} to {Reference} ({Size} bytes)", + verdict.VerdictId, imageReference, verdictBytes.Length); + + // Step 1: Push the verdict as a blob + var blobDigest = await PushBlobAsync(parsed, verdictBytes, cancellationToken); + if (blobDigest is null) + { + return new OciPublishResult + { + Success = false, + ErrorMessage = "Failed to push verdict blob", + Duration = _timeProvider.GetUtcNow() - startTime + }; + } + + // Step 2: Create and push artifact manifest with subject reference + var manifestDigest = await PushArtifactManifestAsync( + parsed, + blobDigest, + verdictBytes.Length, + verdict, + cancellationToken); + + if (manifestDigest is null) + { + return new OciPublishResult + { + Success = false, + ErrorMessage = "Failed to push artifact manifest", + Duration = _timeProvider.GetUtcNow() - startTime + }; + } + + // Log for audit trail + _logger.LogInformation( + "Successfully published StellaVerdict {VerdictId} to {Reference}, manifest={Manifest}", + verdict.VerdictId, imageReference, manifestDigest); + + return new OciPublishResult + { + Success = true, + OciDigest = blobDigest, + ManifestDigest = manifestDigest, + Duration = _timeProvider.GetUtcNow() - startTime + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish StellaVerdict to {Reference}", imageReference); + return new OciPublishResult + { + Success = false, + ErrorMessage = ex.Message, + Duration = _timeProvider.GetUtcNow() - startTime + }; + } + } + + public async Task FetchAsync( + string imageReference, + string? verdictId = null, + CancellationToken cancellationToken = default) + { + var opts = _options.CurrentValue; + + if (opts.OfflineMode && !string.IsNullOrEmpty(opts.OfflineStoragePath)) + { + // Try offline storage first + return await FetchOfflineAsync(verdictId, opts.OfflineStoragePath, cancellationToken); + } + + if (!opts.Enabled) + { + _logger.LogDebug("OCI publishing disabled, skipping fetch for {Reference}", imageReference); + return null; + } + + try + { + var parsed = ParseReference(imageReference); + if (parsed is null) + { + _logger.LogWarning("Invalid OCI reference: {Reference}", imageReference); + return null; + } + + // Query referrers API for verdict attestations + // GET /v2/{name}/referrers/{digest}?artifactType={ArtifactType} + var referrers = await FetchReferrersAsync(parsed, cancellationToken); + if (referrers is null || referrers.Count == 0) + { + return null; + } + + // Find matching verdict + foreach (var referrer in referrers) + { + var verdict = await FetchVerdictBlobAsync(parsed, referrer.Digest, cancellationToken); + if (verdict is not null) + { + if (verdictId is null || verdict.VerdictId == verdictId) + { + return verdict; + } + } + } + + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch StellaVerdict from {Reference}", imageReference); + return null; + } + } + + public async Task> ListAsync( + string imageReference, + CancellationToken cancellationToken = default) + { + var opts = _options.CurrentValue; + + if (!opts.Enabled && !opts.OfflineMode) + { + return []; + } + + try + { + var parsed = ParseReference(imageReference); + if (parsed is null) + { + return []; + } + + var referrers = await FetchReferrersAsync(parsed, cancellationToken); + if (referrers is null) + { + return []; + } + + var entries = new List(); + foreach (var referrer in referrers) + { + var verdict = await FetchVerdictBlobAsync(parsed, referrer.Digest, cancellationToken); + if (verdict is not null) + { + entries.Add(new OciVerdictEntry + { + VerdictId = verdict.VerdictId, + VulnerabilityId = verdict.Subject.VulnerabilityId, + Purl = verdict.Subject.Purl, + OciDigest = referrer.Digest, + AttachedAt = referrer.CreatedAt ?? _timeProvider.GetUtcNow(), + SizeBytes = referrer.Size + }); + } + } + + return entries; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list StellaVerdicts for {Reference}", imageReference); + return []; + } + } + + public async Task RemoveAsync( + string imageReference, + string verdictId, + CancellationToken cancellationToken = default) + { + var opts = _options.CurrentValue; + + if (!opts.Enabled) + { + return false; + } + + try + { + // Find and delete the referrer manifest + _logger.LogInformation( + "Would remove StellaVerdict {VerdictId} from {Reference}", + verdictId, imageReference); + + // Note: Full implementation requires finding the manifest digest + // and issuing DELETE /v2/{name}/manifests/{digest} + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove StellaVerdict from {Reference}", imageReference); + return false; + } + } + + private async Task StoreOfflineAsync( + StellaVerdict verdict, + string storagePath, + CancellationToken cancellationToken) + { + try + { + Directory.CreateDirectory(storagePath); + var fileName = $"verdict-{Uri.EscapeDataString(verdict.VerdictId)}.json"; + var filePath = Path.Combine(storagePath, fileName); + + var json = JsonSerializer.Serialize(verdict, new JsonSerializerOptions + { + WriteIndented = true + }); + + await File.WriteAllTextAsync(filePath, json, cancellationToken); + + _logger.LogDebug("Stored verdict offline at {Path}", filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to store verdict offline"); + } + } + + private async Task FetchOfflineAsync( + string? verdictId, + string storagePath, + CancellationToken cancellationToken) + { + try + { + if (!Directory.Exists(storagePath)) + { + return null; + } + + if (!string.IsNullOrEmpty(verdictId)) + { + var fileName = $"verdict-{Uri.EscapeDataString(verdictId)}.json"; + var filePath = Path.Combine(storagePath, fileName); + if (File.Exists(filePath)) + { + var json = await File.ReadAllTextAsync(filePath, cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions); + } + } + + // Return first available + var files = Directory.GetFiles(storagePath, "verdict-*.json"); + if (files.Length > 0) + { + var json = await File.ReadAllTextAsync(files[0], cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch verdict from offline storage"); + return null; + } + } + + private async Task PushBlobAsync( + OciReference reference, + byte[] content, + CancellationToken cancellationToken) + { + var opts = _options.CurrentValue; + var digest = ComputeSha256(content); + + // POST /v2/{name}/blobs/uploads/ + // then PUT /v2/{name}/blobs/uploads/{uuid}?digest=sha256:xxx + var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; + + try + { + // Start upload session + var initiateUrl = $"{baseUrl}/blobs/uploads/"; + var initiateRequest = new HttpRequestMessage(HttpMethod.Post, initiateUrl); + AddAuthHeaders(initiateRequest, opts); + + var initiateResponse = await _httpClient.SendAsync(initiateRequest, cancellationToken); + + if (!initiateResponse.IsSuccessStatusCode) + { + _logger.LogWarning( + "Failed to initiate blob upload: {Status}", + initiateResponse.StatusCode); + return null; + } + + // Get upload URL from Location header + var location = initiateResponse.Headers.Location?.ToString(); + if (string.IsNullOrEmpty(location)) + { + return null; + } + + // Complete upload + var uploadUrl = location.Contains('?') + ? $"{location}&digest={digest}" + : $"{location}?digest={digest}"; + + var uploadRequest = new HttpRequestMessage(HttpMethod.Put, uploadUrl) + { + Content = new ByteArrayContent(content) + }; + uploadRequest.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue(JsonLdMediaType); + AddAuthHeaders(uploadRequest, opts); + + var uploadResponse = await _httpClient.SendAsync(uploadRequest, cancellationToken); + + if (uploadResponse.IsSuccessStatusCode) + { + return digest; + } + + _logger.LogWarning("Failed to upload blob: {Status}", uploadResponse.StatusCode); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to push blob to registry"); + return null; + } + } + + private async Task PushArtifactManifestAsync( + OciReference reference, + string blobDigest, + long blobSize, + StellaVerdict verdict, + CancellationToken cancellationToken) + { + var opts = _options.CurrentValue; + + // Create OCI artifact manifest with subject reference + var manifest = new + { + schemaVersion = 2, + mediaType = "application/vnd.oci.artifact.manifest.v1+json", + artifactType = ArtifactType, + blobs = new[] + { + new + { + mediaType = JsonLdMediaType, + digest = blobDigest, + size = blobSize, + annotations = new Dictionary + { + ["org.stellaops.verdict.id"] = verdict.VerdictId, + ["org.stellaops.verdict.cve"] = verdict.Subject.VulnerabilityId, + ["org.stellaops.verdict.purl"] = verdict.Subject.Purl, + ["org.stellaops.verdict.status"] = verdict.Claim.Status.ToString() + } + } + }, + subject = reference.Digest is not null + ? new { mediaType = "application/vnd.oci.image.manifest.v1+json", digest = reference.Digest } + : null, + annotations = new Dictionary + { + ["org.opencontainers.image.created"] = _timeProvider.GetUtcNow().ToString("O"), + ["org.stellaops.verdict.version"] = verdict.Version + } + }; + + var manifestJson = JsonSerializer.Serialize(manifest, JsonOptions); + var manifestBytes = Encoding.UTF8.GetBytes(manifestJson); + var manifestDigest = ComputeSha256(manifestBytes); + + // PUT /v2/{name}/manifests/{reference} + var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; + var manifestUrl = $"{baseUrl}/manifests/{manifestDigest}"; + + try + { + var request = new HttpRequestMessage(HttpMethod.Put, manifestUrl) + { + Content = new ByteArrayContent(manifestBytes) + }; + request.Content.Headers.ContentType = + new System.Net.Http.Headers.MediaTypeHeaderValue("application/vnd.oci.artifact.manifest.v1+json"); + AddAuthHeaders(request, opts); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return manifestDigest; + } + + _logger.LogWarning("Failed to push manifest: {Status}", response.StatusCode); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to push artifact manifest"); + return null; + } + } + + private async Task?> FetchReferrersAsync( + OciReference reference, + CancellationToken cancellationToken) + { + if (reference.Digest is null) + { + return null; + } + + var opts = _options.CurrentValue; + var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; + var referrersUrl = $"{baseUrl}/referrers/{reference.Digest}?artifactType={Uri.EscapeDataString(ArtifactType)}"; + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, referrersUrl); + AddAuthHeaders(request, opts); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var index = JsonSerializer.Deserialize(json, JsonOptions); + + return index?.Manifests; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch referrers"); + return null; + } + } + + private async Task FetchVerdictBlobAsync( + OciReference reference, + string digest, + CancellationToken cancellationToken) + { + var opts = _options.CurrentValue; + var baseUrl = $"https://{reference.Registry}/v2/{reference.Repository}"; + var blobUrl = $"{baseUrl}/blobs/{digest}"; + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, blobUrl); + AddAuthHeaders(request, opts); + + var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch verdict blob"); + return null; + } + } + + private static void AddAuthHeaders(HttpRequestMessage request, OciPublisherOptions opts) + { + if (!string.IsNullOrEmpty(opts.Auth?.BearerToken)) + { + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.Auth.BearerToken); + } + else if (!string.IsNullOrEmpty(opts.Auth?.Username) && !string.IsNullOrEmpty(opts.Auth?.Password)) + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{opts.Auth.Username}:{opts.Auth.Password}")); + request.Headers.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); + } + } + + private static string ComputeSha256(byte[] content) + { + var hash = System.Security.Cryptography.SHA256.HashData(content); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static OciReference? ParseReference(string reference) + { + try + { + var atIdx = reference.IndexOf('@'); + var colonIdx = reference.LastIndexOf(':'); + + string registry; + string repository; + string? tag = null; + string? digest = null; + + if (atIdx > 0) + { + digest = reference[(atIdx + 1)..]; + var beforeDigest = reference[..atIdx]; + var slashIdx = beforeDigest.IndexOf('/'); + if (slashIdx < 0) return null; + registry = beforeDigest[..slashIdx]; + repository = beforeDigest[(slashIdx + 1)..]; + } + else if (colonIdx > 0 && colonIdx > reference.IndexOf('/')) + { + tag = reference[(colonIdx + 1)..]; + var beforeTag = reference[..colonIdx]; + var slashIdx = beforeTag.IndexOf('/'); + if (slashIdx < 0) return null; + registry = beforeTag[..slashIdx]; + repository = beforeTag[(slashIdx + 1)..]; + } + else + { + return null; + } + + return new OciReference + { + Registry = registry, + Repository = repository, + Tag = tag, + Digest = digest + }; + } + catch + { + return null; + } + } + + private sealed record OciReference + { + public required string Registry { get; init; } + public required string Repository { get; init; } + public string? Tag { get; init; } + public string? Digest { get; init; } + } + + private sealed record ReferrersIndex + { + public IReadOnlyList? Manifests { get; init; } + } + + private sealed record ReferrerEntry + { + public required string Digest { get; init; } + public required long Size { get; init; } + public string? ArtifactType { get; init; } + public DateTimeOffset? CreatedAt { get; init; } + } +} + +/// +/// Configuration options for OCI attestation publishing. +/// +public sealed class OciPublisherOptions +{ + /// + /// Configuration section key. + /// + public const string SectionKey = "VerdictOci"; + + /// + /// Whether OCI publishing is enabled. + /// + public bool Enabled { get; set; } = false; + + /// + /// Whether running in offline/air-gap mode. + /// + public bool OfflineMode { get; set; } = false; + + /// + /// Path to store verdicts when in offline mode. + /// + public string? OfflineStoragePath { get; set; } + + /// + /// Default registry URL if not specified in reference. + /// + public string? DefaultRegistry { get; set; } + + /// + /// Registry authentication. + /// + public OciAuthConfig? Auth { get; set; } + + /// + /// Request timeout. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Whether to verify TLS certificates. + /// + public bool VerifyTls { get; set; } = true; +} + +/// +/// OCI registry authentication configuration. +/// +public sealed class OciAuthConfig +{ + /// + /// Username for basic auth. + /// + public string? Username { get; set; } + + /// + /// Password or token for basic auth. + /// + public string? Password { get; set; } + + /// + /// Bearer token for token auth. + /// + public string? BearerToken { get; set; } + + /// + /// Path to Docker credentials file. + /// + public string? CredentialsFile { get; set; } +} diff --git a/src/__Libraries/StellaOps.Verdict/Persistence/IVerdictStore.cs b/src/__Libraries/StellaOps.Verdict/Persistence/IVerdictStore.cs new file mode 100644 index 000000000..5d8f6a198 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Persistence/IVerdictStore.cs @@ -0,0 +1,147 @@ +using System.Collections.Immutable; +using StellaOps.Verdict.Schema; + +namespace StellaOps.Verdict.Persistence; + +/// +/// Store for persisting and querying StellaVerdict artifacts. +/// +public interface IVerdictStore +{ + /// + /// Stores a verdict. + /// + Task StoreAsync(StellaVerdict verdict, Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Retrieves a verdict by its ID. + /// + Task GetAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Queries verdicts with filters. + /// + Task QueryAsync(VerdictQuery query, CancellationToken cancellationToken = default); + + /// + /// Checks if a verdict exists. + /// + Task ExistsAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Gets verdicts by subject (PURL + CVE). + /// + Task> GetBySubjectAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Gets the latest verdict for a subject. + /// + Task GetLatestAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default); + + /// + /// Deletes expired verdicts. + /// + Task DeleteExpiredAsync(Guid tenantId, DateTimeOffset asOf, CancellationToken cancellationToken = default); +} + +/// +/// Result of storing a verdict. +/// +public sealed record VerdictStoreResult +{ + /// Whether the store operation succeeded. + public required bool Success { get; init; } + + /// The stored verdict ID. + public string? VerdictId { get; init; } + + /// Whether this was an update to an existing verdict. + public bool WasUpdate { get; init; } + + /// Error message if storage failed. + public string? Error { get; init; } + + public static VerdictStoreResult Stored(string verdictId, bool wasUpdate = false) => + new() { Success = true, VerdictId = verdictId, WasUpdate = wasUpdate }; + + public static VerdictStoreResult Failed(string error) => + new() { Success = false, Error = error }; +} + +/// +/// Query parameters for searching verdicts. +/// +public sealed record VerdictQuery +{ + /// Tenant ID for isolation. + public required Guid TenantId { get; init; } + + /// Filter by PURL (exact or prefix match). + public string? Purl { get; init; } + + /// Filter by CVE ID. + public string? CveId { get; init; } + + /// Filter by decision status. + public VerdictStatus? Status { get; init; } + + /// Filter by image digest. + public string? ImageDigest { get; init; } + + /// Filter by inputs hash. + public string? InputsHash { get; init; } + + /// Filter verdicts created after this time. + public DateTimeOffset? CreatedAfter { get; init; } + + /// Filter verdicts created before this time. + public DateTimeOffset? CreatedBefore { get; init; } + + /// Include expired verdicts. + public bool IncludeExpired { get; init; } = false; + + /// Page size for pagination. + public int Limit { get; init; } = 50; + + /// Offset for pagination. + public int Offset { get; init; } = 0; + + /// Sort field. + public VerdictSortField SortBy { get; init; } = VerdictSortField.CreatedAt; + + /// Sort direction. + public bool Descending { get; init; } = true; +} + +/// +/// Sort fields for verdict queries. +/// +public enum VerdictSortField +{ + CreatedAt, + VerdictId, + Purl, + CveId, + Score, +} + +/// +/// Result of a verdict query. +/// +public sealed record VerdictQueryResult +{ + /// The matching verdicts. + public required ImmutableArray Verdicts { get; init; } + + /// Total count of matching verdicts (for pagination). + public required int TotalCount { get; init; } + + /// Offset used in the query. + public int Offset { get; init; } + + /// Limit used in the query. + public int Limit { get; init; } + + /// Whether there are more results. + public bool HasMore => Offset + Verdicts.Length < TotalCount; +} diff --git a/src/__Libraries/StellaOps.Verdict/Persistence/Migrations/001_create_verdicts.sql b/src/__Libraries/StellaOps.Verdict/Persistence/Migrations/001_create_verdicts.sql new file mode 100644 index 000000000..676939439 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Persistence/Migrations/001_create_verdicts.sql @@ -0,0 +1,107 @@ +-- Migration: 001_create_verdicts +-- Description: Create verdicts table for StellaVerdict storage +-- Sprint: SPRINT_1227_0014_0001 + +-- Create schema if not exists +CREATE SCHEMA IF NOT EXISTS stellaops; + +-- Verdicts table +CREATE TABLE stellaops.verdicts ( + verdict_id TEXT NOT NULL, + tenant_id UUID NOT NULL, + + -- Subject fields (extracted for indexing) + subject_purl TEXT NOT NULL, + subject_cve_id TEXT NOT NULL, + subject_component_name TEXT, + subject_component_version TEXT, + subject_image_digest TEXT, + subject_digest TEXT, + + -- Claim fields (extracted for indexing) + claim_status TEXT NOT NULL, + claim_confidence DECIMAL(5,4), + claim_vex_status TEXT, + + -- Result fields (extracted for indexing) + result_disposition TEXT NOT NULL, + result_score DECIMAL(5,4), + result_matched_rule TEXT, + result_quiet BOOLEAN NOT NULL DEFAULT FALSE, + + -- Provenance fields (extracted for indexing) + provenance_generator TEXT NOT NULL, + provenance_run_id TEXT, + provenance_policy_bundle_id TEXT, + + -- Inputs hash for deterministic verification + inputs_hash TEXT NOT NULL, + + -- Full verdict JSON + verdict_json JSONB NOT NULL, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, + + -- Primary key + PRIMARY KEY (tenant_id, verdict_id) +); + +-- Enable Row Level Security +ALTER TABLE stellaops.verdicts ENABLE ROW LEVEL SECURITY; + +-- RLS policy for tenant isolation +CREATE POLICY tenant_isolation_policy ON stellaops.verdicts + USING (tenant_id = current_setting('app.current_tenant_id')::uuid); + +-- Indexes for common query patterns + +-- Query by PURL (most common) +CREATE INDEX idx_verdicts_purl + ON stellaops.verdicts(tenant_id, subject_purl); + +-- Query by CVE +CREATE INDEX idx_verdicts_cve + ON stellaops.verdicts(tenant_id, subject_cve_id); + +-- Query by PURL + CVE combination +CREATE INDEX idx_verdicts_purl_cve + ON stellaops.verdicts(tenant_id, subject_purl, subject_cve_id); + +-- Query by image digest +CREATE INDEX idx_verdicts_image_digest + ON stellaops.verdicts(tenant_id, subject_image_digest) + WHERE subject_image_digest IS NOT NULL; + +-- Query by decision status +CREATE INDEX idx_verdicts_status + ON stellaops.verdicts(tenant_id, claim_status); + +-- Query by inputs hash (for cache invalidation) +CREATE INDEX idx_verdicts_inputs_hash + ON stellaops.verdicts(tenant_id, inputs_hash); + +-- Query by expiration (for cleanup) +CREATE INDEX idx_verdicts_expires + ON stellaops.verdicts(tenant_id, expires_at) + WHERE expires_at IS NOT NULL; + +-- Query by creation time (for timeline queries) +CREATE INDEX idx_verdicts_created + ON stellaops.verdicts(tenant_id, created_at DESC); + +-- Query by policy bundle (for policy version impact analysis) +CREATE INDEX idx_verdicts_policy_bundle + ON stellaops.verdicts(tenant_id, provenance_policy_bundle_id) + WHERE provenance_policy_bundle_id IS NOT NULL; + +-- GIN index for JSONB queries on verdict_json +CREATE INDEX idx_verdicts_json_gin + ON stellaops.verdicts USING GIN (verdict_json jsonb_path_ops); + +-- Comments +COMMENT ON TABLE stellaops.verdicts IS 'StellaVerdict artifacts - signed vulnerability disposition decisions'; +COMMENT ON COLUMN stellaops.verdicts.verdict_id IS 'Content-addressable ID (urn:stella:verdict:sha256:...)'; +COMMENT ON COLUMN stellaops.verdicts.inputs_hash IS 'Hash of inputs for deterministic verification'; +COMMENT ON COLUMN stellaops.verdicts.verdict_json IS 'Full StellaVerdict JSON including signatures'; diff --git a/src/__Libraries/StellaOps.Verdict/Persistence/PostgresVerdictStore.cs b/src/__Libraries/StellaOps.Verdict/Persistence/PostgresVerdictStore.cs new file mode 100644 index 000000000..c236b8a10 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Persistence/PostgresVerdictStore.cs @@ -0,0 +1,301 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using StellaOps.Verdict.Schema; + +namespace StellaOps.Verdict.Persistence; + +/// +/// PostgreSQL implementation of verdict store. +/// +public sealed class PostgresVerdictStore : IVerdictStore +{ + private readonly IDbContextFactory _contextFactory; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions; + + public PostgresVerdictStore( + IDbContextFactory contextFactory, + ILogger logger) + { + _contextFactory = contextFactory; + _logger = logger; + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + } + + public async Task StoreAsync(StellaVerdict verdict, Guid tenantId, CancellationToken cancellationToken = default) + { + try + { + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); + + var row = ToRow(verdict, tenantId); + var existing = await context.Verdicts + .FirstOrDefaultAsync(v => v.TenantId == tenantId && v.VerdictId == verdict.VerdictId, cancellationToken); + + bool wasUpdate = existing is not null; + if (wasUpdate) + { + context.Entry(existing!).CurrentValues.SetValues(row); + } + else + { + context.Verdicts.Add(row); + } + + await context.SaveChangesAsync(cancellationToken); + + _logger.LogInformation("Stored verdict {VerdictId} for tenant {TenantId} (update={WasUpdate})", + verdict.VerdictId, tenantId, wasUpdate); + + return VerdictStoreResult.Stored(verdict.VerdictId, wasUpdate); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to store verdict {VerdictId}", verdict.VerdictId); + return VerdictStoreResult.Failed($"Storage failed: {ex.Message}"); + } + } + + public async Task GetAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); + + var row = await context.Verdicts + .AsNoTracking() + .FirstOrDefaultAsync(v => v.TenantId == tenantId && v.VerdictId == verdictId, cancellationToken); + + return row is null ? null : FromRow(row); + } + + public async Task QueryAsync(VerdictQuery query, CancellationToken cancellationToken = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); + + var queryable = context.Verdicts + .AsNoTracking() + .Where(v => v.TenantId == query.TenantId); + + // Apply filters + if (!string.IsNullOrEmpty(query.Purl)) + { + queryable = queryable.Where(v => v.SubjectPurl.StartsWith(query.Purl)); + } + + if (!string.IsNullOrEmpty(query.CveId)) + { + queryable = queryable.Where(v => v.SubjectCveId == query.CveId); + } + + if (query.Status.HasValue) + { + var statusStr = query.Status.Value.ToString(); + queryable = queryable.Where(v => v.ClaimStatus == statusStr); + } + + if (!string.IsNullOrEmpty(query.ImageDigest)) + { + queryable = queryable.Where(v => v.SubjectImageDigest == query.ImageDigest); + } + + if (!string.IsNullOrEmpty(query.InputsHash)) + { + queryable = queryable.Where(v => v.InputsHash == query.InputsHash); + } + + if (query.CreatedAfter.HasValue) + { + queryable = queryable.Where(v => v.CreatedAt >= query.CreatedAfter.Value); + } + + if (query.CreatedBefore.HasValue) + { + queryable = queryable.Where(v => v.CreatedAt <= query.CreatedBefore.Value); + } + + if (!query.IncludeExpired) + { + var now = DateTimeOffset.UtcNow; + queryable = queryable.Where(v => v.ExpiresAt == null || v.ExpiresAt > now); + } + + // Get total count before pagination + var totalCount = await queryable.CountAsync(cancellationToken); + + // Apply sorting + queryable = query.SortBy switch + { + VerdictSortField.VerdictId => query.Descending + ? queryable.OrderByDescending(v => v.VerdictId) + : queryable.OrderBy(v => v.VerdictId), + VerdictSortField.Purl => query.Descending + ? queryable.OrderByDescending(v => v.SubjectPurl) + : queryable.OrderBy(v => v.SubjectPurl), + VerdictSortField.CveId => query.Descending + ? queryable.OrderByDescending(v => v.SubjectCveId) + : queryable.OrderBy(v => v.SubjectCveId), + VerdictSortField.Score => query.Descending + ? queryable.OrderByDescending(v => v.ResultScore) + : queryable.OrderBy(v => v.ResultScore), + _ => query.Descending + ? queryable.OrderByDescending(v => v.CreatedAt) + : queryable.OrderBy(v => v.CreatedAt), + }; + + // Apply pagination + var rows = await queryable + .Skip(query.Offset) + .Take(query.Limit) + .ToListAsync(cancellationToken); + + var verdicts = rows.Select(FromRow).ToImmutableArray(); + + return new VerdictQueryResult + { + Verdicts = verdicts, + TotalCount = totalCount, + Offset = query.Offset, + Limit = query.Limit, + }; + } + + public async Task ExistsAsync(string verdictId, Guid tenantId, CancellationToken cancellationToken = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); + + return await context.Verdicts + .AsNoTracking() + .AnyAsync(v => v.TenantId == tenantId && v.VerdictId == verdictId, cancellationToken); + } + + public async Task> GetBySubjectAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); + + var rows = await context.Verdicts + .AsNoTracking() + .Where(v => v.TenantId == tenantId && v.SubjectPurl == purl && v.SubjectCveId == cveId) + .OrderByDescending(v => v.CreatedAt) + .ToListAsync(cancellationToken); + + return rows.Select(FromRow).ToImmutableArray(); + } + + public async Task GetLatestAsync(string purl, string cveId, Guid tenantId, CancellationToken cancellationToken = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); + + var now = DateTimeOffset.UtcNow; + var row = await context.Verdicts + .AsNoTracking() + .Where(v => v.TenantId == tenantId && v.SubjectPurl == purl && v.SubjectCveId == cveId) + .Where(v => v.ExpiresAt == null || v.ExpiresAt > now) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + return row is null ? null : FromRow(row); + } + + public async Task DeleteExpiredAsync(Guid tenantId, DateTimeOffset asOf, CancellationToken cancellationToken = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); + + var deleted = await context.Verdicts + .Where(v => v.TenantId == tenantId && v.ExpiresAt.HasValue && v.ExpiresAt <= asOf) + .ExecuteDeleteAsync(cancellationToken); + + if (deleted > 0) + { + _logger.LogInformation("Deleted {Count} expired verdicts for tenant {TenantId}", deleted, tenantId); + } + + return deleted; + } + + private VerdictRow ToRow(StellaVerdict verdict, Guid tenantId) + { + var json = JsonSerializer.Serialize(verdict, _jsonOptions); + var inputsHash = ComputeInputsHash(verdict); + + DateTimeOffset? expiresAt = null; + if (!string.IsNullOrEmpty(verdict.Result.ExpiresAt) && + DateTimeOffset.TryParse(verdict.Result.ExpiresAt, out var parsed)) + { + expiresAt = parsed; + } + + return new VerdictRow + { + VerdictId = verdict.VerdictId, + TenantId = tenantId, + SubjectPurl = verdict.Subject.Purl, + SubjectCveId = verdict.Subject.VulnerabilityId, + SubjectComponentName = verdict.Subject.ComponentName, + SubjectComponentVersion = verdict.Subject.ComponentVersion, + SubjectImageDigest = verdict.Subject.ImageDigest, + SubjectDigest = verdict.Subject.SubjectDigest, + ClaimStatus = verdict.Claim.Status.ToString(), + ClaimConfidence = (decimal)verdict.Claim.Confidence, + ClaimVexStatus = verdict.Claim.VexStatus, + ResultDisposition = verdict.Result.Disposition, + ResultScore = (decimal)verdict.Result.Score, + ResultMatchedRule = verdict.Result.MatchedRule, + ResultQuiet = verdict.Result.Quiet, + ProvenanceGenerator = verdict.Provenance.Generator, + ProvenanceRunId = verdict.Provenance.RunId, + ProvenancePolicyBundleId = verdict.Provenance.PolicyBundleId, + InputsHash = inputsHash, + VerdictJson = json, + CreatedAt = DateTimeOffset.TryParse(verdict.Provenance.CreatedAt, out var createdAt) + ? createdAt + : DateTimeOffset.UtcNow, + ExpiresAt = expiresAt, + }; + } + + private StellaVerdict FromRow(VerdictRow row) + { + return JsonSerializer.Deserialize(row.VerdictJson, _jsonOptions)!; + } + + private static string ComputeInputsHash(StellaVerdict verdict) + { + // Hash the inputs section for deterministic verification + var inputsJson = JsonSerializer.Serialize(verdict.Inputs, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(inputsJson)); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } +} + +/// +/// DbContext for verdict persistence. +/// +public sealed class VerdictDbContext : DbContext +{ + public VerdictDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Verdicts { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.TenantId, e.VerdictId }); + entity.ToTable("verdicts", "stellaops"); + }); + } +} diff --git a/src/__Libraries/StellaOps.Verdict/Persistence/VerdictRow.cs b/src/__Libraries/StellaOps.Verdict/Persistence/VerdictRow.cs new file mode 100644 index 000000000..6051982eb --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Persistence/VerdictRow.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace StellaOps.Verdict.Persistence; + +/// +/// Database entity for verdict storage. +/// +[Table("verdicts", Schema = "stellaops")] +public sealed class VerdictRow +{ + [Column("verdict_id")] + public required string VerdictId { get; set; } + + [Column("tenant_id")] + public Guid TenantId { get; set; } + + // Subject fields + [Column("subject_purl")] + public required string SubjectPurl { get; set; } + + [Column("subject_cve_id")] + public required string SubjectCveId { get; set; } + + [Column("subject_component_name")] + public string? SubjectComponentName { get; set; } + + [Column("subject_component_version")] + public string? SubjectComponentVersion { get; set; } + + [Column("subject_image_digest")] + public string? SubjectImageDigest { get; set; } + + [Column("subject_digest")] + public string? SubjectDigest { get; set; } + + // Claim fields + [Column("claim_status")] + public required string ClaimStatus { get; set; } + + [Column("claim_confidence")] + public decimal? ClaimConfidence { get; set; } + + [Column("claim_vex_status")] + public string? ClaimVexStatus { get; set; } + + // Result fields + [Column("result_disposition")] + public required string ResultDisposition { get; set; } + + [Column("result_score")] + public decimal? ResultScore { get; set; } + + [Column("result_matched_rule")] + public string? ResultMatchedRule { get; set; } + + [Column("result_quiet")] + public bool ResultQuiet { get; set; } + + // Provenance fields + [Column("provenance_generator")] + public required string ProvenanceGenerator { get; set; } + + [Column("provenance_run_id")] + public string? ProvenanceRunId { get; set; } + + [Column("provenance_policy_bundle_id")] + public string? ProvenancePolicyBundleId { get; set; } + + // Inputs hash + [Column("inputs_hash")] + public required string InputsHash { get; set; } + + // Full JSON + [Column("verdict_json", TypeName = "jsonb")] + public required string VerdictJson { get; set; } + + // Timestamps + [Column("created_at")] + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + [Column("expires_at")] + public DateTimeOffset? ExpiresAt { get; set; } +} diff --git a/src/__Libraries/StellaOps.Verdict/Schema/StellaVerdict.cs b/src/__Libraries/StellaOps.Verdict/Schema/StellaVerdict.cs new file mode 100644 index 000000000..4f8c7fe48 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Schema/StellaVerdict.cs @@ -0,0 +1,635 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.Verdict.Schema; + +/// +/// StellaVerdict - Unified artifact consolidating vulnerability disposition decision. +/// Combines PolicyVerdict, ProofBundle, and KnowledgeSnapshot into a single signed artifact. +/// +public sealed record StellaVerdict +{ + /// + /// JSON-LD context for standards interoperability. + /// + [JsonPropertyName("@context")] + public string Context { get; init; } = "https://stella-ops.org/schemas/verdict/1.0"; + + /// + /// JSON-LD type annotation. + /// + [JsonPropertyName("@type")] + public string Type { get; init; } = "StellaVerdict"; + + /// + /// Content-addressable verdict ID (urn:stella:verdict:sha256:...). + /// + [JsonPropertyName("verdictId")] + public required string VerdictId { get; init; } + + /// + /// Schema version for evolution. + /// + [JsonPropertyName("version")] + public string Version { get; init; } = "1.0.0"; + + /// + /// The subject of the verdict (vulnerability + component). + /// + [JsonPropertyName("subject")] + public required VerdictSubject Subject { get; init; } + + /// + /// The claim being made (status + confidence + reason). + /// + [JsonPropertyName("claim")] + public required VerdictClaim Claim { get; init; } + + /// + /// Knowledge inputs that informed the decision. + /// + [JsonPropertyName("inputs")] + public required VerdictInputs Inputs { get; init; } + + /// + /// Evidence graph with nodes and edges (from ProofBundle). + /// + [JsonPropertyName("evidenceGraph")] + public VerdictEvidenceGraph? EvidenceGraph { get; init; } + + /// + /// Policy evaluation path showing rule chain. + /// + [JsonPropertyName("policyPath")] + public ImmutableArray PolicyPath { get; init; } = ImmutableArray.Empty; + + /// + /// Final result with disposition and score. + /// + [JsonPropertyName("result")] + public required VerdictResult Result { get; init; } + + /// + /// Provenance information (scanner, run, timestamp). + /// + [JsonPropertyName("provenance")] + public required VerdictProvenance Provenance { get; init; } + + /// + /// DSSE signatures for the verdict. + /// + [JsonPropertyName("signatures")] + public ImmutableArray Signatures { get; init; } = ImmutableArray.Empty; + + /// + /// Computes a content-addressable ID for the verdict. + /// Uses BLAKE3 hash of canonical JSON (excluding signatures). + /// + public string ComputeVerdictId() + { + var canonical = GetCanonicalPayload(); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); + return $"urn:stella:verdict:sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + /// + /// Gets the canonical JSON payload for signing (excludes signatures). + /// + public string GetCanonicalPayload() + { + var forSigning = new + { + context = Context, + type = Type, + version = Version, + subject = Subject, + claim = Claim, + inputs = Inputs, + evidenceGraph = EvidenceGraph, + policyPath = PolicyPath, + result = Result, + provenance = Provenance + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + return JsonSerializer.Serialize(forSigning, options); + } + + /// + /// Creates a new verdict with computed ID. + /// + public StellaVerdict WithComputedId() => this with { VerdictId = ComputeVerdictId() }; +} + +/// +/// Subject of the verdict - what is being assessed. +/// +public sealed record VerdictSubject +{ + /// + /// The vulnerability ID (CVE, GHSA, etc.). + /// + [JsonPropertyName("vulnerabilityId")] + public required string VulnerabilityId { get; init; } + + /// + /// The component PURL. + /// + [JsonPropertyName("purl")] + public required string Purl { get; init; } + + /// + /// Component name. + /// + [JsonPropertyName("componentName")] + public string? ComponentName { get; init; } + + /// + /// Component version. + /// + [JsonPropertyName("componentVersion")] + public string? ComponentVersion { get; init; } + + /// + /// Image digest if in container context. + /// + [JsonPropertyName("imageDigest")] + public string? ImageDigest { get; init; } + + /// + /// Content digest for subject identity. + /// + [JsonPropertyName("subjectDigest")] + public string? SubjectDigest { get; init; } +} + +/// +/// The claim being made by the verdict. +/// +public sealed record VerdictClaim +{ + /// + /// Verdict status (Pass, Blocked, Warned, etc.). + /// + [JsonPropertyName("status")] + public required VerdictStatus Status { get; init; } + + /// + /// Confidence level (0.0-1.0). + /// + [JsonPropertyName("confidence")] + public double Confidence { get; init; } = 1.0; + + /// + /// Confidence band label (High, Medium, Low, Unknown). + /// + [JsonPropertyName("confidenceBand")] + public string? ConfidenceBand { get; init; } + + /// + /// Human-readable reason for the verdict. + /// + [JsonPropertyName("reason")] + public string? Reason { get; init; } + + /// + /// VEX status if applicable. + /// + [JsonPropertyName("vexStatus")] + public string? VexStatus { get; init; } + + /// + /// VEX justification if applicable. + /// + [JsonPropertyName("vexJustification")] + public string? VexJustification { get; init; } +} + +/// +/// Verdict status values aligned with PolicyVerdictStatus. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum VerdictStatus +{ + /// Component passed all policy checks. + Pass, + /// Component is blocked by policy. + Blocked, + /// Finding is ignored per policy. + Ignored, + /// Warning issued but not blocking. + Warned, + /// Decision deferred for manual review. + Deferred, + /// Escalated for security team review. + Escalated, + /// Requires VEX statement to resolve. + RequiresVex +} + +/// +/// Final result of the verdict evaluation. +/// +public sealed record VerdictResult +{ + /// + /// K4 lattice disposition. + /// + [JsonPropertyName("disposition")] + public required string Disposition { get; init; } + + /// + /// Computed risk score. + /// + [JsonPropertyName("score")] + public double Score { get; init; } + + /// + /// Rule that matched. + /// + [JsonPropertyName("matchedRule")] + public string? MatchedRule { get; init; } + + /// + /// Action taken by the rule. + /// + [JsonPropertyName("ruleAction")] + public string? RuleAction { get; init; } + + /// + /// Whether the finding is quieted. + /// + [JsonPropertyName("quiet")] + public bool Quiet { get; init; } + + /// + /// Who/what quieted the finding. + /// + [JsonPropertyName("quietedBy")] + public string? QuietedBy { get; init; } + + /// + /// Expiration of this verdict (ISO 8601). + /// + [JsonPropertyName("expiresAt")] + public string? ExpiresAt { get; init; } +} + +/// +/// Provenance information for the verdict. +/// +public sealed record VerdictProvenance +{ + /// + /// Generator name. + /// + [JsonPropertyName("generator")] + public required string Generator { get; init; } + + /// + /// Generator version. + /// + [JsonPropertyName("generatorVersion")] + public string? GeneratorVersion { get; init; } + + /// + /// Scan/run ID. + /// + [JsonPropertyName("runId")] + public string? RunId { get; init; } + + /// + /// When the verdict was created (ISO 8601). + /// + [JsonPropertyName("createdAt")] + public required string CreatedAt { get; init; } + + /// + /// Policy bundle ID used. + /// + [JsonPropertyName("policyBundleId")] + public string? PolicyBundleId { get; init; } + + /// + /// Policy bundle version. + /// + [JsonPropertyName("policyBundleVersion")] + public string? PolicyBundleVersion { get; init; } +} + +/// +/// DSSE signature on the verdict. +/// +public sealed record VerdictSignature +{ + /// + /// Key ID used for signing. + /// + [JsonPropertyName("keyid")] + public required string KeyId { get; init; } + + /// + /// Signature algorithm. + /// + [JsonPropertyName("sig")] + public required string Sig { get; init; } + + /// + /// Certificate chain if available. + /// + [JsonPropertyName("cert")] + public string? Cert { get; init; } +} + +/// +/// Knowledge inputs that informed the verdict decision. +/// +public sealed record VerdictInputs +{ + /// + /// Advisory sources consulted. + /// + [JsonPropertyName("advisorySources")] + public ImmutableArray AdvisorySources { get; init; } = ImmutableArray.Empty; + + /// + /// VEX statements considered. + /// + [JsonPropertyName("vexStatements")] + public ImmutableArray VexStatements { get; init; } = ImmutableArray.Empty; + + /// + /// CVSS scores used in decision. + /// + [JsonPropertyName("cvssScores")] + public ImmutableArray CvssScores { get; init; } = ImmutableArray.Empty; + + /// + /// EPSS probability if available. + /// + [JsonPropertyName("epss")] + public VerdictEpssInput? Epss { get; init; } + + /// + /// KEV (Known Exploited Vulnerability) status. + /// + [JsonPropertyName("kev")] + public VerdictKevInput? Kev { get; init; } + + /// + /// Reachability analysis result. + /// + [JsonPropertyName("reachability")] + public VerdictReachabilityInput? Reachability { get; init; } +} + +/// +/// Advisory source input. +/// +public sealed record VerdictAdvisorySource +{ + /// Source identifier (e.g., "NVD", "OSV", "GHSA"). + [JsonPropertyName("source")] + public required string Source { get; init; } + + /// Advisory ID in that source. + [JsonPropertyName("advisoryId")] + public required string AdvisoryId { get; init; } + + /// When the advisory was fetched (ISO 8601). + [JsonPropertyName("fetchedAt")] + public string? FetchedAt { get; init; } + + /// Content hash of the advisory. + [JsonPropertyName("contentHash")] + public string? ContentHash { get; init; } +} + +/// +/// VEX statement input. +/// +public sealed record VerdictVexInput +{ + /// VEX document ID. + [JsonPropertyName("vexId")] + public required string VexId { get; init; } + + /// Issuer of the VEX statement. + [JsonPropertyName("issuer")] + public required string Issuer { get; init; } + + /// VEX status (not_affected, affected, fixed, under_investigation). + [JsonPropertyName("status")] + public required string Status { get; init; } + + /// Justification if not_affected. + [JsonPropertyName("justification")] + public string? Justification { get; init; } + + /// When the VEX was issued (ISO 8601). + [JsonPropertyName("timestamp")] + public string? Timestamp { get; init; } +} + +/// +/// CVSS score input. +/// +public sealed record VerdictCvssInput +{ + /// CVSS version (2.0, 3.0, 3.1, 4.0). + [JsonPropertyName("version")] + public required string Version { get; init; } + + /// Vector string. + [JsonPropertyName("vector")] + public required string Vector { get; init; } + + /// Base score. + [JsonPropertyName("baseScore")] + public double BaseScore { get; init; } + + /// Temporal score if available. + [JsonPropertyName("temporalScore")] + public double? TemporalScore { get; init; } + + /// Environmental score if available. + [JsonPropertyName("environmentalScore")] + public double? EnvironmentalScore { get; init; } + + /// Source of the CVSS score. + [JsonPropertyName("source")] + public string? Source { get; init; } +} + +/// +/// EPSS (Exploit Prediction Scoring System) input. +/// +public sealed record VerdictEpssInput +{ + /// EPSS probability (0.0-1.0). + [JsonPropertyName("probability")] + public double Probability { get; init; } + + /// EPSS percentile (0.0-1.0). + [JsonPropertyName("percentile")] + public double Percentile { get; init; } + + /// Date of the EPSS data (ISO 8601). + [JsonPropertyName("date")] + public required string Date { get; init; } +} + +/// +/// KEV (Known Exploited Vulnerability) input. +/// +public sealed record VerdictKevInput +{ + /// Whether the CVE is in KEV catalog. + [JsonPropertyName("inKev")] + public bool InKev { get; init; } + + /// Date added to KEV (ISO 8601). + [JsonPropertyName("dateAdded")] + public string? DateAdded { get; init; } + + /// Required action deadline (ISO 8601). + [JsonPropertyName("dueDate")] + public string? DueDate { get; init; } +} + +/// +/// Reachability analysis input. +/// +public sealed record VerdictReachabilityInput +{ + /// Whether vulnerable code is reachable. + [JsonPropertyName("isReachable")] + public bool IsReachable { get; init; } + + /// Confidence of reachability analysis (0.0-1.0). + [JsonPropertyName("confidence")] + public double Confidence { get; init; } + + /// Analysis method used. + [JsonPropertyName("method")] + public string? Method { get; init; } + + /// Call path to vulnerable code if reachable. + [JsonPropertyName("callPath")] + public ImmutableArray CallPath { get; init; } = ImmutableArray.Empty; +} + +/// +/// Evidence graph from proof bundle (content-addressable audit trail). +/// +public sealed record VerdictEvidenceGraph +{ + /// + /// Evidence nodes in the graph. + /// + [JsonPropertyName("nodes")] + public ImmutableArray Nodes { get; init; } = ImmutableArray.Empty; + + /// + /// Edges connecting evidence nodes. + /// + [JsonPropertyName("edges")] + public ImmutableArray Edges { get; init; } = ImmutableArray.Empty; + + /// + /// Root node ID (entry point for verification). + /// + [JsonPropertyName("root")] + public string? Root { get; init; } +} + +/// +/// Evidence node in the proof graph. +/// +public sealed record VerdictEvidenceNode +{ + /// Content-addressable node ID (hash). + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// Type of evidence (advisory, vex, scan_result, policy_eval, etc.). + [JsonPropertyName("type")] + public required string Type { get; init; } + + /// Human-readable label. + [JsonPropertyName("label")] + public string? Label { get; init; } + + /// Content hash algorithm. + [JsonPropertyName("hashAlgorithm")] + public string HashAlgorithm { get; init; } = "sha256"; + + /// Timestamp when evidence was captured (ISO 8601). + [JsonPropertyName("capturedAt")] + public string? CapturedAt { get; init; } + + /// URI to retrieve the full evidence if needed. + [JsonPropertyName("uri")] + public string? Uri { get; init; } + + /// Additional metadata. + [JsonPropertyName("metadata")] + public ImmutableDictionary? Metadata { get; init; } +} + +/// +/// Edge connecting evidence nodes. +/// +public sealed record VerdictEvidenceEdge +{ + /// Source node ID. + [JsonPropertyName("from")] + public required string From { get; init; } + + /// Target node ID. + [JsonPropertyName("to")] + public required string To { get; init; } + + /// Relationship type (derives_from, supersedes, validates, etc.). + [JsonPropertyName("relationship")] + public required string Relationship { get; init; } +} + +/// +/// Policy evaluation step in the decision path. +/// +public sealed record VerdictPolicyStep +{ + /// Rule ID that was evaluated. + [JsonPropertyName("ruleId")] + public required string RuleId { get; init; } + + /// Rule name for display. + [JsonPropertyName("ruleName")] + public string? RuleName { get; init; } + + /// Whether the rule matched. + [JsonPropertyName("matched")] + public bool Matched { get; init; } + + /// Action taken if matched (block, warn, ignore, defer, escalate). + [JsonPropertyName("action")] + public string? Action { get; init; } + + /// Reason for match/no-match. + [JsonPropertyName("reason")] + public string? Reason { get; init; } + + /// Evaluation order in the chain. + [JsonPropertyName("order")] + public int Order { get; init; } +} diff --git a/src/__Libraries/StellaOps.Verdict/Services/VerdictAssemblyService.cs b/src/__Libraries/StellaOps.Verdict/Services/VerdictAssemblyService.cs new file mode 100644 index 000000000..edd061cea --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Services/VerdictAssemblyService.cs @@ -0,0 +1,394 @@ +using System.Collections.Immutable; +using StellaOps.Policy; +using StellaOps.Policy.TrustLattice; +using StellaOps.Verdict.Schema; + +namespace StellaOps.Verdict.Services; + +/// +/// Service for assembling StellaVerdict from PolicyVerdict, ProofBundle, and knowledge inputs. +/// +public interface IVerdictAssemblyService +{ + /// + /// Assembles a StellaVerdict from the given inputs. + /// + StellaVerdict AssembleVerdict(VerdictAssemblyContext context); + + /// + /// Assembles multiple verdicts from a batch of contexts. + /// + ImmutableArray AssembleVerdicts(IEnumerable contexts); +} + +/// +/// Context containing all inputs needed to assemble a StellaVerdict. +/// +public sealed record VerdictAssemblyContext +{ + /// The vulnerability ID (CVE, GHSA, etc.). + public required string VulnerabilityId { get; init; } + + /// The component PURL. + public required string Purl { get; init; } + + /// Component name. + public string? ComponentName { get; init; } + + /// Component version. + public string? ComponentVersion { get; init; } + + /// Image digest if in container context. + public string? ImageDigest { get; init; } + + /// The policy verdict result. + public required PolicyVerdict PolicyVerdict { get; init; } + + /// The proof bundle with decision trace. + public ProofBundle? ProofBundle { get; init; } + + /// Knowledge inputs (advisories, VEX, CVSS, etc.). + public VerdictKnowledgeInputs? Knowledge { get; init; } + + /// Generator name (e.g., "StellaOps Scanner"). + public string Generator { get; init; } = "StellaOps"; + + /// Generator version. + public string? GeneratorVersion { get; init; } + + /// Scan/run ID. + public string? RunId { get; init; } +} + +/// +/// Knowledge inputs for verdict assembly. +/// +public sealed record VerdictKnowledgeInputs +{ + /// Advisory sources consulted. + public ImmutableArray AdvisorySources { get; init; } = ImmutableArray.Empty; + + /// VEX statements considered. + public ImmutableArray VexStatements { get; init; } = ImmutableArray.Empty; + + /// CVSS scores used. + public ImmutableArray CvssScores { get; init; } = ImmutableArray.Empty; + + /// EPSS data. + public EpssInput? Epss { get; init; } + + /// KEV status. + public KevInput? Kev { get; init; } + + /// Reachability analysis. + public ReachabilityInput? Reachability { get; init; } +} + +/// Advisory source input record. +public sealed record AdvisorySourceInput(string Source, string AdvisoryId, DateTimeOffset? FetchedAt = null, string? ContentHash = null); + +/// VEX statement input record. +public sealed record VexStatementInput(string VexId, string Issuer, string Status, string? Justification = null, DateTimeOffset? Timestamp = null); + +/// CVSS score input record. +public sealed record CvssScoreInput(string Version, string Vector, double BaseScore, double? TemporalScore = null, double? EnvironmentalScore = null, string? Source = null); + +/// EPSS input record. +public sealed record EpssInput(double Probability, double Percentile, DateOnly Date); + +/// KEV input record. +public sealed record KevInput(bool InKev, DateOnly? DateAdded = null, DateOnly? DueDate = null); + +/// Reachability input record. +public sealed record ReachabilityInput(bool IsReachable, double Confidence, string? Method = null, ImmutableArray? CallPath = null); + +/// +/// Default implementation of the verdict assembly service. +/// +public sealed class VerdictAssemblyService : IVerdictAssemblyService +{ + public StellaVerdict AssembleVerdict(VerdictAssemblyContext context) + { + var subject = BuildSubject(context); + var claim = BuildClaim(context.PolicyVerdict); + var inputs = BuildInputs(context.Knowledge); + var evidenceGraph = BuildEvidenceGraph(context.ProofBundle); + var policyPath = BuildPolicyPath(context.ProofBundle); + var result = BuildResult(context.PolicyVerdict, context.ProofBundle); + var provenance = BuildProvenance(context); + + var verdict = new StellaVerdict + { + VerdictId = string.Empty, // Will be computed + Subject = subject, + Claim = claim, + Inputs = inputs, + EvidenceGraph = evidenceGraph, + PolicyPath = policyPath, + Result = result, + Provenance = provenance, + }; + + return verdict.WithComputedId(); + } + + public ImmutableArray AssembleVerdicts(IEnumerable contexts) + { + return contexts.Select(AssembleVerdict).ToImmutableArray(); + } + + private static VerdictSubject BuildSubject(VerdictAssemblyContext context) + { + return new VerdictSubject + { + VulnerabilityId = context.VulnerabilityId, + Purl = context.Purl, + ComponentName = context.ComponentName, + ComponentVersion = context.ComponentVersion, + ImageDigest = context.ImageDigest, + SubjectDigest = ComputeSubjectDigest(context.VulnerabilityId, context.Purl), + }; + } + + private static string ComputeSubjectDigest(string vulnId, string purl) + { + var input = $"{vulnId}|{purl}"; + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input)); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static VerdictClaim BuildClaim(PolicyVerdict policyVerdict) + { + return new VerdictClaim + { + Status = MapPolicyStatus(policyVerdict.Status), + Confidence = policyVerdict.UnknownConfidence ?? 1.0, + ConfidenceBand = policyVerdict.ConfidenceBand, + Reason = policyVerdict.Notes, + VexStatus = null, // Populated from VEX inputs if available + VexJustification = null, + }; + } + + private static VerdictStatus MapPolicyStatus(PolicyVerdictStatus status) + { + return status switch + { + PolicyVerdictStatus.Pass => VerdictStatus.Pass, + PolicyVerdictStatus.Blocked => VerdictStatus.Blocked, + PolicyVerdictStatus.Ignored => VerdictStatus.Ignored, + PolicyVerdictStatus.Warned => VerdictStatus.Warned, + PolicyVerdictStatus.Deferred => VerdictStatus.Deferred, + PolicyVerdictStatus.Escalated => VerdictStatus.Escalated, + PolicyVerdictStatus.RequiresVex => VerdictStatus.RequiresVex, + _ => VerdictStatus.Pass, + }; + } + + private static VerdictInputs BuildInputs(VerdictKnowledgeInputs? knowledge) + { + if (knowledge is null) + { + return new VerdictInputs(); + } + + return new VerdictInputs + { + AdvisorySources = knowledge.AdvisorySources + .Select(a => new VerdictAdvisorySource + { + Source = a.Source, + AdvisoryId = a.AdvisoryId, + FetchedAt = a.FetchedAt?.ToString("o"), + ContentHash = a.ContentHash, + }) + .ToImmutableArray(), + VexStatements = knowledge.VexStatements + .Select(v => new VerdictVexInput + { + VexId = v.VexId, + Issuer = v.Issuer, + Status = v.Status, + Justification = v.Justification, + Timestamp = v.Timestamp?.ToString("o"), + }) + .ToImmutableArray(), + CvssScores = knowledge.CvssScores + .Select(c => new VerdictCvssInput + { + Version = c.Version, + Vector = c.Vector, + BaseScore = c.BaseScore, + TemporalScore = c.TemporalScore, + EnvironmentalScore = c.EnvironmentalScore, + Source = c.Source, + }) + .ToImmutableArray(), + Epss = knowledge.Epss is { } epss + ? new VerdictEpssInput + { + Probability = epss.Probability, + Percentile = epss.Percentile, + Date = epss.Date.ToString("yyyy-MM-dd"), + } + : null, + Kev = knowledge.Kev is { } kev + ? new VerdictKevInput + { + InKev = kev.InKev, + DateAdded = kev.DateAdded?.ToString("yyyy-MM-dd"), + DueDate = kev.DueDate?.ToString("yyyy-MM-dd"), + } + : null, + Reachability = knowledge.Reachability is { } reach + ? new VerdictReachabilityInput + { + IsReachable = reach.IsReachable, + Confidence = reach.Confidence, + Method = reach.Method, + CallPath = reach.CallPath ?? ImmutableArray.Empty, + } + : null, + }; + } + + private static VerdictEvidenceGraph? BuildEvidenceGraph(ProofBundle? proofBundle) + { + if (proofBundle is null) + { + return null; + } + + var nodes = new List(); + var edges = new List(); + + // Add input nodes + foreach (var input in proofBundle.Inputs) + { + nodes.Add(new VerdictEvidenceNode + { + Id = input.Digest, + Type = input.Type, + Label = $"{input.Type}: {input.Source ?? input.Digest}", + CapturedAt = input.IngestedAt.ToString("o"), + Uri = input.Source, + }); + } + + // Add claim nodes from proof bundle + foreach (var claim in proofBundle.Claims) + { + var claimId = claim.Id ?? claim.ComputeId(); + var assertionSummary = claim.Assertions.Count > 0 + ? string.Join(", ", claim.Assertions.Select(a => $"{a.Atom}={a.Value}")) + : "No assertions"; + nodes.Add(new VerdictEvidenceNode + { + Id = claimId, + Type = "claim", + Label = $"Claim: {assertionSummary}", + CapturedAt = claim.Time.IssuedAt.ToString("o"), + }); + } + + // Add decision nodes + foreach (var decision in proofBundle.Decisions) + { + var decisionId = $"decision:{decision.SubjectDigest}"; + nodes.Add(new VerdictEvidenceNode + { + Id = decisionId, + Type = "decision", + Label = $"Decision: {decision.Disposition}", + Metadata = ImmutableDictionary.Empty + .Add("disposition", decision.Disposition.ToString()) + .Add("matchedRule", decision.MatchedRule), + }); + + // Connect subject to decision + edges.Add(new VerdictEvidenceEdge + { + From = decision.SubjectDigest, + To = decisionId, + Relationship = "derives_from", + }); + } + + // Add policy bundle as root + var rootId = $"policy:{proofBundle.PolicyBundleId}"; + nodes.Add(new VerdictEvidenceNode + { + Id = rootId, + Type = "policy_bundle", + Label = $"Policy: {proofBundle.PolicyBundleId}", + Metadata = proofBundle.PolicyBundleVersion is not null + ? ImmutableDictionary.Empty.Add("version", proofBundle.PolicyBundleVersion) + : null, + }); + + return new VerdictEvidenceGraph + { + Nodes = nodes.ToImmutableArray(), + Edges = edges.ToImmutableArray(), + Root = rootId, + }; + } + + private static ImmutableArray BuildPolicyPath(ProofBundle? proofBundle) + { + if (proofBundle is null) + { + return ImmutableArray.Empty; + } + + var steps = new List(); + var order = 0; + + foreach (var decision in proofBundle.Decisions) + { + foreach (var traceStep in decision.Trace) + { + steps.Add(new VerdictPolicyStep + { + RuleId = traceStep.RuleName, // Use RuleName as RuleId + RuleName = traceStep.RuleName, + Matched = traceStep.Matched, + Action = traceStep.Matched ? decision.Disposition.ToString() : null, + Reason = traceStep.Condition, + Order = order++, + }); + } + } + + return steps.ToImmutableArray(); + } + + private static VerdictResult BuildResult(PolicyVerdict policyVerdict, ProofBundle? proofBundle) + { + var disposition = proofBundle?.Decisions.FirstOrDefault()?.Disposition.ToString() ?? policyVerdict.Status.ToString(); + + return new VerdictResult + { + Disposition = disposition, + Score = policyVerdict.Score, + MatchedRule = policyVerdict.RuleName, + RuleAction = policyVerdict.RuleAction, + Quiet = policyVerdict.Quiet, + QuietedBy = policyVerdict.QuietedBy, + ExpiresAt = null, // Can be set based on policy + }; + } + + private static VerdictProvenance BuildProvenance(VerdictAssemblyContext context) + { + return new VerdictProvenance + { + Generator = context.Generator, + GeneratorVersion = context.GeneratorVersion, + RunId = context.RunId, + CreatedAt = DateTimeOffset.UtcNow.ToString("o"), + PolicyBundleId = context.ProofBundle?.PolicyBundleId, + PolicyBundleVersion = context.ProofBundle?.PolicyBundleVersion, + }; + } +} diff --git a/src/__Libraries/StellaOps.Verdict/Services/VerdictSigningService.cs b/src/__Libraries/StellaOps.Verdict/Services/VerdictSigningService.cs new file mode 100644 index 000000000..32fc14022 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/Services/VerdictSigningService.cs @@ -0,0 +1,302 @@ +using System.Collections.Immutable; +using System.Text; +using StellaOps.Attestor.Envelope; +using StellaOps.Verdict.Schema; + +namespace StellaOps.Verdict.Services; + +/// +/// Service for DSSE signing of StellaVerdict artifacts. +/// +public interface IVerdictSigningService +{ + /// + /// Signs a verdict using the provided key. + /// + Task SignAsync(StellaVerdict verdict, EnvelopeKey key, CancellationToken cancellationToken = default); + + /// + /// Verifies signatures on a verdict. + /// + Task VerifyAsync(StellaVerdict verdict, IEnumerable trustedKeys, CancellationToken cancellationToken = default); + + /// + /// Adds a signature to an existing verdict. + /// + StellaVerdict AddSignature(StellaVerdict verdict, VerdictSignature signature); +} + +/// +/// Result of signing a verdict. +/// +public sealed record VerdictSigningResult +{ + /// Whether signing was successful. + public required bool Success { get; init; } + + /// The signed verdict with signature attached. + public StellaVerdict? SignedVerdict { get; init; } + + /// The DSSE envelope for external verification. + public DsseEnvelope? Envelope { get; init; } + + /// Error message if signing failed. + public string? Error { get; init; } + + public static VerdictSigningResult Succeeded(StellaVerdict verdict, DsseEnvelope envelope) => + new() { Success = true, SignedVerdict = verdict, Envelope = envelope }; + + public static VerdictSigningResult Failed(string error) => + new() { Success = false, Error = error }; +} + +/// +/// Result of verifying verdict signatures. +/// +public sealed record VerdictVerificationResult +{ + /// Whether all signatures are valid. + public required bool Valid { get; init; } + + /// Number of valid signatures. + public int ValidSignatureCount { get; init; } + + /// Number of invalid or unverifiable signatures. + public int InvalidSignatureCount { get; init; } + + /// Details of each signature verification. + public ImmutableArray Details { get; init; } = ImmutableArray.Empty; + + /// Error message if verification failed. + public string? Error { get; init; } + + public static VerdictVerificationResult Verified(ImmutableArray details) + { + var validCount = details.Count(d => d.Valid); + var invalidCount = details.Count(d => !d.Valid); + return new() + { + Valid = invalidCount == 0 && validCount > 0, + ValidSignatureCount = validCount, + InvalidSignatureCount = invalidCount, + Details = details, + }; + } + + public static VerdictVerificationResult Failed(string error) => + new() { Valid = false, Error = error }; +} + +/// +/// Detail of a single signature verification. +/// +public sealed record SignatureVerificationDetail +{ + /// Key ID of the signature. + public required string KeyId { get; init; } + + /// Whether the signature is valid. + public required bool Valid { get; init; } + + /// Error message if verification failed. + public string? Error { get; init; } +} + +/// +/// Default implementation of verdict signing service. +/// +public sealed class VerdictSigningService : IVerdictSigningService +{ + private const string VerdictPayloadType = "application/vnd.stella-ops.verdict+json"; + + private readonly EnvelopeSignatureService _signatureService; + + public VerdictSigningService() + : this(new EnvelopeSignatureService()) + { + } + + public VerdictSigningService(EnvelopeSignatureService signatureService) + { + _signatureService = signatureService; + } + + public Task SignAsync(StellaVerdict verdict, EnvelopeKey key, CancellationToken cancellationToken = default) + { + try + { + // Get the canonical payload for signing (excludes signatures) + var canonicalPayload = verdict.GetCanonicalPayload(); + var payloadBytes = Encoding.UTF8.GetBytes(canonicalPayload); + + // Compute Pre-Authentication Encoding (PAE) for DSSE + var paeBytes = ComputePae(VerdictPayloadType, payloadBytes); + + // Sign using the envelope service + var signResult = _signatureService.Sign(paeBytes, key, cancellationToken); + if (!signResult.IsSuccess) + { + return Task.FromResult(VerdictSigningResult.Failed( + signResult.Error?.Message ?? "Signing failed")); + } + + var envelopeSignature = signResult.Value; + + // Create the verdict signature + var verdictSignature = new VerdictSignature + { + KeyId = envelopeSignature.KeyId ?? key.KeyId, + Sig = Convert.ToBase64String(envelopeSignature.Value.ToArray()), + Cert = null, // Certificate chain can be added if available + }; + + // Add the signature to the verdict + var signedVerdict = AddSignature(verdict, verdictSignature); + + // Create DSSE envelope for external verification + var dsseSignature = DsseSignature.FromBytes(envelopeSignature.Value.Span, key.KeyId); + var envelope = new DsseEnvelope( + VerdictPayloadType, + payloadBytes, + new[] { dsseSignature }); + + return Task.FromResult(VerdictSigningResult.Succeeded(signedVerdict, envelope)); + } + catch (Exception ex) + { + return Task.FromResult(VerdictSigningResult.Failed($"Signing failed: {ex.Message}")); + } + } + + public Task VerifyAsync(StellaVerdict verdict, IEnumerable trustedKeys, CancellationToken cancellationToken = default) + { + try + { + if (verdict.Signatures.IsDefaultOrEmpty) + { + return Task.FromResult(VerdictVerificationResult.Failed("Verdict has no signatures")); + } + + var keysByKeyId = trustedKeys.ToDictionary(k => k.KeyId, StringComparer.Ordinal); + var details = new List(); + + // Get the canonical payload + var canonicalPayload = verdict.GetCanonicalPayload(); + var payloadBytes = Encoding.UTF8.GetBytes(canonicalPayload); + var paeBytes = ComputePae(VerdictPayloadType, payloadBytes); + + foreach (var signature in verdict.Signatures) + { + if (!keysByKeyId.TryGetValue(signature.KeyId, out var key)) + { + details.Add(new SignatureVerificationDetail + { + KeyId = signature.KeyId, + Valid = false, + Error = "No trusted key found for signature", + }); + continue; + } + + try + { + var signatureBytes = Convert.FromBase64String(signature.Sig); + var envelopeSignature = new EnvelopeSignature(key.KeyId, key.AlgorithmId, signatureBytes); + + var verifyResult = _signatureService.Verify(paeBytes, envelopeSignature, key, cancellationToken); + if (verifyResult.IsSuccess && verifyResult.Value) + { + details.Add(new SignatureVerificationDetail + { + KeyId = signature.KeyId, + Valid = true, + }); + } + else + { + details.Add(new SignatureVerificationDetail + { + KeyId = signature.KeyId, + Valid = false, + Error = verifyResult.Error?.Message ?? "Signature verification failed", + }); + } + } + catch (Exception ex) + { + details.Add(new SignatureVerificationDetail + { + KeyId = signature.KeyId, + Valid = false, + Error = $"Verification error: {ex.Message}", + }); + } + } + + return Task.FromResult(VerdictVerificationResult.Verified(details.ToImmutableArray())); + } + catch (Exception ex) + { + return Task.FromResult(VerdictVerificationResult.Failed($"Verification failed: {ex.Message}")); + } + } + + public StellaVerdict AddSignature(StellaVerdict verdict, VerdictSignature signature) + { + var existingSignatures = verdict.Signatures.IsDefaultOrEmpty + ? ImmutableArray.Empty + : verdict.Signatures; + + // Check for duplicate key ID + if (existingSignatures.Any(s => s.KeyId == signature.KeyId)) + { + // Replace existing signature from same key + var updated = existingSignatures + .Where(s => s.KeyId != signature.KeyId) + .Append(signature) + .ToImmutableArray(); + return verdict with { Signatures = updated }; + } + + return verdict with { Signatures = existingSignatures.Add(signature) }; + } + + /// + /// Computes the DSSE Pre-Authentication Encoding (PAE). + /// PAE(type, payload) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(payload) + SP + payload + /// + private static byte[] ComputePae(string payloadType, byte[] payload) + { + const string dssePrefix = "DSSEv1"; + var typeBytes = Encoding.UTF8.GetBytes(payloadType); + + using var ms = new MemoryStream(); + + // Write prefix + ms.Write(Encoding.UTF8.GetBytes(dssePrefix)); + ms.WriteByte((byte)' '); + + // Write type length + WriteLength(ms, typeBytes.Length); + ms.WriteByte((byte)' '); + + // Write type + ms.Write(typeBytes); + ms.WriteByte((byte)' '); + + // Write payload length + WriteLength(ms, payload.Length); + ms.WriteByte((byte)' '); + + // Write payload + ms.Write(payload); + + return ms.ToArray(); + } + + private static void WriteLength(MemoryStream ms, int length) + { + var lengthBytes = Encoding.UTF8.GetBytes(length.ToString()); + ms.Write(lengthBytes); + } +} diff --git a/src/__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj b/src/__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj new file mode 100644 index 000000000..bf58ea8d2 --- /dev/null +++ b/src/__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/__Libraries/__Tests/StellaOps.AuditPack.Tests/AuditPackExportServiceIntegrationTests.cs b/src/__Libraries/__Tests/StellaOps.AuditPack.Tests/AuditPackExportServiceIntegrationTests.cs new file mode 100644 index 000000000..1b0cb4385 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.AuditPack.Tests/AuditPackExportServiceIntegrationTests.cs @@ -0,0 +1,415 @@ +// ----------------------------------------------------------------------------- +// AuditPackExportServiceIntegrationTests.cs +// Sprint: SPRINT_1227_0005_0003_FE_copy_audit_export +// Task: T11 — Integration tests for export flow +// ----------------------------------------------------------------------------- + +using System.IO.Compression; +using System.Text.Json; +using FluentAssertions; +using StellaOps.AuditPack.Services; +using Xunit; + +namespace StellaOps.AuditPack.Tests; + +/// +/// Integration tests for AuditPackExportService. +/// Tests full export flows including ZIP, JSON, and DSSE formats. +/// +[Trait("Category", "Integration")] +[Trait("Category", "AuditPack")] +public class AuditPackExportServiceIntegrationTests +{ + private readonly AuditPackExportService _service; + + public AuditPackExportServiceIntegrationTests() + { + var mockWriter = new MockAuditBundleWriter(); + _service = new AuditPackExportService(mockWriter); + } + + #region ZIP Export Tests + + [Fact(DisplayName = "ZIP export produces valid archive with manifest")] + public async Task ExportAsZip_ProducesValidArchive() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Zip); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.ContentType.Should().Be("application/zip"); + result.Filename.Should().EndWith(".zip"); + result.Data.Should().NotBeNullOrEmpty(); + } + + [Fact(DisplayName = "ZIP export includes all requested segments")] + public async Task ExportAsZip_IncludesRequestedSegments() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Zip, includeAllSegments: true); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + + using var memoryStream = new MemoryStream(result.Data!); + using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read); + + // Verify manifest exists + archive.GetEntry("manifest.json").Should().NotBeNull(); + + // Verify segment entries exist + archive.GetEntry("sbom/sbom.json").Should().NotBeNull(); + archive.GetEntry("match/vulnerability-match.json").Should().NotBeNull(); + archive.GetEntry("reachability/reachability-analysis.json").Should().NotBeNull(); + archive.GetEntry("policy/policy-evaluation.json").Should().NotBeNull(); + } + + [Fact(DisplayName = "ZIP export includes attestations when requested")] + public async Task ExportAsZip_IncludesAttestations() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-123", + Format = ExportFormat.Zip, + Segments = [ExportSegment.Sbom], + IncludeAttestations = true, + IncludeProofChain = false, + Filename = "test-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + + using var memoryStream = new MemoryStream(result.Data!); + using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read); + + // Note: Attestations entry may be empty without repository + archive.Entries.Should().Contain(e => e.FullName.Contains("manifest.json")); + } + + [Fact(DisplayName = "ZIP export includes proof chain when requested")] + public async Task ExportAsZip_IncludesProofChain() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-123", + Format = ExportFormat.Zip, + Segments = [ExportSegment.Sbom], + IncludeAttestations = false, + IncludeProofChain = true, + Filename = "test-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + } + + [Fact(DisplayName = "ZIP manifest contains export metadata")] + public async Task ExportAsZip_ManifestContainsMetadata() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Zip); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + + using var memoryStream = new MemoryStream(result.Data!); + using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read); + + var manifestEntry = archive.GetEntry("manifest.json"); + manifestEntry.Should().NotBeNull(); + + using var reader = new StreamReader(manifestEntry!.Open()); + var manifestJson = await reader.ReadToEndAsync(); + var manifest = JsonSerializer.Deserialize(manifestJson, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + manifest.Should().NotBeNull(); + manifest!.ScanId.Should().Be("scan-123"); + manifest.Format.Should().Be("Zip"); + manifest.Version.Should().Be("1.0"); + } + + #endregion + + #region JSON Export Tests + + [Fact(DisplayName = "JSON export produces valid document")] + public async Task ExportAsJson_ProducesValidDocument() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Json); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.ContentType.Should().Be("application/json"); + result.Filename.Should().EndWith(".json"); + result.Data.Should().NotBeNullOrEmpty(); + + // Verify it's valid JSON + var doc = JsonDocument.Parse(result.Data!); + doc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123"); + doc.RootElement.GetProperty("format").GetString().Should().Be("json"); + doc.RootElement.GetProperty("version").GetString().Should().Be("1.0"); + } + + [Fact(DisplayName = "JSON export includes all segments")] + public async Task ExportAsJson_IncludesAllSegments() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Json, includeAllSegments: true); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + + var doc = JsonDocument.Parse(result.Data!); + var segments = doc.RootElement.GetProperty("segments"); + + segments.GetProperty("sbom").ValueKind.Should().NotBe(JsonValueKind.Undefined); + segments.GetProperty("match").ValueKind.Should().NotBe(JsonValueKind.Undefined); + segments.GetProperty("reachability").ValueKind.Should().NotBe(JsonValueKind.Undefined); + segments.GetProperty("policy").ValueKind.Should().NotBe(JsonValueKind.Undefined); + } + + [Fact(DisplayName = "JSON export has correct export timestamp")] + public async Task ExportAsJson_HasExportTimestamp() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Json); + var beforeExport = DateTimeOffset.UtcNow; + + // Act + var result = await _service.ExportAsync(request); + var afterExport = DateTimeOffset.UtcNow; + + // Assert + result.Success.Should().BeTrue(); + + var doc = JsonDocument.Parse(result.Data!); + var exportedAt = DateTimeOffset.Parse(doc.RootElement.GetProperty("exportedAt").GetString()!); + + exportedAt.Should().BeOnOrAfter(beforeExport); + exportedAt.Should().BeOnOrBefore(afterExport); + } + + #endregion + + #region DSSE Export Tests + + [Fact(DisplayName = "DSSE export produces valid envelope")] + public async Task ExportAsDsse_ProducesValidEnvelope() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Dsse); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.ContentType.Should().Be("application/vnd.dsse+json"); + result.Filename.Should().EndWith(".dsse.json"); + result.Data.Should().NotBeNullOrEmpty(); + + // Verify envelope structure + var envelope = JsonSerializer.Deserialize(result.Data!, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + envelope.Should().NotBeNull(); + envelope!.PayloadType.Should().Be("application/vnd.stellaops.audit-pack+json"); + envelope.Payload.Should().NotBeNullOrEmpty(); + } + + [Fact(DisplayName = "DSSE envelope payload is valid base64")] + public async Task ExportAsDsse_PayloadIsValidBase64() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Dsse); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + + var envelope = JsonSerializer.Deserialize(result.Data!, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + // Should not throw + var payloadBytes = Convert.FromBase64String(envelope!.Payload); + payloadBytes.Should().NotBeEmpty(); + + // Payload should be valid JSON + var payloadDoc = JsonDocument.Parse(payloadBytes); + payloadDoc.RootElement.GetProperty("scanId").GetString().Should().Be("scan-123"); + } + + #endregion + + #region Error Handling Tests + + [Fact(DisplayName = "Export returns error for unsupported format")] + public async Task Export_ReturnsError_ForUnsupportedFormat() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-123", + Format = (ExportFormat)999, // Invalid format + Segments = [ExportSegment.Sbom], + Filename = "test-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("Unsupported"); + } + + [Fact(DisplayName = "Export handles empty segments list")] + public async Task Export_HandlesEmptySegments() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-123", + Format = ExportFormat.Zip, + Segments = [], + Filename = "test-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNullOrEmpty(); + } + + [Fact(DisplayName = "Export includes finding IDs when specified")] + public async Task Export_IncludesFindingIds() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-123", + FindingIds = ["CVE-2024-0001@pkg:npm/lodash@4.17.21", "CVE-2024-0002@pkg:npm/express@4.18.0"], + Format = ExportFormat.Json, + Segments = [ExportSegment.Sbom], + Filename = "test-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + } + + #endregion + + #region Size Reporting Tests + + [Fact(DisplayName = "Export reports correct size")] + public async Task Export_ReportsCorrectSize() + { + // Arrange + var request = CreateTestRequest(ExportFormat.Json); + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.SizeBytes.Should().Be(result.Data!.Length); + } + + #endregion + + #region Helpers + + private static ExportRequest CreateTestRequest( + ExportFormat format, + bool includeAllSegments = false) + { + var segments = includeAllSegments + ? new List + { + ExportSegment.Sbom, + ExportSegment.Match, + ExportSegment.Reachability, + ExportSegment.Guards, + ExportSegment.Runtime, + ExportSegment.Policy + } + : new List { ExportSegment.Sbom, ExportSegment.Match }; + + return new ExportRequest + { + ScanId = "scan-123", + Format = format, + Segments = segments, + IncludeAttestations = false, + IncludeProofChain = false, + Filename = "test-export" + }; + } + + #endregion +} + +/// +/// Mock implementation of IAuditBundleWriter for testing. +/// +internal class MockAuditBundleWriter : IAuditBundleWriter +{ + public Task WriteAsync(string path, AuditBundle bundle, CancellationToken ct = default) + { + return Task.CompletedTask; + } +} + +/// +/// AuditBundle placeholder for testing. +/// +internal class AuditBundle { } + +/// +/// IAuditBundleWriter interface for testing. +/// +internal interface IAuditBundleWriter +{ + Task WriteAsync(string path, AuditBundle bundle, CancellationToken ct = default); +} diff --git a/src/__Libraries/__Tests/StellaOps.Canonicalization.Tests/StellaOps.Canonicalization.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Canonicalization.Tests/StellaOps.Canonicalization.Tests.csproj index 8cf783556..8d1b64751 100644 --- a/src/__Libraries/__Tests/StellaOps.Canonicalization.Tests/StellaOps.Canonicalization.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Canonicalization.Tests/StellaOps.Canonicalization.Tests.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/__Libraries/__Tests/StellaOps.Cryptography.Kms.Tests/StellaOps.Cryptography.Kms.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Cryptography.Kms.Tests/StellaOps.Cryptography.Kms.Tests.csproj index 63d0d1103..140f1f09b 100644 --- a/src/__Libraries/__Tests/StellaOps.Cryptography.Kms.Tests/StellaOps.Cryptography.Kms.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Cryptography.Kms.Tests/StellaOps.Cryptography.Kms.Tests.csproj @@ -6,9 +6,9 @@ false - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests.csproj index a85c71982..df93e8955 100644 --- a/src/__Libraries/__Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests/StellaOps.Cryptography.Plugin.OfflineVerification.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj index e094a7f78..badb6d823 100644 --- a/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.DeltaVerdict.Tests/StellaOps.DeltaVerdict.Tests.csproj b/src/__Libraries/__Tests/StellaOps.DeltaVerdict.Tests/StellaOps.DeltaVerdict.Tests.csproj index fa14b4877..95aeebaeb 100644 --- a/src/__Libraries/__Tests/StellaOps.DeltaVerdict.Tests/StellaOps.DeltaVerdict.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.DeltaVerdict.Tests/StellaOps.DeltaVerdict.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.Evidence.Persistence.Tests/StellaOps.Evidence.Persistence.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Evidence.Persistence.Tests/StellaOps.Evidence.Persistence.Tests.csproj index 1b417a368..f7f2f5410 100644 --- a/src/__Libraries/__Tests/StellaOps.Evidence.Persistence.Tests/StellaOps.Evidence.Persistence.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Evidence.Persistence.Tests/StellaOps.Evidence.Persistence.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.Evidence.Tests/StellaOps.Evidence.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Evidence.Tests/StellaOps.Evidence.Tests.csproj index 8ee16c813..3b45dae8d 100644 --- a/src/__Libraries/__Tests/StellaOps.Evidence.Tests/StellaOps.Evidence.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Evidence.Tests/StellaOps.Evidence.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj index 84a317d4d..63adb84fe 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/StellaOps.Infrastructure.Postgres.Tests.csproj @@ -11,9 +11,9 @@ - + - + diff --git a/src/__Libraries/__Tests/StellaOps.Messaging.Transport.Valkey.Tests/StellaOps.Messaging.Transport.Valkey.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Messaging.Transport.Valkey.Tests/StellaOps.Messaging.Transport.Valkey.Tests.csproj index e5fbade5b..8c081c198 100644 --- a/src/__Libraries/__Tests/StellaOps.Messaging.Transport.Valkey.Tests/StellaOps.Messaging.Transport.Valkey.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Messaging.Transport.Valkey.Tests/StellaOps.Messaging.Transport.Valkey.Tests.csproj @@ -21,14 +21,14 @@ - + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Metrics.Tests/StellaOps.Metrics.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Metrics.Tests/StellaOps.Metrics.Tests.csproj index 2ccef5318..5a6147b61 100644 --- a/src/__Libraries/__Tests/StellaOps.Metrics.Tests/StellaOps.Metrics.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Metrics.Tests/StellaOps.Metrics.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.Microservice.SourceGen.Tests/StellaOps.Microservice.SourceGen.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Microservice.SourceGen.Tests/StellaOps.Microservice.SourceGen.Tests.csproj index b79c5cd89..95debf022 100644 --- a/src/__Libraries/__Tests/StellaOps.Microservice.SourceGen.Tests/StellaOps.Microservice.SourceGen.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Microservice.SourceGen.Tests/StellaOps.Microservice.SourceGen.Tests.csproj @@ -19,9 +19,9 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -29,9 +29,9 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + diff --git a/src/__Libraries/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj index 821893f1d..e3eaa7fab 100644 --- a/src/__Libraries/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj @@ -20,9 +20,9 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -30,10 +30,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + diff --git a/src/__Libraries/__Tests/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj index 7b4b7fc3b..2194aaa2f 100644 --- a/src/__Libraries/__Tests/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Plugin.Tests/StellaOps.Plugin.Tests.csproj @@ -19,11 +19,11 @@ - + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Provcache.Tests/StellaOps.Provcache.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Provcache.Tests/StellaOps.Provcache.Tests.csproj index 897419464..fff022ed0 100644 --- a/src/__Libraries/__Tests/StellaOps.Provcache.Tests/StellaOps.Provcache.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Provcache.Tests/StellaOps.Provcache.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/CanonicalSerializerTests.cs b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/CanonicalSerializerTests.cs new file mode 100644 index 000000000..d32a21f1a --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/CanonicalSerializerTests.cs @@ -0,0 +1,293 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Collections.Immutable; +using System.Text; +using System.Text.Json; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; +using Xunit; + +namespace StellaOps.ReachGraph.Tests; + +public class CanonicalSerializerTests +{ + private readonly CanonicalReachGraphSerializer _serializer = new(); + + [Fact] + public void Serialization_WithSameInput_ProducesSameOutput() + { + // Arrange + var graph = CreateSampleGraph(); + + // Act + var result1 = _serializer.SerializeMinimal(graph); + var result2 = _serializer.SerializeMinimal(graph); + + // Assert + Assert.Equal(result1, result2); + } + + [Fact] + public void Serialization_NodeOrder_IsLexicographic() + { + // Arrange + var graph = CreateGraphWithUnorderedNodes(); + + // Act + var json = _serializer.SerializePretty(graph); + var deserialized = _serializer.Deserialize(json); + + // Assert + Assert.True(IsLexicographicallySorted(deserialized.Nodes, n => n.Id)); + } + + [Fact] + public void Serialization_EdgeOrder_IsLexicographicByFromThenTo() + { + // Arrange + var graph = CreateGraphWithUnorderedEdges(); + + // Act + var json = _serializer.SerializePretty(graph); + var deserialized = _serializer.Deserialize(json); + + // Assert + var edges = deserialized.Edges.ToArray(); + for (var i = 1; i < edges.Length; i++) + { + var comparison = string.Compare(edges[i - 1].From, edges[i].From, StringComparison.Ordinal); + if (comparison == 0) + { + comparison = string.Compare(edges[i - 1].To, edges[i].To, StringComparison.Ordinal); + } + Assert.True(comparison <= 0, $"Edges not sorted: {edges[i - 1].From}->{edges[i - 1].To} should come before {edges[i].From}->{edges[i].To}"); + } + } + + [Fact] + public void Serialization_NullFields_AreOmitted() + { + // Arrange + var graph = CreateGraphWithNullFields(); + + // Act + var json = Encoding.UTF8.GetString(_serializer.SerializeMinimal(graph)); + + // Assert + Assert.DoesNotContain("\"cves\":null", json); + Assert.DoesNotContain("\"signatures\":null", json); + Assert.DoesNotContain("\"vex\":null", json); + } + + [Fact] + public void Serialization_Timestamps_AreUtcIso8601WithMilliseconds() + { + // Arrange + var computedAt = new DateTimeOffset(2025, 12, 27, 10, 30, 45, 123, TimeSpan.Zero); + var graph = CreateSampleGraph() with + { + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs { Sbom = "sha256:abc123" }, + ComputedAt = computedAt, + Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolchain") + } + }; + + // Act + var json = Encoding.UTF8.GetString(_serializer.SerializeMinimal(graph)); + + // Assert + Assert.Contains("2025-12-27T10:30:45.123Z", json); + } + + [Fact] + public void Roundtrip_PreservesAllData() + { + // Arrange + var original = CreateCompleteGraph(); + + // Act + var bytes = _serializer.SerializeMinimal(original); + var restored = _serializer.Deserialize(bytes); + + // Assert + Assert.Equal(original.SchemaVersion, restored.SchemaVersion); + Assert.Equal(original.Artifact.Name, restored.Artifact.Name); + Assert.Equal(original.Artifact.Digest, restored.Artifact.Digest); + Assert.Equal(original.Scope.Entrypoints.Length, restored.Scope.Entrypoints.Length); + Assert.Equal(original.Nodes.Length, restored.Nodes.Length); + Assert.Equal(original.Edges.Length, restored.Edges.Length); + } + + [Fact] + public void Deserialize_ValidJson_Succeeds() + { + // Arrange + var json = """ + { + "schemaVersion": "reachgraph.min@v1", + "artifact": {"name": "test", "digest": "sha256:abc", "env": ["linux/amd64"]}, + "scope": {"entrypoints": ["/main"], "selectors": ["prod"]}, + "nodes": [{"id": "n1", "kind": "function", "ref": "main()"}], + "edges": [{"from": "n1", "to": "n2", "why": {"type": "import", "confidence": 1.0}}], + "provenance": { + "inputs": {"sbom": "sha256:sbom123"}, + "computedAt": "2025-12-27T10:00:00.000Z", + "analyzer": {"name": "test", "version": "1.0.0", "toolchainDigest": "sha256:tc"} + } + } + """; + + // Act + var graph = _serializer.Deserialize(json); + + // Assert + Assert.Equal("reachgraph.min@v1", graph.SchemaVersion); + Assert.Equal("test", graph.Artifact.Name); + Assert.Single(graph.Nodes); + Assert.Single(graph.Edges); + } + + private static ReachGraphMinimal CreateSampleGraph() => new() + { + Artifact = new ReachGraphArtifact("test-app", "sha256:abc123", ["linux/amd64"]), + Scope = new ReachGraphScope( + ["/app/main"], + ["prod"] + ), + Nodes = + [ + new ReachGraphNode { Id = "sha256:001", Kind = ReachGraphNodeKind.Function, Ref = "main()" }, + new ReachGraphNode { Id = "sha256:002", Kind = ReachGraphNodeKind.Function, Ref = "process()" } + ], + Edges = + [ + new ReachGraphEdge + { + From = "sha256:001", + To = "sha256:002", + Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } + } + ], + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs { Sbom = "sha256:sbom123" }, + ComputedAt = DateTimeOffset.UtcNow, + Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolchain") + } + }; + + private static ReachGraphMinimal CreateGraphWithUnorderedNodes() => CreateSampleGraph() with + { + Nodes = + [ + new ReachGraphNode { Id = "sha256:zzz", Kind = ReachGraphNodeKind.Function, Ref = "z()" }, + new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" }, + new ReachGraphNode { Id = "sha256:mmm", Kind = ReachGraphNodeKind.Function, Ref = "m()" } + ] + }; + + private static ReachGraphMinimal CreateGraphWithUnorderedEdges() => CreateSampleGraph() with + { + Edges = + [ + new ReachGraphEdge { From = "sha256:bbb", To = "sha256:ccc", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } }, + new ReachGraphEdge { From = "sha256:aaa", To = "sha256:zzz", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } }, + new ReachGraphEdge { From = "sha256:aaa", To = "sha256:bbb", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } } + ] + }; + + private static ReachGraphMinimal CreateGraphWithNullFields() => new() + { + Artifact = new ReachGraphArtifact("test", "sha256:abc", []), + Scope = new ReachGraphScope([], [], null), // cves is null + Nodes = [], + Edges = [], + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs { Sbom = "sha256:sbom" }, // vex, callgraph, etc. are null + ComputedAt = DateTimeOffset.UtcNow, + Analyzer = new ReachGraphAnalyzer("test", "1.0", "sha256:tc") + }, + Signatures = null // signatures is null + }; + + private static ReachGraphMinimal CreateCompleteGraph() => new() + { + Artifact = new ReachGraphArtifact("complete-app:v1", "sha256:complete123", ["linux/amd64", "linux/arm64"]), + Scope = new ReachGraphScope( + ["/app/main", "/app/worker"], + ["prod", "staging"], + ["CVE-2024-1234", "CVE-2024-5678"] + ), + Nodes = + [ + new ReachGraphNode + { + Id = "sha256:entry", + Kind = ReachGraphNodeKind.Function, + Ref = "main()", + File = "src/main.ts", + Line = 1, + IsEntrypoint = true + }, + new ReachGraphNode + { + Id = "sha256:sink", + Kind = ReachGraphNodeKind.Function, + Ref = "vulnerable()", + File = "node_modules/vuln/index.js", + Line = 50, + IsSink = true + } + ], + Edges = + [ + new ReachGraphEdge + { + From = "sha256:entry", + To = "sha256:sink", + Why = new EdgeExplanation + { + Type = EdgeExplanationType.EnvGuard, + Loc = "src/main.ts:10", + Guard = "DEBUG=true", + Confidence = 0.9, + Metadata = new Dictionary { ["source"] = "static-analysis" }.ToImmutableDictionary() + } + } + ], + Provenance = new ReachGraphProvenance + { + Intoto = ["intoto:abc123"], + Inputs = new ReachGraphInputs + { + Sbom = "sha256:sbom", + Vex = "sha256:vex", + Callgraph = "sha256:cg", + RuntimeFacts = "sha256:rt", + Policy = "sha256:policy" + }, + ComputedAt = new DateTimeOffset(2025, 12, 27, 10, 0, 0, TimeSpan.Zero), + Analyzer = new ReachGraphAnalyzer("stellaops", "1.0.0", "sha256:toolchain") + }, + Signatures = + [ + new ReachGraphSignature("key-1", "sig-base64-1"), + new ReachGraphSignature("key-2", "sig-base64-2") + ] + }; + + private static bool IsLexicographicallySorted(ImmutableArray items, Func keySelector) + { + for (var i = 1; i < items.Length; i++) + { + if (string.Compare(keySelector(items[i - 1]), keySelector(items[i]), StringComparison.Ordinal) > 0) + { + return false; + } + } + return true; + } +} diff --git a/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/DigestComputerTests.cs b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/DigestComputerTests.cs new file mode 100644 index 000000000..103102261 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/DigestComputerTests.cs @@ -0,0 +1,214 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using StellaOps.ReachGraph.Hashing; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; +using Xunit; + +namespace StellaOps.ReachGraph.Tests; + +public class DigestComputerTests +{ + private readonly CanonicalReachGraphSerializer _serializer = new(); + private readonly ReachGraphDigestComputer _digestComputer; + + public DigestComputerTests() + { + _digestComputer = new ReachGraphDigestComputer(_serializer); + } + + [Fact] + public void ComputeDigest_WithSameInput_ProducesSameDigest() + { + // Arrange + var graph = CreateSampleGraph(); + + // Act + var digest1 = _digestComputer.ComputeDigest(graph); + var digest2 = _digestComputer.ComputeDigest(graph); + + // Assert + Assert.Equal(digest1, digest2); + } + + [Fact] + public void ComputeDigest_ReturnsBlake3Format() + { + // Arrange + var graph = CreateSampleGraph(); + + // Act + var digest = _digestComputer.ComputeDigest(graph); + + // Assert + Assert.StartsWith("blake3:", digest); + Assert.Equal(71, digest.Length); // "blake3:" (7) + 64 hex chars + } + + [Fact] + public void ComputeDigest_ExcludesSignatures() + { + // Arrange + var unsigned = CreateSampleGraph(); + var signed = unsigned with + { + Signatures = [new ReachGraphSignature("key-1", "sig-base64")] + }; + + // Act + var digestUnsigned = _digestComputer.ComputeDigest(unsigned); + var digestSigned = _digestComputer.ComputeDigest(signed); + + // Assert - signatures should not affect digest + Assert.Equal(digestUnsigned, digestSigned); + } + + [Fact] + public void ComputeDigest_DifferentInputs_ProduceDifferentDigests() + { + // Arrange + var graph1 = CreateSampleGraph(); + var graph2 = graph1 with + { + Artifact = new ReachGraphArtifact("different-app", "sha256:different", ["linux/amd64"]) + }; + + // Act + var digest1 = _digestComputer.ComputeDigest(graph1); + var digest2 = _digestComputer.ComputeDigest(graph2); + + // Assert + Assert.NotEqual(digest1, digest2); + } + + [Fact] + public void VerifyDigest_ValidDigest_ReturnsTrue() + { + // Arrange + var graph = CreateSampleGraph(); + var digest = _digestComputer.ComputeDigest(graph); + + // Act + var result = _digestComputer.VerifyDigest(graph, digest); + + // Assert + Assert.True(result); + } + + [Fact] + public void VerifyDigest_InvalidDigest_ReturnsFalse() + { + // Arrange + var graph = CreateSampleGraph(); + var wrongDigest = "blake3:0000000000000000000000000000000000000000000000000000000000000000"; + + // Act + var result = _digestComputer.VerifyDigest(graph, wrongDigest); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsValidBlake3Digest_ValidFormat_ReturnsTrue() + { + // Arrange + var validDigest = "blake3:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + + // Act + var result = ReachGraphDigestComputer.IsValidBlake3Digest(validDigest); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("sha256:abcdef")] // Wrong algorithm + [InlineData("blake3:short")] // Too short + [InlineData("blake3:ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ")] // Invalid hex + [InlineData("")] // Empty + [InlineData("blake3")] // No colon + public void IsValidBlake3Digest_InvalidFormat_ReturnsFalse(string digest) + { + // Act + var result = ReachGraphDigestComputer.IsValidBlake3Digest(digest); + + // Assert + Assert.False(result); + } + + [Fact] + public void ParseDigest_ValidFormat_ReturnsComponents() + { + // Arrange + var digest = "blake3:abc123def456"; + + // Act + var result = ReachGraphDigestComputer.ParseDigest(digest); + + // Assert + Assert.NotNull(result); + Assert.Equal("blake3", result.Value.Algorithm); + Assert.Equal("abc123def456", result.Value.Hash); + } + + [Theory] + [InlineData("")] + [InlineData("nocolon")] + [InlineData(":noleft")] + [InlineData("noright:")] + public void ParseDigest_InvalidFormat_ReturnsNull(string digest) + { + // Act + var result = ReachGraphDigestComputer.ParseDigest(digest); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ComputeDigest_IsDeterministic_AcrossNodeOrdering() + { + // Arrange - nodes in different order + var graph1 = CreateSampleGraph() with + { + Nodes = + [ + new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" }, + new ReachGraphNode { Id = "sha256:bbb", Kind = ReachGraphNodeKind.Function, Ref = "b()" } + ] + }; + var graph2 = CreateSampleGraph() with + { + Nodes = + [ + new ReachGraphNode { Id = "sha256:bbb", Kind = ReachGraphNodeKind.Function, Ref = "b()" }, + new ReachGraphNode { Id = "sha256:aaa", Kind = ReachGraphNodeKind.Function, Ref = "a()" } + ] + }; + + // Act + var digest1 = _digestComputer.ComputeDigest(graph1); + var digest2 = _digestComputer.ComputeDigest(graph2); + + // Assert - canonical serialization should produce same digest regardless of input order + Assert.Equal(digest1, digest2); + } + + private static ReachGraphMinimal CreateSampleGraph() => new() + { + Artifact = new ReachGraphArtifact("test-app", "sha256:abc123", ["linux/amd64"]), + Scope = new ReachGraphScope(["/app/main"], ["prod"]), + Nodes = + [ + new ReachGraphNode { Id = "sha256:001", Kind = ReachGraphNodeKind.Function, Ref = "main()" } + ], + Edges = [], + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs { Sbom = "sha256:sbom123" }, + ComputedAt = new DateTimeOffset(2025, 12, 27, 10, 0, 0, TimeSpan.Zero), + Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolchain") + } + }; +} diff --git a/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/EdgeExplanationTests.cs b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/EdgeExplanationTests.cs new file mode 100644 index 000000000..e0b195e16 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/EdgeExplanationTests.cs @@ -0,0 +1,174 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Collections.Immutable; +using StellaOps.ReachGraph.Schema; +using Xunit; + +namespace StellaOps.ReachGraph.Tests; + +public class EdgeExplanationTests +{ + [Theory] + [InlineData(EdgeExplanationType.Import)] + [InlineData(EdgeExplanationType.DynamicLoad)] + [InlineData(EdgeExplanationType.Reflection)] + [InlineData(EdgeExplanationType.Ffi)] + [InlineData(EdgeExplanationType.EnvGuard)] + [InlineData(EdgeExplanationType.FeatureFlag)] + [InlineData(EdgeExplanationType.PlatformArch)] + [InlineData(EdgeExplanationType.TaintGate)] + [InlineData(EdgeExplanationType.LoaderRule)] + [InlineData(EdgeExplanationType.DirectCall)] + [InlineData(EdgeExplanationType.Unknown)] + public void EdgeExplanationType_AllValues_AreValid(EdgeExplanationType type) + { + // Arrange + var explanation = new EdgeExplanation + { + Type = type, + Confidence = 1.0 + }; + + // Assert + Assert.Equal(type, explanation.Type); + } + + [Fact] + public void EdgeExplanation_WithAllProperties_CreatesValidRecord() + { + // Arrange + var metadata = new Dictionary + { + ["source"] = "static-analysis", + ["tool"] = "stellaops-scanner" + }.ToImmutableDictionary(); + + // Act + var explanation = new EdgeExplanation + { + Type = EdgeExplanationType.EnvGuard, + Loc = "src/main.ts:42", + Guard = "NODE_ENV=production", + Confidence = 0.95, + Metadata = metadata + }; + + // Assert + Assert.Equal(EdgeExplanationType.EnvGuard, explanation.Type); + Assert.Equal("src/main.ts:42", explanation.Loc); + Assert.Equal("NODE_ENV=production", explanation.Guard); + Assert.Equal(0.95, explanation.Confidence); + Assert.Equal(2, explanation.Metadata!.Count); + Assert.Equal("static-analysis", explanation.Metadata["source"]); + } + + [Fact] + public void EdgeExplanation_MinimalProperties_CreatesValidRecord() + { + // Act + var explanation = new EdgeExplanation + { + Type = EdgeExplanationType.Import, + Confidence = 1.0 + }; + + // Assert + Assert.Equal(EdgeExplanationType.Import, explanation.Type); + Assert.Null(explanation.Loc); + Assert.Null(explanation.Guard); + Assert.Equal(1.0, explanation.Confidence); + Assert.Null(explanation.Metadata); + } + + [Theory] + [InlineData(0.0)] + [InlineData(0.5)] + [InlineData(1.0)] + public void EdgeExplanation_Confidence_AcceptsValidRange(double confidence) + { + // Act + var explanation = new EdgeExplanation + { + Type = EdgeExplanationType.DynamicLoad, + Confidence = confidence + }; + + // Assert + Assert.Equal(confidence, explanation.Confidence); + } + + [Fact] + public void ReachGraphEdge_WithExplanation_CreatesValidRecord() + { + // Arrange + var explanation = new EdgeExplanation + { + Type = EdgeExplanationType.FeatureFlag, + Loc = "config.ts:10", + Guard = "FEATURE_X=true", + Confidence = 0.9 + }; + + // Act + var edge = new ReachGraphEdge + { + From = "sha256:source", + To = "sha256:target", + Why = explanation + }; + + // Assert + Assert.Equal("sha256:source", edge.From); + Assert.Equal("sha256:target", edge.To); + Assert.Equal(EdgeExplanationType.FeatureFlag, edge.Why.Type); + Assert.Equal("FEATURE_X=true", edge.Why.Guard); + } + + [Fact] + public void EdgeExplanationType_Enum_HasExpectedCount() + { + // Act + var values = Enum.GetValues(); + + // Assert - ensures we don't accidentally add/remove types without updating tests + Assert.Equal(11, values.Length); + } + + [Fact] + public void EdgeExplanationType_Unknown_IsDefault() + { + // Arrange + EdgeExplanationType defaultValue = default; + + // Assert - Unknown should be the first enum value (0) + // Actually Import is 0, Unknown is last. Let's verify the enum structure + Assert.Equal(EdgeExplanationType.Import, defaultValue); + } + + [Fact] + public void EdgeExplanation_GuardPatterns_ArePreserved() + { + // Test various guard pattern formats + var guards = new[] + { + "DEBUG=true", + "NODE_ENV=production", + "FEATURE_X=truthy", + "platform=linux", + "os.name=Linux", + "config.enableNewFeature=true" + }; + + foreach (var guard in guards) + { + var explanation = new EdgeExplanation + { + Type = EdgeExplanationType.EnvGuard, + Guard = guard, + Confidence = 0.9 + }; + + Assert.Equal(guard, explanation.Guard); + } + } +} diff --git a/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/Fixtures/feature-flag-guards.reachgraph.min.json b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/Fixtures/feature-flag-guards.reachgraph.min.json new file mode 100644 index 000000000..a3fdefe1f --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/Fixtures/feature-flag-guards.reachgraph.min.json @@ -0,0 +1 @@ +{"schemaVersion":"reachgraph.min@v1","artifact":{"name":"feature-app:v2.0.0","digest":"sha256:feature123def456789abc123def456789abc123def456789abc123def456789ab","env":["linux/amd64","linux/arm64"]},"scope":{"entrypoints":["/app/server"],"selectors":["prod","staging"],"cves":["CVE-2024-1234"]},"nodes":[{"id":"sha256:entry1","kind":"function","ref":"handleRequest()","file":"src/server.ts","line":10,"isEntrypoint":true},{"id":"sha256:check1","kind":"function","ref":"checkFeatureFlag()","file":"src/flags.ts","line":25},{"id":"sha256:vuln1","kind":"function","ref":"vulnerable.process()","file":"node_modules/vulnerable/index.js","line":100,"isSink":true}],"edges":[{"from":"sha256:entry1","to":"sha256:check1","why":{"type":"import","confidence":1.0,"loc":"src/server.ts:15"}},{"from":"sha256:check1","to":"sha256:vuln1","why":{"type":"featureFlag","confidence":0.9,"loc":"src/flags.ts:30","guard":"ENABLE_NEW_PROCESSOR=true"}}],"provenance":{"inputs":{"sbom":"sha256:sbom456def789abc123def456789abc123def456789abc123def456789abc123de","vex":"sha256:vex789abc123def456789abc123def456789abc123def456789abc123def456789"},"computedAt":"2025-12-27T11:00:00.000Z","analyzer":{"name":"stellaops-scanner","version":"1.0.0","toolchainDigest":"sha256:toolchain123456789"}}} \ No newline at end of file diff --git a/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/Fixtures/simple-single-path.reachgraph.min.json b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/Fixtures/simple-single-path.reachgraph.min.json new file mode 100644 index 000000000..37ab66d2f --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/Fixtures/simple-single-path.reachgraph.min.json @@ -0,0 +1 @@ +{"schemaVersion":"reachgraph.min@v1","artifact":{"name":"example-app:v1.0.0","digest":"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abc1","env":["linux/amd64"]},"scope":{"entrypoints":["/app/bin/main"],"selectors":["prod"]},"nodes":[{"id":"sha256:0001","kind":"function","ref":"main()","file":"src/main.ts","line":1,"isEntrypoint":true},{"id":"sha256:0002","kind":"function","ref":"processData()","file":"src/processor.ts","line":42},{"id":"sha256:0003","kind":"function","ref":"lodash.template()","file":"node_modules/lodash/template.js","line":1,"isSink":true}],"edges":[{"from":"sha256:0001","to":"sha256:0002","why":{"type":"import","confidence":1.0,"loc":"src/main.ts:3"}},{"from":"sha256:0002","to":"sha256:0003","why":{"type":"import","confidence":1.0,"loc":"src/processor.ts:5"}}],"provenance":{"inputs":{"sbom":"sha256:sbom123abc456def789abc123def456789abc123def456789abc123def456789ab"},"computedAt":"2025-12-27T10:00:00.000Z","analyzer":{"name":"stellaops-scanner","version":"1.0.0","toolchainDigest":"sha256:toolchain123456789"}}} \ No newline at end of file diff --git a/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/GoldenSampleTests.cs b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/GoldenSampleTests.cs new file mode 100644 index 000000000..88a9c47f1 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/GoldenSampleTests.cs @@ -0,0 +1,178 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Reflection; +using StellaOps.ReachGraph.Hashing; +using StellaOps.ReachGraph.Schema; +using StellaOps.ReachGraph.Serialization; +using Xunit; + +namespace StellaOps.ReachGraph.Tests; + +public class GoldenSampleTests +{ + private readonly CanonicalReachGraphSerializer _serializer = new(); + private readonly ReachGraphDigestComputer _digestComputer; + + public GoldenSampleTests() + { + _digestComputer = new ReachGraphDigestComputer(_serializer); + } + + public static IEnumerable GoldenSamples() + { + yield return ["Fixtures/simple-single-path.reachgraph.min.json"]; + yield return ["Fixtures/feature-flag-guards.reachgraph.min.json"]; + } + + [Theory] + [MemberData(nameof(GoldenSamples))] + public void GoldenSample_Deserializes_Successfully(string resourcePath) + { + // Arrange + var json = LoadEmbeddedResource(resourcePath); + + // Act + var graph = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(graph); + Assert.Equal("reachgraph.min@v1", graph.SchemaVersion); + Assert.NotEmpty(graph.Artifact.Name); + Assert.NotEmpty(graph.Artifact.Digest); + } + + [Theory] + [MemberData(nameof(GoldenSamples))] + public void GoldenSample_Roundtrip_ProducesSameDigest(string resourcePath) + { + // Arrange + var originalJson = LoadEmbeddedResource(resourcePath); + var graph = _serializer.Deserialize(originalJson); + var originalDigest = _digestComputer.ComputeDigest(graph); + + // Act - serialize and deserialize again + var reserialized = _serializer.SerializeMinimal(graph); + var reloaded = _serializer.Deserialize(reserialized); + var reloadedDigest = _digestComputer.ComputeDigest(reloaded); + + // Assert + Assert.Equal(originalDigest, reloadedDigest); + } + + [Fact] + public void SimpleSinglePath_HasExpectedStructure() + { + // Arrange + var json = LoadEmbeddedResource("Fixtures/simple-single-path.reachgraph.min.json"); + + // Act + var graph = _serializer.Deserialize(json); + + // Assert + Assert.Equal("example-app:v1.0.0", graph.Artifact.Name); + Assert.Contains("linux/amd64", graph.Artifact.Env); + Assert.Equal(3, graph.Nodes.Length); + Assert.Equal(2, graph.Edges.Length); + + // Check nodes + var entryNode = graph.Nodes.First(n => n.IsEntrypoint == true); + Assert.Equal("main()", entryNode.Ref); + + var sinkNode = graph.Nodes.First(n => n.IsSink == true); + Assert.Equal("lodash.template()", sinkNode.Ref); + + // Check edges + Assert.All(graph.Edges, e => Assert.Equal(EdgeExplanationType.Import, e.Why.Type)); + } + + [Fact] + public void FeatureFlagGuards_HasExpectedGuards() + { + // Arrange + var json = LoadEmbeddedResource("Fixtures/feature-flag-guards.reachgraph.min.json"); + + // Act + var graph = _serializer.Deserialize(json); + + // Assert + Assert.Equal("feature-app:v2.0.0", graph.Artifact.Name); + Assert.NotNull(graph.Scope.Cves); + Assert.Contains("CVE-2024-1234", graph.Scope.Cves.Value); + + // Find the feature flag edge + var featureFlagEdge = graph.Edges.FirstOrDefault(e => e.Why.Type == EdgeExplanationType.FeatureFlag); + Assert.NotNull(featureFlagEdge); + Assert.Equal("ENABLE_NEW_PROCESSOR=true", featureFlagEdge.Why.Guard); + Assert.Equal(0.9, featureFlagEdge.Why.Confidence); + } + + [Theory] + [MemberData(nameof(GoldenSamples))] + public void GoldenSample_PreservesProvenance(string resourcePath) + { + // Arrange + var json = LoadEmbeddedResource(resourcePath); + + // Act + var graph = _serializer.Deserialize(json); + + // Assert + Assert.NotNull(graph.Provenance); + Assert.NotNull(graph.Provenance.Inputs); + Assert.NotEmpty(graph.Provenance.Inputs.Sbom); + Assert.NotEqual(default, graph.Provenance.ComputedAt); + Assert.NotNull(graph.Provenance.Analyzer); + Assert.NotEmpty(graph.Provenance.Analyzer.Name); + } + + [Theory] + [MemberData(nameof(GoldenSamples))] + public void GoldenSample_NodeIds_AreValid(string resourcePath) + { + // Arrange + var json = LoadEmbeddedResource(resourcePath); + var graph = _serializer.Deserialize(json); + + // Act & Assert + foreach (var node in graph.Nodes) + { + Assert.NotEmpty(node.Id); + Assert.NotEqual(ReachGraphNodeKind.Package, default); // Kind is set + Assert.NotEmpty(node.Ref); + } + } + + [Theory] + [MemberData(nameof(GoldenSamples))] + public void GoldenSample_Edges_ReferenceValidNodes(string resourcePath) + { + // Arrange + var json = LoadEmbeddedResource(resourcePath); + var graph = _serializer.Deserialize(json); + var nodeIds = graph.Nodes.Select(n => n.Id).ToHashSet(); + + // Act & Assert + foreach (var edge in graph.Edges) + { + Assert.NotEmpty(edge.From); + Assert.NotEmpty(edge.To); + // Note: We only check format, not that nodes exist (edges may reference external nodes) + Assert.True(edge.Why.Confidence >= 0.0 && edge.Why.Confidence <= 1.0); + } + } + + private static string LoadEmbeddedResource(string resourcePath) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = $"StellaOps.ReachGraph.Tests.{resourcePath.Replace('/', '.')}"; + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + throw new FileNotFoundException($"Embedded resource not found: {resourceName}"); + } + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/StellaOps.ReachGraph.Tests.csproj b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/StellaOps.ReachGraph.Tests.csproj new file mode 100644 index 000000000..22d933b35 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/StellaOps.ReachGraph.Tests.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + preview + StellaOps.ReachGraph.Tests + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + diff --git a/src/__Libraries/__Tests/StellaOps.Replay.Tests/StellaOps.Replay.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Replay.Tests/StellaOps.Replay.Tests.csproj index a5fb9be04..e3df11b47 100644 --- a/src/__Libraries/__Tests/StellaOps.Replay.Tests/StellaOps.Replay.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Replay.Tests/StellaOps.Replay.Tests.csproj @@ -6,13 +6,13 @@ - + - + diff --git a/src/__Libraries/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj index bab6ca1ac..081f87a79 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj @@ -21,10 +21,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj index da9464080..00dc8a99d 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj @@ -21,10 +21,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Router.Integration.Tests/StellaOps.Router.Integration.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Integration.Tests/StellaOps.Router.Integration.Tests.csproj index 10c175ae4..92fde20df 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Integration.Tests/StellaOps.Router.Integration.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Integration.Tests/StellaOps.Router.Integration.Tests.csproj @@ -19,13 +19,13 @@ - + - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj index fe4106fe4..a454e729e 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj @@ -21,10 +21,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Router.Transport.RabbitMq.Tests/StellaOps.Router.Transport.RabbitMq.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Transport.RabbitMq.Tests/StellaOps.Router.Transport.RabbitMq.Tests.csproj index a8e7292fa..dc8fbad9a 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Transport.RabbitMq.Tests/StellaOps.Router.Transport.RabbitMq.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Transport.RabbitMq.Tests/StellaOps.Router.Transport.RabbitMq.Tests.csproj @@ -21,12 +21,12 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Router.Transport.Tcp.Tests/StellaOps.Router.Transport.Tcp.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Transport.Tcp.Tests/StellaOps.Router.Transport.Tcp.Tests.csproj index 180a83f48..63775ad41 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Transport.Tcp.Tests/StellaOps.Router.Transport.Tcp.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Transport.Tcp.Tests/StellaOps.Router.Transport.Tcp.Tests.csproj @@ -12,11 +12,11 @@ $(NoWarn);xUnit1051 - + - - + + diff --git a/src/__Libraries/__Tests/StellaOps.Router.Transport.Tls.Tests/StellaOps.Router.Transport.Tls.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Transport.Tls.Tests/StellaOps.Router.Transport.Tls.Tests.csproj index 3572aeb13..e5ea453b5 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Transport.Tls.Tests/StellaOps.Router.Transport.Tls.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Transport.Tls.Tests/StellaOps.Router.Transport.Tls.Tests.csproj @@ -9,15 +9,15 @@ false - + - + all runtime; build; native; contentfiles; analyzers - + - + diff --git a/src/__Libraries/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj index 885e737f3..5d11fe100 100644 --- a/src/__Libraries/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj @@ -11,13 +11,13 @@ - + - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj index b92b7f079..33d920cad 100644 --- a/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.TestKit.Tests/StellaOps.TestKit.Tests.csproj b/src/__Libraries/__Tests/StellaOps.TestKit.Tests/StellaOps.TestKit.Tests.csproj index c86291ce2..ad341d1b8 100644 --- a/src/__Libraries/__Tests/StellaOps.TestKit.Tests/StellaOps.TestKit.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.TestKit.Tests/StellaOps.TestKit.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/__Libraries/__Tests/StellaOps.Testing.Determinism.Tests/StellaOps.Testing.Determinism.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Testing.Determinism.Tests/StellaOps.Testing.Determinism.Tests.csproj index f6c888e93..bfec965ad 100644 --- a/src/__Libraries/__Tests/StellaOps.Testing.Determinism.Tests/StellaOps.Testing.Determinism.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Testing.Determinism.Tests/StellaOps.Testing.Determinism.Tests.csproj @@ -9,11 +9,11 @@ - + - + diff --git a/src/__Libraries/__Tests/StellaOps.Testing.Manifests.Tests/StellaOps.Testing.Manifests.Tests.csproj b/src/__Libraries/__Tests/StellaOps.Testing.Manifests.Tests/StellaOps.Testing.Manifests.Tests.csproj index 15992c4cf..25c69f29c 100644 --- a/src/__Libraries/__Tests/StellaOps.Testing.Manifests.Tests/StellaOps.Testing.Manifests.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.Testing.Manifests.Tests/StellaOps.Testing.Manifests.Tests.csproj @@ -6,11 +6,11 @@ - + - + diff --git a/src/__Libraries/__Tests/StellaOps.VersionComparison.Tests/StellaOps.VersionComparison.Tests.csproj b/src/__Libraries/__Tests/StellaOps.VersionComparison.Tests/StellaOps.VersionComparison.Tests.csproj index 755d26aa5..44b182565 100644 --- a/src/__Libraries/__Tests/StellaOps.VersionComparison.Tests/StellaOps.VersionComparison.Tests.csproj +++ b/src/__Libraries/__Tests/StellaOps.VersionComparison.Tests/StellaOps.VersionComparison.Tests.csproj @@ -9,9 +9,9 @@ - - - + + + diff --git a/src/__Tests/Graph/StellaOps.Graph.Indexer.Tests/StellaOps.Graph.Indexer.Tests.csproj b/src/__Tests/Graph/StellaOps.Graph.Indexer.Tests/StellaOps.Graph.Indexer.Tests.csproj index 4eeadd8b4..2d13a4aa1 100644 --- a/src/__Tests/Graph/StellaOps.Graph.Indexer.Tests/StellaOps.Graph.Indexer.Tests.csproj +++ b/src/__Tests/Graph/StellaOps.Graph.Indexer.Tests/StellaOps.Graph.Indexer.Tests.csproj @@ -7,8 +7,8 @@ false - - + + diff --git a/src/__Tests/Integration/StellaOps.Integration.AirGap/StellaOps.Integration.AirGap.csproj b/src/__Tests/Integration/StellaOps.Integration.AirGap/StellaOps.Integration.AirGap.csproj index d4a475a23..ac6456793 100644 --- a/src/__Tests/Integration/StellaOps.Integration.AirGap/StellaOps.Integration.AirGap.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.AirGap/StellaOps.Integration.AirGap.csproj @@ -10,21 +10,21 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + - - - + + + diff --git a/src/__Tests/Integration/StellaOps.Integration.Determinism/StellaOps.Integration.Determinism.csproj b/src/__Tests/Integration/StellaOps.Integration.Determinism/StellaOps.Integration.Determinism.csproj index 1f4bd772d..177aa989d 100644 --- a/src/__Tests/Integration/StellaOps.Integration.Determinism/StellaOps.Integration.Determinism.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.Determinism/StellaOps.Integration.Determinism.csproj @@ -17,18 +17,18 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + @@ -43,7 +43,7 @@ - + diff --git a/src/__Tests/Integration/StellaOps.Integration.E2E/ReachGraphE2ETests.cs b/src/__Tests/Integration/StellaOps.Integration.E2E/ReachGraphE2ETests.cs new file mode 100644 index 000000000..2e68e4540 --- /dev/null +++ b/src/__Tests/Integration/StellaOps.Integration.E2E/ReachGraphE2ETests.cs @@ -0,0 +1,430 @@ +// Licensed to StellaOps under the AGPL-3.0-or-later license. + +using System.Collections.Immutable; +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; +using StellaOps.ReachGraph.Schema; +using StellaOps.Scanner.CallGraph; +using Xunit; + +namespace StellaOps.Integration.E2E; + +/// +/// End-to-end tests for the ReachGraph pipeline. +/// Tests: scan -> extract call graph -> store -> slice query -> verify determinism. +/// Sprint: SPRINT_1227_0012_0003 +/// Task: T12 - End-to-end test +/// +public class ReachGraphE2ETests : IClassFixture> +{ + private readonly HttpClient _client; + private const string TenantHeader = "X-Tenant-ID"; + private const string TestTenant = "e2e-test-tenant"; + + public ReachGraphE2ETests(WebApplicationFactory factory) + { + _client = factory.CreateClient(); + _client.DefaultRequestHeaders.Add(TenantHeader, TestTenant); + } + + [Fact] + public async Task FullPipeline_CallGraphToSliceQuery_Succeeds() + { + // 1. Create a call graph with edge explanations + var callGraph = CreateTestCallGraphWithExplanations(); + + // 2. Build a ReachGraph from the call graph + var reachGraph = BuildReachGraphFromCallGraph(callGraph); + + // 3. Store the reachability graph + var upsertRequest = new { graph = reachGraph }; + var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest); + Assert.Equal(HttpStatusCode.Created, upsertResponse.StatusCode); + + var upsertResult = await upsertResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(upsertResult); + Assert.True(upsertResult.Created); + Assert.StartsWith("blake3:", upsertResult.Digest); + + // 4. Query slice by CVE + var sliceResponse = await _client.GetAsync( + $"/v1/reachgraphs/{upsertResult.Digest}/slice?cve=CVE-2024-1234"); + Assert.Equal(HttpStatusCode.OK, sliceResponse.StatusCode); + + var slice = await sliceResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(slice); + Assert.Equal("cve", slice.SliceQuery?.Type); + Assert.Equal("CVE-2024-1234", slice.SliceQuery?.Cve); + + // 5. Verify paths exist and have edge explanations + Assert.NotNull(slice.Paths); + Assert.NotEmpty(slice.Paths); + var path = slice.Paths.First(); + Assert.True(path.Hops?.Count > 0); + + // 6. Verify determinism via replay + var replayRequest = new + { + expectedDigest = upsertResult.Digest, + inputs = new + { + sbom = reachGraph.Provenance.Inputs.Sbom, + callgraph = reachGraph.Provenance.Inputs.Callgraph + } + }; + + var replayResponse = await _client.PostAsJsonAsync("/v1/reachgraphs/replay", replayRequest); + Assert.Equal(HttpStatusCode.OK, replayResponse.StatusCode); + + var replayResult = await replayResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(replayResult); + Assert.True(replayResult.Match); + Assert.Equal(upsertResult.Digest, replayResult.ComputedDigest); + + // 7. Verify the graph can be fetched + var getResponse = await _client.GetAsync($"/v1/reachgraphs/{upsertResult.Digest}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + } + + [Fact] + public async Task EdgeExplanations_AllTypes_Classified() + { + // Test that all edge explanation types are preserved through the pipeline + var reachGraph = CreateGraphWithAllExplanationTypes(); + + var upsertRequest = new { graph = reachGraph }; + var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest); + Assert.Equal(HttpStatusCode.Created, upsertResponse.StatusCode); + + var upsertResult = await upsertResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(upsertResult); + + // Fetch the graph back + var getResponse = await _client.GetAsync($"/v1/reachgraphs/{upsertResult.Digest}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var fetchedGraph = await getResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(fetchedGraph); + Assert.NotNull(fetchedGraph.Edges); + + // Verify edge explanations are preserved + var edgeTypes = fetchedGraph.Edges.Select(e => e.Why.Type).Distinct().ToList(); + Assert.Contains(EdgeExplanationType.Import, edgeTypes); + Assert.Contains(EdgeExplanationType.EnvGuard, edgeTypes); + } + + [Fact] + public async Task SliceByPackage_ReturnsConnectedSubgraph() + { + var reachGraph = CreateMultiPackageGraph(); + + var upsertRequest = new { graph = reachGraph }; + var upsertResponse = await _client.PostAsJsonAsync("/v1/reachgraphs", upsertRequest); + Assert.Equal(HttpStatusCode.Created, upsertResponse.StatusCode); + + var upsertResult = await upsertResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(upsertResult); + + // Slice by package pattern + var sliceResponse = await _client.GetAsync( + $"/v1/reachgraphs/{upsertResult.Digest}/slice?q=pkg:npm/lodash@*&depth=2"); + Assert.Equal(HttpStatusCode.OK, sliceResponse.StatusCode); + + var slice = await sliceResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(slice); + Assert.Equal("package", slice.SliceQuery?.Type); + Assert.True(slice.NodeCount > 0); + } + + [Fact] + public async Task DeterministicDigest_MultipleUpserts_SameDigest() + { + var reachGraph1 = CreateTestCallGraphWithExplanations(); + var reachGraph2 = CreateTestCallGraphWithExplanations(); + + // Both graphs should produce the same digest + var graph1 = BuildReachGraphFromCallGraph(reachGraph1); + var graph2 = BuildReachGraphFromCallGraph(reachGraph2); + + var response1 = await _client.PostAsJsonAsync("/v1/reachgraphs", new { graph = graph1 }); + var response2 = await _client.PostAsJsonAsync("/v1/reachgraphs", new { graph = graph2 }); + + var result1 = await response1.Content.ReadFromJsonAsync(); + var result2 = await response2.Content.ReadFromJsonAsync(); + + Assert.NotNull(result1); + Assert.NotNull(result2); + Assert.Equal(result1.Digest, result2.Digest); + + // First upsert creates, second returns existing + Assert.True(result1.Created); + Assert.False(result2.Created); + } + + #region Test Data Builders + + private static CallGraphSnapshot CreateTestCallGraphWithExplanations() + { + var nodes = ImmutableArray.Create( + new CallGraphNode( + NodeId: "sha256:entry1", + Symbol: "main()", + File: "src/index.ts", + Line: 1, + Package: "test-app", + Visibility: Visibility.Public, + IsEntrypoint: true, + EntrypointType: EntrypointType.HttpHandler, + IsSink: false, + SinkCategory: null), + new CallGraphNode( + NodeId: "sha256:handler1", + Symbol: "processRequest()", + File: "src/handler.ts", + Line: 42, + Package: "test-app", + Visibility: Visibility.Public, + IsEntrypoint: false, + EntrypointType: null, + IsSink: false, + SinkCategory: null), + new CallGraphNode( + NodeId: "sha256:sink1", + Symbol: "lodash.template()", + File: "node_modules/lodash/template.js", + Line: 100, + Package: "lodash", + Visibility: Visibility.Public, + IsEntrypoint: false, + EntrypointType: null, + IsSink: true, + SinkCategory: SinkCategory.TemplateInjection) + ); + + var edges = ImmutableArray.Create( + new CallGraphEdge( + SourceId: "sha256:entry1", + TargetId: "sha256:handler1", + CallKind: CallKind.Direct, + CallSite: "src/index.ts:5", + Explanation: CallEdgeExplanation.Import()), + new CallGraphEdge( + SourceId: "sha256:handler1", + TargetId: "sha256:sink1", + CallKind: CallKind.Direct, + CallSite: "src/handler.ts:50", + Explanation: CallEdgeExplanation.EnvGuard("DEBUG=true")) + ); + + return new CallGraphSnapshot( + Nodes: nodes, + Edges: edges, + EntrypointIds: ["sha256:entry1"], + Module: "test-app", + Version: "1.0.0"); + } + + private static ReachGraphMinimal BuildReachGraphFromCallGraph(CallGraphSnapshot callGraph) + { + var nodes = callGraph.Nodes.Select(n => new ReachGraphNode + { + Id = n.NodeId, + Kind = n.IsEntrypoint ? ReachGraphNodeKind.Function : n.IsSink ? ReachGraphNodeKind.Function : ReachGraphNodeKind.Package, + Ref = n.Symbol, + File = n.File, + Line = n.Line ?? 0, + IsEntrypoint = n.IsEntrypoint, + IsSink = n.IsSink + }).ToImmutableArray(); + + var edges = callGraph.Edges.Select(e => new ReachGraphEdge + { + From = e.SourceId, + To = e.TargetId, + Why = new EdgeExplanation + { + Type = MapExplanationType(e.Explanation?.Type ?? CallEdgeExplanationType.DirectCall), + Loc = e.CallSite, + Guard = e.Explanation?.Guard, + Confidence = e.Explanation?.Confidence ?? 1.0 + } + }).ToImmutableArray(); + + return new ReachGraphMinimal + { + SchemaVersion = "reachgraph.min@v1", + Artifact = new ReachGraphArtifact( + $"{callGraph.Module}:{callGraph.Version}", + $"sha256:{Guid.NewGuid():N}", + ["linux/amd64"]), + Scope = new ReachGraphScope( + callGraph.EntrypointIds.ToArray(), + ["prod"], + ["CVE-2024-1234"]), + Nodes = nodes, + Edges = edges, + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs + { + Sbom = $"sha256:sbom{Guid.NewGuid():N}", + Callgraph = $"sha256:cg{Guid.NewGuid():N}" + }, + ComputedAt = DateTimeOffset.UtcNow, + Analyzer = new ReachGraphAnalyzer( + "stellaops-e2e-test", + "1.0.0", + $"sha256:tool{Guid.NewGuid():N}") + } + }; + } + + private static ReachGraphMinimal CreateGraphWithAllExplanationTypes() + { + var nodes = ImmutableArray.Create( + new ReachGraphNode { Id = "n1", Kind = ReachGraphNodeKind.Function, Ref = "entry()", IsEntrypoint = true }, + new ReachGraphNode { Id = "n2", Kind = ReachGraphNodeKind.Function, Ref = "loadModule()" }, + new ReachGraphNode { Id = "n3", Kind = ReachGraphNodeKind.Function, Ref = "reflectCall()" }, + new ReachGraphNode { Id = "n4", Kind = ReachGraphNodeKind.Function, Ref = "guardedCall()" }, + new ReachGraphNode { Id = "n5", Kind = ReachGraphNodeKind.Function, Ref = "sink()", IsSink = true } + ); + + var edges = ImmutableArray.Create( + new ReachGraphEdge + { + From = "n1", + To = "n2", + Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } + }, + new ReachGraphEdge + { + From = "n2", + To = "n3", + Why = new EdgeExplanation { Type = EdgeExplanationType.DynamicLoad, Confidence = 0.5 } + }, + new ReachGraphEdge + { + From = "n3", + To = "n4", + Why = new EdgeExplanation { Type = EdgeExplanationType.Reflection, Confidence = 0.5 } + }, + new ReachGraphEdge + { + From = "n4", + To = "n5", + Why = new EdgeExplanation + { + Type = EdgeExplanationType.EnvGuard, + Guard = "ENABLE_FEATURE=true", + Confidence = 0.9 + } + } + ); + + return new ReachGraphMinimal + { + SchemaVersion = "reachgraph.min@v1", + Artifact = new ReachGraphArtifact("all-types:v1", "sha256:abc123", ["linux/amd64"]), + Scope = new ReachGraphScope(["n1"], ["test"]), + Nodes = nodes, + Edges = edges, + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs { Sbom = "sha256:sbom1" }, + ComputedAt = DateTimeOffset.UtcNow, + Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:tool1") + } + }; + } + + private static ReachGraphMinimal CreateMultiPackageGraph() + { + var nodes = ImmutableArray.Create( + new ReachGraphNode { Id = "app1", Kind = ReachGraphNodeKind.Function, Ref = "main()", IsEntrypoint = true }, + new ReachGraphNode { Id = "lodash1", Kind = ReachGraphNodeKind.Package, Ref = "pkg:npm/lodash@4.17.21" }, + new ReachGraphNode { Id = "axios1", Kind = ReachGraphNodeKind.Package, Ref = "pkg:npm/axios@1.6.0" }, + new ReachGraphNode { Id = "sink1", Kind = ReachGraphNodeKind.Function, Ref = "template()", IsSink = true } + ); + + var edges = ImmutableArray.Create( + new ReachGraphEdge { From = "app1", To = "lodash1", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } }, + new ReachGraphEdge { From = "app1", To = "axios1", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } }, + new ReachGraphEdge { From = "lodash1", To = "sink1", Why = new EdgeExplanation { Type = EdgeExplanationType.Import, Confidence = 1.0 } } + ); + + return new ReachGraphMinimal + { + SchemaVersion = "reachgraph.min@v1", + Artifact = new ReachGraphArtifact("multi-pkg:v1", "sha256:multi123", ["linux/amd64"]), + Scope = new ReachGraphScope(["app1"], ["prod"]), + Nodes = nodes, + Edges = edges, + Provenance = new ReachGraphProvenance + { + Inputs = new ReachGraphInputs { Sbom = "sha256:sbommulti" }, + ComputedAt = DateTimeOffset.UtcNow, + Analyzer = new ReachGraphAnalyzer("test", "1.0.0", "sha256:toolmulti") + } + }; + } + + private static EdgeExplanationType MapExplanationType(CallEdgeExplanationType callType) => callType switch + { + CallEdgeExplanationType.Import => EdgeExplanationType.Import, + CallEdgeExplanationType.DynamicLoad => EdgeExplanationType.DynamicLoad, + CallEdgeExplanationType.Reflection => EdgeExplanationType.Reflection, + CallEdgeExplanationType.Ffi => EdgeExplanationType.Ffi, + CallEdgeExplanationType.EnvGuard => EdgeExplanationType.EnvGuard, + CallEdgeExplanationType.FeatureFlag => EdgeExplanationType.FeatureFlag, + CallEdgeExplanationType.PlatformArch => EdgeExplanationType.PlatformArch, + CallEdgeExplanationType.TaintGate => EdgeExplanationType.TaintGate, + CallEdgeExplanationType.LoaderRule => EdgeExplanationType.LoaderRule, + _ => EdgeExplanationType.Import + }; + + #endregion + + #region Response DTOs + + private sealed record UpsertResult + { + public bool Created { get; init; } + public string Digest { get; init; } = string.Empty; + public int NodeCount { get; init; } + public int EdgeCount { get; init; } + } + + private sealed record SliceResult + { + public SliceQueryInfo? SliceQuery { get; init; } + public string? ParentDigest { get; init; } + public int NodeCount { get; init; } + public int EdgeCount { get; init; } + public List? Paths { get; init; } + } + + private sealed record SliceQueryInfo + { + public string? Type { get; init; } + public string? Query { get; init; } + public string? Cve { get; init; } + } + + private sealed record PathInfo + { + public string? Entrypoint { get; init; } + public string? Sink { get; init; } + public List? Hops { get; init; } + } + + private sealed record ReplayResult + { + public bool Match { get; init; } + public string? ComputedDigest { get; init; } + public string? ExpectedDigest { get; init; } + public int DurationMs { get; init; } + } + + #endregion +} diff --git a/src/__Tests/Integration/StellaOps.Integration.E2E/StellaOps.Integration.E2E.csproj b/src/__Tests/Integration/StellaOps.Integration.E2E/StellaOps.Integration.E2E.csproj index 046aa6354..abe271135 100644 --- a/src/__Tests/Integration/StellaOps.Integration.E2E/StellaOps.Integration.E2E.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.E2E/StellaOps.Integration.E2E.csproj @@ -18,16 +18,16 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + @@ -45,10 +45,9 @@ - + - - + @@ -59,7 +58,7 @@ - + diff --git a/src/__Tests/Integration/StellaOps.Integration.Performance/StellaOps.Integration.Performance.csproj b/src/__Tests/Integration/StellaOps.Integration.Performance/StellaOps.Integration.Performance.csproj index ee1ba65b7..9337ee43a 100644 --- a/src/__Tests/Integration/StellaOps.Integration.Performance/StellaOps.Integration.Performance.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.Performance/StellaOps.Integration.Performance.csproj @@ -11,20 +11,19 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - - - + + + diff --git a/src/__Tests/Integration/StellaOps.Integration.Platform/StellaOps.Integration.Platform.csproj b/src/__Tests/Integration/StellaOps.Integration.Platform/StellaOps.Integration.Platform.csproj index 94ad2368e..560623da1 100644 --- a/src/__Tests/Integration/StellaOps.Integration.Platform/StellaOps.Integration.Platform.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.Platform/StellaOps.Integration.Platform.csproj @@ -17,16 +17,16 @@ - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/src/__Tests/Integration/StellaOps.Integration.ProofChain/StellaOps.Integration.ProofChain.csproj b/src/__Tests/Integration/StellaOps.Integration.ProofChain/StellaOps.Integration.ProofChain.csproj index 1974a1734..3c153d2e6 100644 --- a/src/__Tests/Integration/StellaOps.Integration.ProofChain/StellaOps.Integration.ProofChain.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.ProofChain/StellaOps.Integration.ProofChain.csproj @@ -17,16 +17,16 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + @@ -35,10 +35,9 @@ - - + diff --git a/src/__Tests/Integration/StellaOps.Integration.Reachability/StellaOps.Integration.Reachability.csproj b/src/__Tests/Integration/StellaOps.Integration.Reachability/StellaOps.Integration.Reachability.csproj index b719667a7..becd5ebd6 100644 --- a/src/__Tests/Integration/StellaOps.Integration.Reachability/StellaOps.Integration.Reachability.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.Reachability/StellaOps.Integration.Reachability.csproj @@ -17,27 +17,22 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + - - - - - diff --git a/src/__Tests/Integration/StellaOps.Integration.Unknowns/StellaOps.Integration.Unknowns.csproj b/src/__Tests/Integration/StellaOps.Integration.Unknowns/StellaOps.Integration.Unknowns.csproj index 675125e13..2a936f93d 100644 --- a/src/__Tests/Integration/StellaOps.Integration.Unknowns/StellaOps.Integration.Unknowns.csproj +++ b/src/__Tests/Integration/StellaOps.Integration.Unknowns/StellaOps.Integration.Unknowns.csproj @@ -17,25 +17,22 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + - - - - + diff --git a/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj index 08c445862..c07fe58cb 100644 --- a/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj +++ b/src/__Tests/StellaOps.Audit.ReplayToken.Tests/StellaOps.Audit.ReplayToken.Tests.csproj @@ -10,10 +10,10 @@ false - + - + diff --git a/src/__Tests/StellaOps.Evidence.Bundle.Tests/StellaOps.Evidence.Bundle.Tests.csproj b/src/__Tests/StellaOps.Evidence.Bundle.Tests/StellaOps.Evidence.Bundle.Tests.csproj index 040c4fe29..167be2845 100644 --- a/src/__Tests/StellaOps.Evidence.Bundle.Tests/StellaOps.Evidence.Bundle.Tests.csproj +++ b/src/__Tests/StellaOps.Evidence.Bundle.Tests/StellaOps.Evidence.Bundle.Tests.csproj @@ -10,6 +10,6 @@ - + diff --git a/src/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj b/src/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj index 7e947f944..11b19e0f3 100644 --- a/src/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj +++ b/src/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj @@ -8,13 +8,13 @@ false - - - + + + - - + + diff --git a/src/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj b/src/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj index cdb41b6ac..864860a64 100644 --- a/src/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj +++ b/src/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj @@ -10,7 +10,7 @@ - - + + diff --git a/src/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj b/src/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj index 91a009512..aed41da2b 100644 --- a/src/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj +++ b/src/__Tests/StellaOps.Router.Config.Tests/StellaOps.Router.Config.Tests.csproj @@ -9,15 +9,15 @@ - - + + - - - - + + + + diff --git a/src/__Tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj b/src/__Tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj index 99d25150e..c97365671 100644 --- a/src/__Tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj +++ b/src/__Tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj @@ -9,15 +9,15 @@ - - + + - - - - - + + + + + diff --git a/src/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj b/src/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj index fd278c2ff..7cfc7de79 100644 --- a/src/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj +++ b/src/__Tests/StellaOps.Router.Transport.InMemory.Tests/StellaOps.Router.Transport.InMemory.Tests.csproj @@ -9,13 +9,13 @@ - - + + - - + + diff --git a/src/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj b/src/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj index f06f0c87b..5ae564ed5 100644 --- a/src/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj +++ b/src/__Tests/StellaOps.Router.Transport.Udp.Tests/StellaOps.Router.Transport.Udp.Tests.csproj @@ -9,13 +9,13 @@ - - + + - - + + diff --git a/src/__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj b/src/__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj index 1171bc962..f9b4e2d00 100644 --- a/src/__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj +++ b/src/__Tests/__Libraries/StellaOps.Concelier.Testing/StellaOps.Concelier.Testing.csproj @@ -7,7 +7,7 @@ false - + all diff --git a/src/__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj b/src/__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj index ac70000e8..b3369052f 100644 --- a/src/__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj +++ b/src/__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/__Tests/__Libraries/StellaOps.Messaging.Testing/StellaOps.Messaging.Testing.csproj b/src/__Tests/__Libraries/StellaOps.Messaging.Testing/StellaOps.Messaging.Testing.csproj index 8afc3e3cc..9b67208e2 100644 --- a/src/__Tests/__Libraries/StellaOps.Messaging.Testing/StellaOps.Messaging.Testing.csproj +++ b/src/__Tests/__Libraries/StellaOps.Messaging.Testing/StellaOps.Messaging.Testing.csproj @@ -20,8 +20,8 @@ - - + + diff --git a/src/__Tests/__Libraries/StellaOps.Router.Testing/StellaOps.Router.Testing.csproj b/src/__Tests/__Libraries/StellaOps.Router.Testing/StellaOps.Router.Testing.csproj index db8df1216..259984511 100644 --- a/src/__Tests/__Libraries/StellaOps.Router.Testing/StellaOps.Router.Testing.csproj +++ b/src/__Tests/__Libraries/StellaOps.Router.Testing/StellaOps.Router.Testing.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/__Tests/__Libraries/StellaOps.Testing.Determinism.Properties/StellaOps.Testing.Determinism.Properties.csproj b/src/__Tests/__Libraries/StellaOps.Testing.Determinism.Properties/StellaOps.Testing.Determinism.Properties.csproj index 50e021c11..8d7c05718 100644 --- a/src/__Tests/__Libraries/StellaOps.Testing.Determinism.Properties/StellaOps.Testing.Determinism.Properties.csproj +++ b/src/__Tests/__Libraries/StellaOps.Testing.Determinism.Properties/StellaOps.Testing.Determinism.Properties.csproj @@ -10,14 +10,14 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/__Tests/__Libraries/StellaOps.Testing.Manifests/StellaOps.Testing.Manifests.csproj b/src/__Tests/__Libraries/StellaOps.Testing.Manifests/StellaOps.Testing.Manifests.csproj index 3f016e800..0d32054c7 100644 --- a/src/__Tests/__Libraries/StellaOps.Testing.Manifests/StellaOps.Testing.Manifests.csproj +++ b/src/__Tests/__Libraries/StellaOps.Testing.Manifests/StellaOps.Testing.Manifests.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/__Tests/architecture/StellaOps.Architecture.Tests/StellaOps.Architecture.Tests.csproj b/src/__Tests/architecture/StellaOps.Architecture.Tests/StellaOps.Architecture.Tests.csproj index 14b73ee8b..ed8025d52 100644 --- a/src/__Tests/architecture/StellaOps.Architecture.Tests/StellaOps.Architecture.Tests.csproj +++ b/src/__Tests/architecture/StellaOps.Architecture.Tests/StellaOps.Architecture.Tests.csproj @@ -10,15 +10,15 @@ - + - - + + diff --git a/src/__Tests/chaos/StellaOps.Chaos.Router.Tests/StellaOps.Chaos.Router.Tests.csproj b/src/__Tests/chaos/StellaOps.Chaos.Router.Tests/StellaOps.Chaos.Router.Tests.csproj index 0312d4b8b..58b7829fd 100644 --- a/src/__Tests/chaos/StellaOps.Chaos.Router.Tests/StellaOps.Chaos.Router.Tests.csproj +++ b/src/__Tests/chaos/StellaOps.Chaos.Router.Tests/StellaOps.Chaos.Router.Tests.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj b/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj index 945baa806..f6cf14d17 100644 --- a/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj +++ b/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/__Tests/offline/StellaOps.Offline.E2E.Tests/StellaOps.Offline.E2E.Tests.csproj b/src/__Tests/offline/StellaOps.Offline.E2E.Tests/StellaOps.Offline.E2E.Tests.csproj index 6beb5deee..232f263d1 100644 --- a/src/__Tests/offline/StellaOps.Offline.E2E.Tests/StellaOps.Offline.E2E.Tests.csproj +++ b/src/__Tests/offline/StellaOps.Offline.E2E.Tests/StellaOps.Offline.E2E.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/__Tests/parity/StellaOps.Parity.Tests/StellaOps.Parity.Tests.csproj b/src/__Tests/parity/StellaOps.Parity.Tests/StellaOps.Parity.Tests.csproj index fb8eeade2..117f8feca 100644 --- a/src/__Tests/parity/StellaOps.Parity.Tests/StellaOps.Parity.Tests.csproj +++ b/src/__Tests/parity/StellaOps.Parity.Tests/StellaOps.Parity.Tests.csproj @@ -17,19 +17,19 @@ - + - + - + - - + + diff --git a/src/__Tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj b/src/__Tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj index 37e187613..542732564 100644 --- a/src/__Tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj +++ b/src/__Tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj @@ -7,13 +7,13 @@ false - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/__Tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj b/src/__Tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj index 1e72ae0ad..083fe1281 100644 --- a/src/__Tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj +++ b/src/__Tests/reachability/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj @@ -7,7 +7,7 @@ false - + diff --git a/src/__Tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj b/src/__Tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj index 010c062e9..bc2576ecc 100644 --- a/src/__Tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj +++ b/src/__Tests/reachability/StellaOps.ScannerSignals.IntegrationTests/StellaOps.ScannerSignals.IntegrationTests.csproj @@ -7,13 +7,13 @@ false - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/__Tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj b/src/__Tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj index a4dc661d9..7fffdf226 100644 --- a/src/__Tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj +++ b/src/__Tests/reachability/StellaOps.Signals.Reachability.Tests/StellaOps.Signals.Reachability.Tests.csproj @@ -7,7 +7,7 @@ false - + diff --git a/src/__Tests/security/StellaOps.Security.Tests/StellaOps.Security.Tests.csproj b/src/__Tests/security/StellaOps.Security.Tests/StellaOps.Security.Tests.csproj index be662c7e2..9b406330d 100644 --- a/src/__Tests/security/StellaOps.Security.Tests/StellaOps.Security.Tests.csproj +++ b/src/__Tests/security/StellaOps.Security.Tests/StellaOps.Security.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/__Tests/unit/StellaOps.AuditPack.Tests/AuditPackExportServiceTests.cs b/src/__Tests/unit/StellaOps.AuditPack.Tests/AuditPackExportServiceTests.cs new file mode 100644 index 000000000..b24b40d90 --- /dev/null +++ b/src/__Tests/unit/StellaOps.AuditPack.Tests/AuditPackExportServiceTests.cs @@ -0,0 +1,230 @@ +// ----------------------------------------------------------------------------- +// AuditPackExportServiceTests.cs +// Sprint: SPRINT_1227_0005_0003_FE_copy_audit_export +// Task: T10 — Unit tests for AuditPackExportService +// ----------------------------------------------------------------------------- + +namespace StellaOps.AuditPack.Tests; + +using StellaOps.AuditPack.Services; +using System.IO.Compression; +using System.Text.Json; + +[Trait("Category", "Unit")] +public class AuditPackExportServiceTests +{ + private readonly AuditPackExportService _service; + + public AuditPackExportServiceTests() + { + _service = new AuditPackExportService( + new MockAuditBundleWriter(), + null); + } + + [Fact] + public async Task ExportAsync_Zip_CreatesValidZipArchive() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-123", + Format = ExportFormat.Zip, + Segments = [ExportSegment.Sbom, ExportSegment.Match], + IncludeAttestations = true, + IncludeProofChain = false, + Filename = "test-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.ContentType.Should().Be("application/zip"); + result.Filename.Should().Be("test-export.zip"); + result.Data.Should().NotBeNull(); + result.SizeBytes.Should().BeGreaterThan(0); + + // Verify ZIP structure + using var memoryStream = new MemoryStream(result.Data!); + using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read); + archive.Entries.Should().Contain(e => e.FullName == "manifest.json"); + } + + [Fact] + public async Task ExportAsync_Json_CreatesSingleJsonFile() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-456", + Format = ExportFormat.Json, + Segments = [ExportSegment.Sbom], + IncludeAttestations = false, + IncludeProofChain = false, + Filename = "test-json" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.ContentType.Should().Be("application/json"); + result.Filename.Should().Be("test-json.json"); + + // Verify JSON structure + using var jsonDoc = JsonDocument.Parse(result.Data!); + var root = jsonDoc.RootElement; + root.TryGetProperty("scanId", out var scanIdProp).Should().BeTrue(); + scanIdProp.GetString().Should().Be("scan-456"); + root.TryGetProperty("segments", out _).Should().BeTrue(); + } + + [Fact] + public async Task ExportAsync_Dsse_CreatesDsseEnvelope() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-789", + Format = ExportFormat.Dsse, + Segments = [ExportSegment.Policy], + IncludeAttestations = true, + IncludeProofChain = true, + Filename = "test-dsse" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.ContentType.Should().Be("application/vnd.dsse+json"); + result.Filename.Should().Be("test-dsse.dsse.json"); + + // Verify DSSE structure + using var jsonDoc = JsonDocument.Parse(result.Data!); + var root = jsonDoc.RootElement; + root.TryGetProperty("payloadType", out var payloadType).Should().BeTrue(); + payloadType.GetString().Should().Be("application/vnd.stellaops.audit-pack+json"); + root.TryGetProperty("payload", out _).Should().BeTrue(); + root.TryGetProperty("signatures", out _).Should().BeTrue(); + } + + [Fact] + public async Task ExportAsync_AllSegments_IncludesAllInZip() + { + // Arrange + var allSegments = new[] + { + ExportSegment.Sbom, + ExportSegment.Match, + ExportSegment.Reachability, + ExportSegment.Guards, + ExportSegment.Runtime, + ExportSegment.Policy + }; + + var request = new ExportRequest + { + ScanId = "scan-full", + Format = ExportFormat.Zip, + Segments = allSegments, + IncludeAttestations = true, + IncludeProofChain = true, + Filename = "full-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + + using var memoryStream = new MemoryStream(result.Data!); + using var archive = new ZipArchive(memoryStream, ZipArchiveMode.Read); + + // Should have manifest + 6 segments + attestations + proof chain + archive.Entries.Count.Should().BeGreaterThanOrEqualTo(3); + } + + [Fact] + public async Task ExportAsync_EmptySegments_StillCreatesValidExport() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-empty", + Format = ExportFormat.Json, + Segments = [], + IncludeAttestations = false, + IncludeProofChain = false, + Filename = "empty-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + } + + [Fact] + public async Task ExportAsync_UnsupportedFormat_ReturnsFailed() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-fail", + Format = (ExportFormat)999, // Invalid format + Segments = [ExportSegment.Sbom], + IncludeAttestations = false, + IncludeProofChain = false, + Filename = "fail-export" + }; + + // Act + var result = await _service.ExportAsync(request); + + // Assert + result.Success.Should().BeFalse(); + result.Error.Should().Contain("Unsupported"); + } + + [Fact] + public async Task ExportAsync_CancellationRequested_ThrowsOperationCanceled() + { + // Arrange + var request = new ExportRequest + { + ScanId = "scan-cancel", + Format = ExportFormat.Zip, + Segments = [ExportSegment.Sbom], + IncludeAttestations = false, + IncludeProofChain = false, + Filename = "cancel-export" + }; + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.ExportAsync(request, cts.Token)); + } + + // Mock implementation for testing + private class MockAuditBundleWriter : IAuditBundleWriter + { + public Task WriteAsync( + AuditBundleManifest manifest, + string outputPath, + CancellationToken ct = default) + { + return Task.FromResult(new BundleWriteResult { Success = true }); + } + } +} diff --git a/src/__Tests/unit/StellaOps.AuditPack.Tests/ReplayAttestationServiceTests.cs b/src/__Tests/unit/StellaOps.AuditPack.Tests/ReplayAttestationServiceTests.cs new file mode 100644 index 000000000..d6bb17a50 --- /dev/null +++ b/src/__Tests/unit/StellaOps.AuditPack.Tests/ReplayAttestationServiceTests.cs @@ -0,0 +1,258 @@ +// ----------------------------------------------------------------------------- +// ReplayAttestationServiceTests.cs +// Sprint: SPRINT_1227_0005_0004_BE_verdict_replay +// Task: T8 — Unit tests for ReplayAttestationService +// ----------------------------------------------------------------------------- + +namespace StellaOps.AuditPack.Tests; + +using StellaOps.AuditPack.Models; +using StellaOps.AuditPack.Services; +using System.Collections.Immutable; + +[Trait("Category", "Unit")] +public class ReplayAttestationServiceTests +{ + private readonly ReplayAttestationService _service; + + public ReplayAttestationServiceTests() + { + _service = new ReplayAttestationService(null); + } + + private static AuditBundleManifest CreateTestManifest(string bundleId = "bundle-123") + { + return new AuditBundleManifest + { + BundleId = bundleId, + Name = "Test Bundle", + CreatedAt = DateTimeOffset.UtcNow, + ScanId = "scan-123", + ImageRef = "docker.io/library/alpine:3.18", + ImageDigest = "sha256:abc123", + MerkleRoot = "sha256:merkle-root", + VerdictDigest = "sha256:verdict-digest", + Decision = "pass", + Inputs = new InputDigests + { + SbomDigest = "sha256:sbom-digest", + FeedsDigest = "sha256:feeds-digest", + PolicyDigest = "sha256:policy-digest" + }, + Files = ImmutableArray.Empty + }; + } + + private static ReplayExecutionResult CreateTestResult(bool match = true) + { + return new ReplayExecutionResult + { + Success = true, + Status = match ? ReplayStatus.Match : ReplayStatus.Drift, + VerdictMatches = match, + DecisionMatches = match, + OriginalVerdictDigest = "sha256:verdict-digest", + ReplayedVerdictDigest = match ? "sha256:verdict-digest" : "sha256:different-digest", + OriginalDecision = "pass", + ReplayedDecision = match ? "pass" : "warn", + Drifts = match ? [] : [new DriftItem { Type = DriftType.Decision, Field = "decision" }], + DurationMs = 150, + EvaluatedAt = DateTimeOffset.UtcNow + }; + } + + [Fact] + public async Task GenerateAsync_CreatesValidAttestation() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(match: true); + + // Act + var attestation = await _service.GenerateAsync(manifest, result); + + // Assert + attestation.Should().NotBeNull(); + attestation.AttestationId.Should().NotBeNullOrEmpty(); + attestation.ManifestId.Should().Be("bundle-123"); + attestation.Match.Should().BeTrue(); + attestation.ReplayStatus.Should().Be("Match"); + attestation.Statement.Should().NotBeNull(); + attestation.StatementDigest.Should().StartWith("sha256:"); + } + + [Fact] + public async Task GenerateAsync_IncludesInTotoStatement() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(); + + // Act + var attestation = await _service.GenerateAsync(manifest, result); + + // Assert + attestation.Statement.Type.Should().Be("https://in-toto.io/Statement/v1"); + attestation.Statement.PredicateType.Should().Be("https://stellaops.io/attestation/verdict-replay/v1"); + attestation.Statement.Subject.Should().HaveCount(1); + attestation.Statement.Subject[0].Name.Should().StartWith("verdict:"); + } + + [Fact] + public async Task GenerateAsync_IncludesPredicate() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(); + + // Act + var attestation = await _service.GenerateAsync(manifest, result); + var predicate = attestation.Statement.Predicate; + + // Assert + predicate.ManifestId.Should().Be("bundle-123"); + predicate.ScanId.Should().Be("scan-123"); + predicate.ImageRef.Should().Be("docker.io/library/alpine:3.18"); + predicate.Match.Should().BeTrue(); + predicate.Status.Should().Be("Match"); + predicate.DurationMs.Should().Be(150); + } + + [Fact] + public async Task GenerateAsync_CreatesDsseEnvelope() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(); + + // Act + var attestation = await _service.GenerateAsync(manifest, result); + + // Assert + attestation.Envelope.Should().NotBeNull(); + attestation.Envelope!.PayloadType.Should().Be("application/vnd.in-toto+json"); + attestation.Envelope.Payload.Should().NotBeNullOrEmpty(); + // Without signer, signatures should be empty + attestation.Envelope.Signatures.Should().BeEmpty(); + } + + [Fact] + public async Task GenerateAsync_DriftResult_RecordsDivergence() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(match: false); + + // Act + var attestation = await _service.GenerateAsync(manifest, result); + var predicate = attestation.Statement.Predicate; + + // Assert + attestation.Match.Should().BeFalse(); + attestation.ReplayStatus.Should().Be("Drift"); + predicate.Match.Should().BeFalse(); + predicate.DriftCount.Should().Be(1); + predicate.Drifts.Should().HaveCount(1); + } + + [Fact] + public async Task VerifyAsync_ValidAttestation_ReturnsValid() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(); + var attestation = await _service.GenerateAsync(manifest, result); + + // Act + var verificationResult = await _service.VerifyAsync(attestation); + + // Assert + verificationResult.IsValid.Should().BeTrue(); + verificationResult.Errors.Should().BeEmpty(); + } + + [Fact] + public async Task GenerateBatchAsync_MultipleReplays_CreatesMultipleAttestations() + { + // Arrange + var replays = new[] + { + (CreateTestManifest("bundle-1"), CreateTestResult()), + (CreateTestManifest("bundle-2"), CreateTestResult(match: false)), + (CreateTestManifest("bundle-3"), CreateTestResult()) + }; + + // Act + var attestations = await _service.GenerateBatchAsync(replays); + + // Assert + attestations.Should().HaveCount(3); + attestations[0].ManifestId.Should().Be("bundle-1"); + attestations[1].ManifestId.Should().Be("bundle-2"); + attestations[2].ManifestId.Should().Be("bundle-3"); + attestations[1].Match.Should().BeFalse(); + } + + [Fact] + public async Task GenerateAsync_ComputesInputsDigest() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(); + + // Act + var attestation = await _service.GenerateAsync(manifest, result); + var predicate = attestation.Statement.Predicate; + + // Assert + predicate.InputsDigest.Should().StartWith("sha256:"); + predicate.InputsDigest.Should().HaveLength(71); // sha256: + 64 hex chars + } + + [Fact] + public async Task GenerateAsync_ThrowsForNullManifest() + { + // Arrange + var result = CreateTestResult(); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.GenerateAsync(null!, result)); + } + + [Fact] + public async Task GenerateAsync_ThrowsForNullResult() + { + // Arrange + var manifest = CreateTestManifest(); + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.GenerateAsync(manifest, null!)); + } + + [Fact] + public async Task VerifyAsync_TamperedPayload_ReturnsInvalid() + { + // Arrange + var manifest = CreateTestManifest(); + var result = CreateTestResult(); + var attestation = await _service.GenerateAsync(manifest, result); + + // Tamper with the envelope payload + var tamperedAttestation = attestation with + { + Envelope = attestation.Envelope! with + { + Payload = Convert.ToBase64String(new byte[] { 1, 2, 3 }) + } + }; + + // Act + var verificationResult = await _service.VerifyAsync(tamperedAttestation); + + // Assert + verificationResult.IsValid.Should().BeFalse(); + verificationResult.Errors.Should().Contain(e => e.Contains("payload digest")); + } +} diff --git a/src/__Tests/unit/StellaOps.AuditPack.Tests/StellaOps.AuditPack.Tests.csproj b/src/__Tests/unit/StellaOps.AuditPack.Tests/StellaOps.AuditPack.Tests.csproj index ded1e983f..1bf140055 100644 --- a/src/__Tests/unit/StellaOps.AuditPack.Tests/StellaOps.AuditPack.Tests.csproj +++ b/src/__Tests/unit/StellaOps.AuditPack.Tests/StellaOps.AuditPack.Tests.csproj @@ -10,11 +10,11 @@ - + - + diff --git a/src/scripts/add-projects-to-solution.py b/src/scripts/add-projects-to-solution.py deleted file mode 100644 index 6821aa4ed..000000000 --- a/src/scripts/add-projects-to-solution.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -""" -Add external project references to a Visual Studio solution file. -""" -import sys -import os -import uuid -import re -from pathlib import Path - -def generate_guid(): - """Generate a GUID in the format used by Visual Studio solution files.""" - return str(uuid.uuid4()).upper() - -def get_relative_path(solution_path, project_path): - """Get relative path from solution to project.""" - solution_dir = os.path.dirname(solution_path) - try: - rel_path = os.path.relpath(project_path, solution_dir) - # Convert to backslashes for consistency with VS - return rel_path.replace('/', '\\') - except ValueError: - # Different drives on Windows - return project_path - -def parse_solution(solution_path): - """Parse a solution file and return its structure.""" - with open(solution_path, 'r', encoding='utf-8-sig') as f: - content = f.read() - - # Find all existing project entries - project_pattern = r'Project\("\{([^}]+)\}"\) = "([^"]+)", "([^"]+)", "\{([^}]+)\}"' - projects = [] - for match in re.finditer(project_pattern, content): - projects.append({ - 'type_guid': match.group(1), - 'name': match.group(2), - 'path': match.group(3), - 'guid': match.group(4) - }) - - # Find solution folder GUIDs - folder_pattern = r'Project\("\{2150E333-8FDC-42A3-9474-1A3956D46DE8\}"\) = "([^"]+)", "[^"]*", "\{([^}]+)\}"' - folders = {} - for match in re.finditer(folder_pattern, content): - folders[match.group(1)] = match.group(2) - - return content, projects, folders - -def add_projects_to_solution(solution_path, project_paths, folder_name="External Dependencies"): - """Add multiple projects to a solution file.""" - - print(f"Adding {len(project_paths)} projects to {solution_path}") - - # Parse existing solution - content, existing_projects, folders = parse_solution(solution_path) - - # Check which projects already exist - existing_paths = {p['path'] for p in existing_projects} - new_projects = [] - - for project_path in project_paths: - rel_path = get_relative_path(solution_path, project_path) - if rel_path in existing_paths: - print(f" Skipping {project_path} (already in solution)") - continue - - # Get project name from path - project_name = os.path.splitext(os.path.basename(project_path))[0] - project_guid = generate_guid() - - new_projects.append({ - 'type_guid': 'FAE04EC0-301F-11D3-BF4B-00C04F79EFBC', # C# project - 'name': project_name, - 'path': rel_path, - 'guid': project_guid - }) - print(f" Adding {project_name}") - - if not new_projects: - print(" No new projects to add") - return - - # Create or get folder GUID - if folder_name not in folders: - folders[folder_name] = generate_guid() - # Add folder entry before first Project entry - folder_entry = f'Project("{{2150E333-8FDC-42A3-9474-1A3956D46DE8}}") = "{folder_name}", "{folder_name}", "{{{folders[folder_name]}}}"\nEndProject\n' - # Insert after MinimumVisualStudioVersion line - content = re.sub( - r'(MinimumVisualStudioVersion = [^\n]+\n)', - r'\1' + folder_entry, - content - ) - - # Add project entries - project_entries = [] - for proj in new_projects: - entry = f'Project("{{{proj["type_guid"]}}}") = "{proj["name"]}", "{proj["path"]}", "{{{proj["guid"]}}}"\nEndProject\n' - project_entries.append(entry) - - # Insert before Global section - global_pattern = r'(Global\r?\n)' - content = re.sub( - global_pattern, - lambda m: ''.join(project_entries) + m.group(1), - content - ) - - # Add to GlobalSection(ProjectConfigurationPlatforms) - config_entries = [] - for proj in new_projects: - guid = proj['guid'] - config_entries.extend([ - f'\t\t{{{guid}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU\n', - f'\t\t{{{guid}}}.Debug|Any CPU.Build.0 = Debug|Any CPU\n', - f'\t\t{{{guid}}}.Release|Any CPU.ActiveCfg = Release|Any CPU\n', - f'\t\t{{{guid}}}.Release|Any CPU.Build.0 = Release|Any CPU\n', - ]) - - # Insert before EndGlobalSection of ProjectConfigurationPlatforms - end_config_pattern = r'(\tEndGlobalSection\r?\n\tGlobalSection\(NestedProjects\))' - content = re.sub( - end_config_pattern, - lambda m: ''.join(config_entries) + m.group(1), - content - ) - - # Add to GlobalSection(NestedProjects) - nested_entries = [] - for proj in new_projects: - nested_entries.append(f'\t\t{{{proj["guid"]}}} = {{{folders[folder_name]}}}\n') - - # Insert before last EndGlobalSection - end_global_pattern = r'(\tEndGlobalSection\r?\nEndGlobal)' - content = re.sub( - end_global_pattern, - lambda m: ''.join(nested_entries) + m.group(1), - content - ) - - # Write back - with open(solution_path, 'w', encoding='utf-8-sig', newline='\r\n') as f: - f.write(content) - - print(f"Successfully added {len(new_projects)} projects") - -if __name__ == '__main__': - if len(sys.argv) < 3: - print("Usage: python add-projects-to-solution.py [project_file2] ...") - print(" Use @file_list to read project paths from a file (one per line)") - sys.exit(1) - - solution_path = sys.argv[1] - project_paths = [] - - # Check if first argument is a file list (starts with @) - for arg in sys.argv[2:]: - if arg.startswith('@'): - # Read project paths from file - list_file = arg[1:] - if not os.path.exists(list_file): - print(f"Error: Project list file not found: {list_file}") - sys.exit(1) - with open(list_file, 'r') as f: - for line in f: - line = line.strip() - if line and not line.startswith('#'): - project_paths.append(line) - else: - project_paths.append(arg) - - if not os.path.exists(solution_path): - print(f"Error: Solution file not found: {solution_path}") - sys.exit(1) - - for project_path in project_paths: - if not os.path.exists(project_path): - print(f"Error: Project file not found: {project_path}") - sys.exit(1) - - add_projects_to_solution(solution_path, project_paths) diff --git a/src/scripts/cli-dependencies.txt b/src/scripts/cli-dependencies.txt deleted file mode 100644 index 322be8e55..000000000 --- a/src/scripts/cli-dependencies.txt +++ /dev/null @@ -1,45 +0,0 @@ -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj -E:\dev\git.stella-ops.org\src\__Tests\__Libraries\StellaOps.Testing.Manifests\StellaOps.Testing.Manifests.csproj -E:\dev\git.stella-ops.org\src\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj -E:\dev\git.stella-ops.org\src\AirGap\StellaOps.AirGap.Importer\StellaOps.AirGap.Importer.csproj -E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj -E:\dev\git.stella-ops.org\src\Authority\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.EntryTrace\StellaOps.Scanner.EntryTrace.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Lang.Node\StellaOps.Scanner.Analyzers.Lang.Node.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Lang.Python\StellaOps.Scanner.Analyzers.Lang.Python.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Lang.Ruby\StellaOps.Scanner.Analyzers.Lang.Ruby.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Lang.Java\StellaOps.Scanner.Analyzers.Lang.Java.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Lang.Php\StellaOps.Scanner.Analyzers.Lang.Php.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Analyzers.Lang.Bun\StellaOps.Scanner.Analyzers.Lang.Bun.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Surface.Validation\StellaOps.Scanner.Surface.Validation.csproj -E:\dev\git.stella-ops.org\src\Policy\StellaOps.PolicyDsl\StellaOps.PolicyDsl.csproj -E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj -E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj -E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestation\StellaOps.Attestation.csproj -E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj -E:\dev\git.stella-ops.org\src\Authority\__Libraries\StellaOps.Authority.Persistence\StellaOps.Authority.Persistence.csproj -E:\dev\git.stella-ops.org\src\Scheduler\__Libraries\StellaOps.Scheduler.Persistence\StellaOps.Scheduler.Persistence.csproj -E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Persistence\StellaOps.Concelier.Persistence.csproj -E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy.Persistence\StellaOps.Policy.Persistence.csproj -E:\dev\git.stella-ops.org\src\Notify\__Libraries\StellaOps.Notify.Persistence\StellaOps.Notify.Persistence.csproj -E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj -E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj -E:\dev\git.stella-ops.org\src\ExportCenter\StellaOps.ExportCenter\StellaOps.ExportCenter.Client\StellaOps.ExportCenter.Client.csproj -E:\dev\git.stella-ops.org\src\ExportCenter\StellaOps.ExportCenter\StellaOps.ExportCenter.Core\StellaOps.ExportCenter.Core.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.AuditPack\StellaOps.AuditPack.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Provcache\StellaOps.Provcache.csproj -E:\dev\git.stella-ops.org\src\Signer\StellaOps.Signer\StellaOps.Signer.Infrastructure\StellaOps.Signer.Infrastructure.csproj -E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj -E:\dev\git.stella-ops.org\src\Symbols\StellaOps.Symbols.Core\StellaOps.Symbols.Core.csproj -E:\dev\git.stella-ops.org\src\Symbols\StellaOps.Symbols.Client\StellaOps.Symbols.Client.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj diff --git a/src/scripts/concelier-dependencies.txt b/src/scripts/concelier-dependencies.txt deleted file mode 100644 index 1e61e5697..000000000 --- a/src/scripts/concelier-dependencies.txt +++ /dev/null @@ -1,14 +0,0 @@ -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Provcache\StellaOps.Provcache.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj -E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.VersionComparison\StellaOps.VersionComparison.csproj -E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj -E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj -E:\dev\git.stella-ops.org\src\Scanner\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj -E:\dev\git.stella-ops.org\src\__Tests\__Libraries\StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj diff --git a/src/scripts/remove-duplicate-projects.py b/src/scripts/remove-duplicate-projects.py deleted file mode 100644 index a6ed10bc1..000000000 --- a/src/scripts/remove-duplicate-projects.py +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env python3 -""" -Remove duplicate project entries from a Visual Studio solution file. -""" -import sys -import re - -def remove_duplicates(solution_path): - """Remove duplicate project entries from a solution file.""" - - with open(solution_path, 'r', encoding='utf-8-sig') as f: - lines = f.readlines() - - # Track seen project GUIDs - seen_guids = set() - seen_names = set() - output_lines = [] - skip_until_endproject = False - project_guid = None - project_name = None - - for line in lines: - # Check if this is a project line - project_match = re.match(r'Project\("\{[^}]+\}"\) = "([^"]+)", "[^"]+", "\{([^}]+)\}"', line) - - if project_match: - project_name = project_match.group(1) - project_guid = project_match.group(2) - - # Check if we've seen this GUID or name before - if project_guid in seen_guids: - print(f" Removing duplicate project by GUID: {project_name} ({project_guid})") - skip_until_endproject = True - continue - elif project_name in seen_names: - print(f" Removing duplicate project by name: {project_name}") - skip_until_endproject = True - continue - else: - seen_guids.add(project_guid) - seen_names.add(project_name) - output_lines.append(line) - elif skip_until_endproject: - if line.strip() == 'EndProject': - skip_until_endproject = False - # Skip this line - continue - else: - output_lines.append(line) - - # Write back - with open(solution_path, 'w', encoding='utf-8-sig', newline='\r\n') as f: - f.writelines(output_lines) - - print(f"Cleaned up duplicates in {solution_path}") - -if __name__ == '__main__': - if len(sys.argv) < 2: - print("Usage: python remove-duplicate-projects.py ") - sys.exit(1) - - solution_path = sys.argv[1] - remove_duplicates(solution_path) diff --git a/src/scripts/update-nuget-packages.py b/src/scripts/update-nuget-packages.py deleted file mode 100644 index 54681bbd8..000000000 --- a/src/scripts/update-nuget-packages.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -""" -Update outdated NuGet packages in .csproj files to their latest stable versions. -""" -import os -import re -import sys -from pathlib import Path - -# Mapping of package names to their latest stable versions -# Based on `dotnet list package --outdated` results -PACKAGE_UPDATES = { - # Microsoft.Extensions packages - "Microsoft.Extensions.Caching.Memory": "10.0.1", - "Microsoft.Extensions.Configuration": "10.0.1", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", - "Microsoft.Extensions.Configuration.Binder": "10.0.1", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.1", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", - "Microsoft.Extensions.Configuration.Json": "10.0.1", - "Microsoft.Extensions.DependencyInjection": "10.0.1", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", - "Microsoft.Extensions.Http": "10.0.1", - "Microsoft.Extensions.Http.Resilience": "10.1.0", - "Microsoft.Extensions.Logging": "10.0.1", - "Microsoft.Extensions.Logging.Abstractions": "10.0.1", - "Microsoft.Extensions.Logging.Configuration": "10.0.1", - "Microsoft.Extensions.Logging.Console": "10.0.1", - "Microsoft.Extensions.Options": "10.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1", - "Microsoft.Extensions.Options.DataAnnotations": "10.0.1", - "Microsoft.Extensions.TimeProvider.Testing": "10.1.0", - - # EntityFrameworkCore packages - "Microsoft.EntityFrameworkCore": "10.0.1", - "Microsoft.EntityFrameworkCore.Design": "10.0.1", - - # ASP.NET Core packages - "Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.1", - "Microsoft.AspNetCore.Mvc.Testing": "10.0.1", - - # Test framework packages - "Microsoft.NET.Test.Sdk": "18.0.1", - "xunit.runner.visualstudio": "3.1.5", - "FluentAssertions": "8.8.0", - - # System packages - "System.CommandLine": "2.0.1", - "System.Text.Json": "10.0.1", - "System.Collections.Immutable": "10.0.1", - "System.Threading.RateLimiting": "10.0.1", - - # Database packages - "Npgsql": "10.0.1", - "Dapper": "2.1.66", - - # Third-party packages - "Blake3": "2.2.0", - "Sodium.Core": "1.4.0", - "JsonSchema.Net": "8.0.4", - "YamlDotNet": "16.3.0", - "Spectre.Console": "0.54.0", - "Spectre.Console.Testing": "0.54.0", - "StackExchange.Redis": "2.10.1", - "Pkcs11Interop": "5.3.0", - "Cronos": "0.11.1", - - # OpenTelemetry packages - "OpenTelemetry": "1.14.0", - "OpenTelemetry.Api": "1.14.0", - "OpenTelemetry.Exporter.Console": "1.14.0", - "OpenTelemetry.Exporter.OpenTelemetryProtocol": "1.14.0", - "OpenTelemetry.Extensions.Hosting": "1.14.0", - "OpenTelemetry.Instrumentation.AspNetCore": "1.14.0", - "OpenTelemetry.Instrumentation.Http": "1.14.0", - "OpenTelemetry.Instrumentation.Runtime": "1.14.0", - - # Testcontainers - "Testcontainers": "4.9.0", - "Testcontainers.PostgreSql": "4.9.0", - - # FsCheck - "FsCheck": "3.3.2", - "FsCheck.Xunit": "3.3.2", - - # AWS/GCP packages - "AWSSDK.KeyManagementService": "4.0.8.3", - "Google.Cloud.Kms.V1": "3.20.0", - - # Serilog packages - "Serilog.AspNetCore": "10.0.0", - "Serilog.Sinks.Console": "6.1.1", - - # OpenIddict - "OpenIddict.Abstractions": "7.2.0", -} - -def update_package_reference(line, package_name, new_version): - """Update a PackageReference line with the new version.""" - # Match: - pattern = rf'()' - match = re.search(pattern, line) - if match: - old_version = match.group(2) - if old_version != new_version: - updated_line = re.sub(pattern, rf'\g<1>{new_version}\g<3>', line) - return updated_line, old_version - return line, None - -def update_csproj_file(csproj_path, dry_run=False): - """Update package references in a .csproj file.""" - with open(csproj_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - - updated_lines = [] - updates_made = [] - - for line in lines: - updated_line = line - for package_name, new_version in PACKAGE_UPDATES.items(): - updated_line, old_version = update_package_reference(updated_line, package_name, new_version) - if old_version: - updates_made.append((package_name, old_version, new_version)) - break # Only one update per line - updated_lines.append(updated_line) - - if updates_made: - if not dry_run: - with open(csproj_path, 'w', encoding='utf-8', newline='\n') as f: - f.writelines(updated_lines) - - rel_path = os.path.relpath(csproj_path) - print(f"\n{rel_path}:") - for package_name, old_version, new_version in updates_made: - print(f" {package_name}: {old_version} -> {new_version}") - - return len(updates_made) - return 0 - -def find_and_update_csproj_files(base_paths, dry_run=False): - """Find and update all .csproj files in the given base paths.""" - total_updates = 0 - total_files = 0 - - for base_path in base_paths: - if not os.path.exists(base_path): - print(f"Warning: Path not found: {base_path}") - continue - - for root, dirs, files in os.walk(base_path): - for file in files: - if file.endswith('.csproj'): - csproj_path = os.path.join(root, file) - updates = update_csproj_file(csproj_path, dry_run) - if updates > 0: - total_files += 1 - total_updates += updates - - return total_files, total_updates - -if __name__ == '__main__': - dry_run = '--dry-run' in sys.argv - - # Solution directories to scan - solution_dirs = [ - 'src/Cartographer', - 'src/Cli', - 'src/Concelier', - 'src/Cryptography', - ] - - if dry_run: - print("DRY RUN MODE - No files will be modified\n") - else: - print("Updating NuGet packages to latest stable versions\n") - - total_files, total_updates = find_and_update_csproj_files(solution_dirs, dry_run) - - print(f"\n{'Would update' if dry_run else 'Updated'} {total_updates} package references in {total_files} files") diff --git a/tools/slntools/__init__.py b/tools/slntools/__init__.py new file mode 100644 index 000000000..71943954f --- /dev/null +++ b/tools/slntools/__init__.py @@ -0,0 +1,9 @@ +""" +StellaOps Solution and NuGet Tools. + +This package provides CLI tools for: +- sln_generator: Generate consistent .sln solution files +- nuget_normalizer: Normalize NuGet package versions across csproj files +""" + +__version__ = "1.0.0" diff --git a/tools/slntools/lib/__init__.py b/tools/slntools/lib/__init__.py new file mode 100644 index 000000000..4fb1c1489 --- /dev/null +++ b/tools/slntools/lib/__init__.py @@ -0,0 +1,37 @@ +""" +StellaOps Solution and NuGet Tools Library. + +This package provides shared utilities for: +- Parsing .csproj files +- Generating .sln solution files +- Normalizing NuGet package versions +""" + +from .models import CsprojProject, SolutionFolder, PackageUsage +from .version_utils import parse_version, compare_versions, is_stable, select_latest_stable +from .csproj_parser import find_all_csproj, parse_csproj, get_deterministic_guid +from .dependency_graph import build_dependency_graph, get_transitive_dependencies, classify_dependencies +from .sln_writer import generate_solution_content, build_folder_hierarchy + +__all__ = [ + # Models + "CsprojProject", + "SolutionFolder", + "PackageUsage", + # Version utilities + "parse_version", + "compare_versions", + "is_stable", + "select_latest_stable", + # Csproj parsing + "find_all_csproj", + "parse_csproj", + "get_deterministic_guid", + # Dependency graph + "build_dependency_graph", + "get_transitive_dependencies", + "classify_dependencies", + # Solution writer + "generate_solution_content", + "build_folder_hierarchy", +] diff --git a/tools/slntools/lib/__pycache__/__init__.cpython-313.pyc b/tools/slntools/lib/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1773cb4ccbdf85cec64afe25a4db60ec3b3102ae GIT binary patch literal 1036 zcmZuv%We}f6!j!&9+OOy^d<3F%r=XrTT~%HLQpY5B`9S>BU#RjGchdr znn4R%K^xjRSMKcv`>-Dzz=2Or$e{Atfy21ksa<_>2bxRCUS-N3q;jT3n)ob<{PWov z*ZxJCO69*4BVe%Ty6(k9C_iK2dlqv)gLEz;u6#9Nz#|`%rO*P2aSEsIvEOH)L=yYm zP+{yle=H;NC@xWxN~XEe zYZgm3$0CU+laj99Wg08d_30?)nnql6n2JOwEkYX4M1+Y)qnVHq-eo+AcoHsX3@n?N zdp1J@OBC8S=eGgqr8x{GQ%Z~%|InR+xf8E!a}Vf5pxY2m7M&&( z(KWz9B(P(lY@uSIYN2Mq4lL9yG%R=)b}ck5v@Em{&O1k@LTQwSl$!E1jkr`>^2S^H zOzmi>|5Y>I$~Br>2wTNXYul@>c8rVjLqC8(w{KgsecA7!pUDk&O3Z_2r^ATPhq2II zmCyTenyGFI@le|dAEL@?BgJS)DgK+Lw3{tV^&!4&Cix?{k3x3l)Ct0mqT@I}3l*pI odyhCzoNMxUNiM&V%O&Ywlm3!SuE}IcUi>N@)(amia2Ip_039|tHvj+t literal 0 HcmV?d00001 diff --git a/tools/slntools/lib/__pycache__/csproj_parser.cpython-313.pyc b/tools/slntools/lib/__pycache__/csproj_parser.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7857b36d18ba972c886bbe5ade13201706c8c76 GIT binary patch literal 8781 zcmbVRZEPDydY&bB$>oPcQKCdy)W=$OY|)nG56Q8k#C9Z0jv~v7!ZlM}teCeVR}vj+ zDa=xiq#Q1~`z750XZxDKa$B6;UsZt?P=EtY*FSZf{_sySyoAigK~&tefYAb_YbOnS zDA0Fixhslha>Xsknc10l-uc*hpLyPy$5mBM1kcak9E<;|6`|jg7vrHN@bY+#LFfY{ zB8)^vw47%!Lt_iJ(AYZ6sulqabJ!*j3F~?LFpqhfX3sl@o!AL!PPCnO4ZE>hKogcw zlhMd_+>R!yk3&o3!JeZ?w7<>Z8j%+rM=jVZ21MrsFS;fmnSR#AdM?WIQ0_o2VsHRI;Y*-91sS>*(P{Z?S#@)DDQXWi2=-+(ZMkQ&*Jpi@ph4 z=m=z%J+$t&W}8?~YT#YpDesUmszb1J!zff-`o(M-g&D&j6w7OtFj6d+$ZFBcEJj(#Zscvp#4wK++YdRm z^NrN;VnVTAGRz!>+(zkk+>XN5kZ1edNQqXXbt4XWjk4|7Y=sT8A}ew%3c+a0A44is zUtWTcQKoQV4m8+UR1LrYX`9HKX`W~<=5wOMOgls;Nq0LGTiCW2?M9c(FJea$%%lMG zu2|a8=s!%MQ`R;##ROeaKYR$MxkAAXo>`;96i&{9g^-U8))iJ^t&2l_$BsWQ%t-2t zkem`yGi1ppq*VMWG0D^n7@Qz0Dy$tL3ibyJ9&BV$kfJ!Lssf%@h@JM~X|>lWz(cC^ zg2m|-rww}{7D5b!%9snTSLlbeXEqa+647~Ry1WOp*B!~C{RExP5M8Fl7O84Ag$Z_m z{m;+M$v8^XLUXfHO6ZWgrn@?Y_VMC#h{R=qQPv)GYRrVj9A4t)Q&Wddjy|k`Py3LE zKw}O)BggQ*02)@-r(G+3!-oskzbj*cK`$7EgNS_m{An$a!PsgOY>;!AQ&a}srt z$;A|yGCw^Zk3~pzmZVf-S>$uTqn#S;oruX-Cjch8;Oiw4(xK#>+Lgr96RBh}p-!j? zMNdHACUlr2aHGn&Yi?0%0330w*LC~U$*4D`!e2rcT($O)_XMPJ(2OA$a-H`9wq|2Znpli z^^P+q9L)$vH}+?R-mLeg#?zkl?9F)&WIPA5 zp3dc=e09ThyvD2~RxYm{T^qk6ty|VEr<*(1`_^YR@MimZGJWXurgWFTUwgm*t|xtF zEIsymI`n4x(p%}vKTLUGyC~B0Tsqqk0r09CTh^|o^ zLYItEq%dI%%`xp?u^dK<6;Rj2Q&ZT)90Q?aKvph+KK-eLrXmw2IAbC*;EHuG!E}Q9 zWe?zT5$WsD&@w^_D_Q~F*h=UN5`K^X^a@p%se#up^CE;X7}EtnPLWkO0lj8%5me)h zzDuMOKs0OtT`i!?4lPwc>agADl~I%G4ScU&3G`YkAv(12Jy>qEB%ljFMTqqG5{y!R z!sfoR7X>+~1*XdR9VBI5iOCpH*yJfY0MuAUy+Q~U)V2`8VO5rJbcSN6v8u=#>lFq} z%A-iL0P2+9&_yV8#N;VyK9QoCKrcE4pckEjoQie@cW5%!XD^T|pkqW4L1wyUOB9x& z?+9!NWZ#e`)nsBGpc|^p(6)9kvra)x;*=Z{CKsuMbE#>&&#@$QS%JaI3!tr6SmF;9 zE1$aa32Xa4kboF8%A;!Kw0l`kjIDN6JtW1@lgmg zcM&29YXv3olsv1k>f7--&8BN6E$H*cgFFRc2=F0N*)s*qH3G~vqHDnekb55f>ZcGa zqk@2VU&bDI;6J$H&fEPNd)tFRcbae9YTLa$vgN9~c4qZV&efc8H9z31uT`&Br+0O4 z3~ltL{ilD&pUHayX+E%3(|EJ`M)lg#XEg^_*e#E5h0QlK-AufnSUH#X)L(md_1(3^ zr=A0V)YsY1Yw91_k$=xOPUNon-0gd8Lya9PujD;-*Ope7*7gI=WuO80ZI5AKNOPGROKN5Z!6b7;A@J8= zGaq~hp#&tLk7?w)0NPAYtOSP7Lff>ZwG<9XyJZiMMr0VdVhLL+!JyF-3c?k@P2nz? zFg4B?R93NFG704P5i{3*$%LD208#i7SV18eY58OHA|7iQ8abff56C ze2&2OpO`n;T!4}S@r8|nHD*IVZAi2zY#%#q(c>w`q@K2=N6s&ZTBugYLVQBiQJY{~U+`-T@L*y~6l~5kgO&T6%>b zr*per_^!AU00d)< zqAXCpSxVNlT{^3aRA|gBdZtZG))YPkLC}HEku;^GEb*;1mNv)8*c=Lem52;Opc3!R z0KX#oxoJlY=z9YM-XoG(2mtz;k=vhhwPai^Iagc8)kXl$z3NW;yEo2noJ!aC{f_Sk zSSqj_=X>DsUR(It!kP;RAfOw7)sI$x^z$FDumv~O63Dm$%L70u-R*Y{W?e7PbW`_6 zPj=T}nr(mr{pBRQ{K3_juWHOy37IM(Th*Sg@{(WR5d0pwY;Ict**M#`Pj(}B^|e#0 zrye5(f79j+3tidK;|agl39}I|5^zlwMYF z@r`n$U2;$XSZ<>7w#Kl{7&lP5zkwZvEN%K!qJbie1(4AeW{At)&6U*sC!G$4Efj?t z3@w1T`v`UY6MA{7*AccqgSbw`-e0gVOTg0%CaeO7n33-MTgU%wNHi>l~pT?YYNohlJfH2h#n?$mM@-5`lYY6T~E&#*-k z_~7JCKM_-%T21-bH2bRxrM0*o<`|QSUfN@h+^i~;PCZZ+z&D3b>PRRq-l)@UZ|2SLbDQt6`+^`q7KF{kynDq_yh!+V{joV)1wMtj>O0* zXlhP#fU4#!&MJ!9SAn|A7E-vjMdV z%foqZbI!Xb^3GP%?%RiN9bVtNVa+!6T(=i&4(|mfFC54SJ-N11 znYL5ewlmj<^8U7)@7;JW=kLh)JMxX~xyE3oF}U9GS!4Gf8e4By->S~H_TAloch~*> z>Cjv0*Dt3pN7Btd{Kjh8)$}NUY669Q$lZ`;>%VD)9D1Xw;HWVfSF;|e1rXayf19s51qg8Hk6oh)KN~69>Q3oFjl~~|MytU zmW_7kSS!{StvhxMF2rxoqmeSSNX^iniV~If4C7mk*OAcyhJT^s@P)66c+7w^&Mv)g zAf--jOuR5q@6STYorjPEae6%=!KK42IFbYsjSB?oNCOSLD#QIoQW5l{N&;?W-N8SA zL1^|jbh)4nlXDi8@&sL1&P` zW6)QK2+>x_Sc=5Ci5$WfH^vC59wGhIw~AR1D-d~dR!u)8q0toW*#Mm(&0XAJXa&th z>*_t;K|u*!q?7k+NPL$oUV1~LkQ*ST5@>`GrElMJN=c!$!VQpN)FvbV{1Rd%!pS7u zgqYv~58&T{{@Cg4{!uYD5noXkv-`9J=mlK_l2cYWXE?dowHuyBDkVP zbvWI@@kw&pb(I|XQ`S^6r@C;-iXb;9DG6>10T%QNmf}@o*-|O?yo?|1@IonLfh$q~ z65SEqWHY3LC;EtINC@{ze(OgK z41_^uG-w$e4Yq3bNFlNQ=r*Wiv+VVF#yd5*Q;_urH-%W0K*&6&k^~EZg~-%?RA4J?jb=N3CMK_ zT>Ho=jZMJuBR$u{#KYC>#l|Ub)7Thn6%>9kFYsssNx$AbjeiQ|1OwDJ zAcQ$$m`4^n!#=J>jOPnf{S|6YqxLUR5!8r_ZE z@A>G;6OvOHW&(`Q5P{@I&!%-#+~hwz{RF{l;Uy>2ygpDs5N(K0NK|O5XMFiuXdU7$ U=miI{-g4|Gm%p~d8=d$614Z{FWB>pF literal 0 HcmV?d00001 diff --git a/tools/slntools/lib/__pycache__/dependency_graph.cpython-313.pyc b/tools/slntools/lib/__pycache__/dependency_graph.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7452391aec976756ee22fe45c7f90a7565ca665 GIT binary patch literal 8361 zcmb_hU2GdycD}=zA%}nBpMEIGkwy|_nW`-*slA)k+Cd!4UdNVfM-*Faq?)D4kwlv! z)jO0O8A+GfEwB^{)O5RUWMqN(p(q?AK!XC^rrU?YXo2pdSvnFus|8dzeb_e|^0vXE z4?XA33`r@pcU$xVyfgRCx##|!pYI&*y4?-}eotltr7 zW1|CX=JtlY8oLJm9gL#2>CPk=VXV?Pk07INTeO6`ZvLy|63t#!5;-}ktSFhJl1Z$| zi!{EnB(LUD=~OPIs3Q&}x}HiZs=Tn8N#s)5j4J1{^{wQjULp^a9N;%PVoJ zDoJ@opF#!t?F)pW5YEI5B)J_tv3nO_G=8D zNU{5j(=~ZHzOs_aESmh__Hs*b&9Vx226HzE2!2r=VP50mo8~tb5TnY1LP3HQRTGdT z%u^feRuh>(X*P4Ac(Sl=?e){+^GW6U{9-CMqUMxzI({U(qK;(g;(RWfO{?>2I-|d& zQ&)h}6wnysH?p#(wO>K`i|zkJ(P#wVr@jnTo>YS*bTEJVNuax6+wSSRH?-A%ywrcZ z+;gHJ-b$~h%To7Kp0pmQT8Yb-pWKB(M01(j!mUs=WSZ@I3Itb4CU6I4!7Vabc?IO+ z4vAV4U=Hgta?GI1SY|EXhWB{WuOS#W-N@A#6(y!L$<283QEnQwdS8h)p*?0uYDS3i z)41k+B`VZsiV7g>BOvSIoZTdweG69TG+XtjxxZt^>ND2o@VD0aPmLM!&!hauFT%k& z&@#l+>E~Goc>z>EZptRe|JisZnFfoLNYTVLk#bjlhZPbM{f5wz60x{{3N z6s;Q?vAPMa>kb^7CM2>eYhfGp!&oNr6tzf`;9`(a)tt!`g@x%29=bF~otKJY1&WP@ zdXPoXtcKu4eGe*7#t@M_?|*OG-}V=S1>24-P`34Kd;EX8T5#!l9|^JjPb9Up{mhc_la9r$Fh>>taYuL!OmUVQ&z@%$q}e&XpW@Y`);p9Mbu{ePPO zhxvcJTyC2zI4Y9&mS^3w;n}Grt2a*%E&j20Lm#-!Qj(_4qlyznSWe%QJOtMdgiy_g}lIFa@mN z_P9I^H!bUOoCUWBt_{`P01_y>rZ^a+1_AVr)`#s=%LX@kBL%kOnzCj%*dy|giC%^p zEC(7Xq)cUunNbqiQei+WHkrCY;}nSYk71Sum(oBpYK}WT#n8}&__Fd^(}7voDK#ck z9hzjWENsk7r2)|^yH#}T5%a#_s^zyPqJ=J>sMdQ~}1X_jj3F|;El7KqxvTrbduHxy~^7NKGy=6}yn2y%&t=5r8ts@1g z66oFv43q)`<-lM;s#-<6ui|Rma>*r^e6Q<~>u|;8FOHX7JzK89l56nZ(j(XCV^`}_ z4`~~%_7j&sKlRnK0P%M163*^>?DAH5c>emCL|TXMA1Vh%W#3ae;RABck|$1D%V94AkR4~}zC(?igmXatdlgB;zqC0D$9{0L z_|^};SMhWL(Dio||LArwe{RR-EB-;*)?e`)sMrI=wX$8Vc!K!)UfCXkUZC}MJ(N1X zdKM0Fo#FMN*%9eMDWQqQ8&L6Vvc4$`9;^@L-HJ zV@6_J4tq5LnQGSS2tQO7+|z!zW<9(B$sl#}%IqSGeJBcqCPKWvtZG&@OCfNlA?&lJ zvx|!g)oeH7Gy`E_xP_v5sDl=I6vgh=%N_Ixf+@4xi|oT^mQ2eW8`rZL^n^K4U^X3hI1=8TnY}C zgNMt3Bl*c~SO2}^W!G5#^osxySn}UFlAmH}kS_xx_iy~oyC1*%d9)ljRgj(p<-*xY zNAFh0P^n|6+z~FEty%?lTg4mP@`g&@(7m@GdB+}mgS&3h+4t0r*^*&0GL}D6={~g8 zJyPl(+3X%$_W%&;eyLyu@Uz|8wQ=k(rwS7lPoV7SuXx)_UU|zqRPqkpca{!)YtuXa zrMLZqmfI~4``-Ln=x6N@doOKz-`cg4wtfiO4!m6T8o+CawDth-a<@J9wgT{Sw;}Kf zj@(}=cbtSpdpgRV!`s70J{c^!fqBK%S#k|*xrRzE@PnJKmv!j%s_)dR!au+2m=JCM zB5+XC<8Y{Zpck4E=AAx=7s%u^1zx7@$w15l(2E1h3jR97gMH_lMRrZ2wFc`7;0=Xu zU#SB&uw1VgHq`R9I1PL&#%WWh2&sWD?&gD9GR2f$Fg5XF~Y5>^{I`aO;`U@8)=oRErx*F%*a49BLfDE?jAnrv>-6*6dwdRsQ2WUYgp)S zVL6w{Wy@v$vT#|v19G+8kYhIGZCU(!PH|a?aC6}M^%Akn;VH0E??s`G@DbstHEN0S zQOF-e#iL?Ggg*hKtHLrrgL&;%kYFy#&4}o0lfZEbqQDuxA2QUV#Pr1=iS4>1&VVEW z$o~-o`QS&J=im$vXPm#DDcd7(&&j4eM{rnT43cC@Jh5bC$x%vvwhRGlx%&%{iETk`Us?yXC#Ds>_WjW!8&Vcoch>k4YpmcW`=())$WE|ELtua%A_aq)hj;FJUcv^jJ1Q=UEVAKy_%vX6*kvzAY z>(1iDBdN0@`EI$_-Noc1skD30_SP(eNhV-Ec&C-DupO)p@D zdcaaJe<;5WMYo&>UUVSz5)AoI9EK+JzrltEiQJz*v)y^%!?yf)w!3>iJeGg6Dzu2b zKMGX|Jbs+~tD|?Xve)}F|AjrftdfbZy&zPap5l!!oIQ_So}y5^@@G@v_kHb{tc8S& zo~(sPh=kiQtWIVVv6yB<>;{+({!IdO1^)|C+^c5It>J%H6b+ze1Ud9z#W38D#ppPEVdtX>N%#XyKQu3Pf|^0rT+LCIb<)w~>-Yh^2iyP0L|+*uxHajx z?qy!1bI=44Au9gU4Jd`&{-WJp+iq?{akCs-(Kb6xdSIB6cZbv z+o__bY1cbTZn=u|aF$kkq4oXm{>qN6b6lA7SKM7y9-cdZrbKwcFi)Ur z!#ALI6b1^p;>7w*eR?}~&vJcS*S(o4fyZb1hDnbJ|!t#!k h#NaaVqJYp;%shAEGJ;M7HAfIr~_=U&eOWUqCnpIn4e))z{VlEK#KMyZz{Y%UVF}vRD9cJ9~P(p9iEwQ zIOI7q-*<*v@py!R-_U1M+8@CC7dE_Kz;(9%4$f0XWC$a&qHls7_YvQ?pZIxf`zN^Z z00~f^n+T4FNQh@7UmtUd5d$AEV(^mBndg!_9SOT=2+;5zw8KR^0FCTHBQ6>RG`0tg zx@a8G#2z#zCP`fEBnh#LB&*#*&+^4cUa=H@u~t!alb^2<-mK|$ORE{YVpRAgQoFB~ zEq+lkl&ZR@8dfIq2BQ#sc1H#7EbEGC+JSjZ)hhyPb5n}70N3#363(IMWjADc;oQd7 zSKvHlB!;k%QlH2Y{{SQU2qy)kpy)pXs1yQ^YkL9eg-8kUL#d)Zl&YGQF)dZsm2_>%%+yF#vT8Nm zluX@l99^4}oQ;xMdSr)X8K;zG`{0h0WxW1gn0~7h%nI|5WY22;>)TIm|LJ!d;p1D- z$Jl|IwV;yn_E*RKK|}lvI6FdQ@3qHyoBAw_!CwW!sIrg*Z+AJ})XuW|^?rDZVIVEO zQc>{R5zW*L(^6n9NEmK%Qsg6t&_4#o#3%DS>4S?Mlx6h+gxtG3WbDOT%mFYM@Zq(A zZ;!o9UT7pQZ1(hRCVT$;=*vfIg>S~6jW?2~H^QgMQ5gB0DG5E8O58UvZ$y?M!*w0~ zm@Gf2E4qs$WqDpBrlo6!YSaJ@$#SJumSuw5+y+=NjGCn&?M#6ErbP&*#O8Dj{3LZN zTG=AFCbnNQEOHpVU~LXLZgX?6+~gE?9gt;6iKUfg#j=PtSGQD{lcWHnuT7KTqziL~ zyULWo{E_)5cm8!G@x}ONB>u&1YF-VTdy#Dn+}Pj_u5oKK&mv9k#w#K7!fFV4r%zn( zU;EXwM3c+6I5u!%t^WrGF0G(HaAvL8V&JlFy~T_3YVDlFVm-jXYHx5`|kx3=QVL zWJi}2kQ7Ts@gZ^Ovcu&CO|L+w9d?DsBwYafJi0=9@Y%?6auAFoC(h2G`|8ln5Zk(N z7VkKM1{pv(_Wx4>asu8#@DmXak9x>|4IDChwsndc|hL7-w^OJEONlh4&ZD=3bf0-%VjA3^k4ui zgwqui`>U4W?>2J42w) zD`iqMO&$stUxpLYoMJ-dk9h?gmD(X*kj}6V*>EBK@Zb83hWf6E-@{(|+rN@chdlh< z)jLjlrQW-y7=c|riSLcdk8#uu?Ll4TAF9N}@&EyY^tx*9 zrnB8yRV`W3b=jN1KDIr1nceVJn9M|CtuX&i_Ix$A#(fig7Jb=wuF-d{*>}F#n{FmE z8{rHUfS*TyMs%)J&e(2%m7|4e3`BI)4h)IT%XPXl|gvy5jIXj~-?D zE%f%I^(P3RLD0Mg25Eimb@zcLci`3W{=elL$46F*kMpa&tLEzAFDIMaNQ?6YF07~4 z&A$jgFyOYrz5u`8^A>#Gd3l_02UWdYx`j*pKB~4$EuQ7QQX8&@gs|OHs*y!S*Ouvd zWo$t)fP*=hbNR}S8t85~zD!5Z?-RvaMW-OT%bc;J&aqsk=kO?$OmDZ^QA7Q$*PwD( z5;}f-J!smg^imPtFWD3Dq67`Tv5Ch%Om^;d7bbhNtGCJZg8h!`b+U*fcVrP48h=~A z2J@aQzB{lu(iiZg58o5=8l=aL5FArt?IVo)#Y5td>&UKLPpc+;k-Qm)yO*<01KDD@ z>w#2xm*M^Nj$DUZ?S!QeMHBCAt z0j+W~@En?DGK(Iqf%~n-FA+t9F_~E~+qV8@@1Z7l$UASY+*F;^JO7RCnC!-GS`^w3a#I6Yd-j;03(#|E>-Dv|3FJJP%$v6EW^%m=EzoeXP06!wW3Kyv_s|!UWiW zW`rdyytN~mlUkw`u#6kwPU?sb^16V2M1Rsi3^Z>T;ZGWg@uZ2EPMV4Nq=i^cT8UL) zCRqOtb(+GviJ7n;fVuD?6#<@9HZp;RfN`Q~!lHw+fN4S-Fi$uFmI>#CW5T*$gQffV zfVGafz+GU8D`2BFx%NtZzz)5(_Rt3^um`DbWMGEIKqa&&%u-c6dcZNEEB>Z%I45dg zM8|$hz?GZX9jJyeYUoUk{rZ5Lj$cbhwoKIQ=K{5~qz>lu(9$|sM?D><23D{mH%|SG z#w#rTttk+XE+iuHm@plQib9)DipIRcj2IJ1m=;52G?IvjlFwv1L*kbrQ=%kHFT^IX zRuU5NUejLT_(CK)B}n<9;?XIQ2(u9wgiOv}5vECePM9b03*ux#g7!no_@XcwpPP@0 z2~og#6%Z!lv4j{)n0||gbIQxg+?jA<7T)~nd7LvG{Vkpx6jaZMiO>}ECUcQkL`p;^ zLo*AJDX&hp9{=2#zCa)}*5@A?@XPN0z;K`cOz4dN^vQw#v!Sz}J2Ma(3O;v2t__?X z4W51O^r_I`>CqDd{-P>tzl0FOnh|Q*rf$?AUEJb7jYTF&T#8R8gcl;xLO3b}5(`t2 zxS+NZ24O=xguytO3qu=`NVv=Up7M2dnVL)bH)_kr9;j14jlX7~e!iE)igkn=;o(#Ox0mN1Op@OW8} zRgp5L8#k-X zj1THz6#*zQ1`YY%JoGl$n1D8Dv@zY)cK|_!aaEG~b2V0@|+(ATtzf)~dc*pp$Aj6m$fdMF>n6CM) ze2?lvOEu@ORda=RkW*n4n6dlHctPzhW+yWaYgQfu6NYmW7!Nb{fMKSYqgoF$&3bj= z7%blOe?fU+9xxaQNIwz737{V_QA&tY!ZbxCNdN?yoQ=e0gv6{U%+FqtB9nkma8&{E z;^c}TCCI`gTvZW<=p!>yuStMMz1h9OC?LNOpU#&6RTAQ4CLD__inOVE^?QZmfCNP& zPsfP>b4@Npf!YB8M8wP`fC3@TO@qkNE2z{5X2Ajgp5zvwG#1)K-;A$AXbOb}UIm&2 zgsRDFGWjWv0Wp=2`*Ynlts&*O~B9?&5WY?XQ_R7QxON8)3WnNu>`d54p^Sa?p9 zIV5v3hcL?K`7i-;sZc(vY#<_#;Y%XHivXBTD`m5~u23Q_n_dV<7sLTV;zZ`6uywKy znk6Knhb{z>pCIlL7bb{#vL0X+V^dx;{Upe)i47|Zxf7HPxxM6Y5ZMiL?1Z1>f!lk7$?jtIble!q*lW^uPs;9D zx9_|${Gp?M$&l4>Mpve$@oU#!yOyrmld9R1uIWkD^sK$QUi0kI@FNpb=~_PbwO3wy zWxZn8lI{b$^L>l+_j<-sm2uTC5C2_aW#H>qQ_ek016e)eu3NHXTD(i<51qA3CqA&( zXPSH~jjLZ+bFS%=o`bJh?t7Y-E%)v9>-IgHjr%v9?%O-oox8uwt@^*iKh$bGRmY7RQZKe|U77Wh&|(G8&`n*A{11 z2k(z1q-Afk^dHvz^swoKLH9E~3#n`ubN4zlL*fhblFUnFGDPC>#AGqiM>&tOkar-Z zEJX7p76L?AJC1$mGmf;6K0&QYrV3>Njarr`rQCR)nFQD%7t{u{l$16jDIG_aMxP+# z&odRZtiZ_BQ60<>JN!9@S%5Xozfi(lkez6OA?SiSg&~xMy1)SPD=7mte~wT#0+>{Q zKd1}xzzR4De=EX|j0qqD$YVxeupsyLo&)C74mE5*zZc6*L1Q@<=|>zbdHi`cRj3G> z)V0UVr}vz5(J?K5;+U4gn2pSMo&^QX{<^}mIoPL)ppBkg1wA|4xnJkmc)7c0Q%C#MyH{Csb``uCyt`>Yud6A$AN-7)1fdP2`xkhBEOgb!&i$Hz5=M<9E`7 zqaQC401V*{AfY({K%uk6qCT)il}-p#BJvF}HmT?jr*k}6Dzs3UxUc~2h*032oks%k z1u`if>pXG>*vYXitY4iVM~HfbGK^KC-GRFFMKfVrxZQNFIyu3o_p6b!`&ls=%e zvmhV*x!AX|?mGKweqy>s)kHX9I z06)gmQ3;vEQT_*RBrtWM3{wX#T(>*PRd3dI{tK5pFtpw^{EJ=p$j{r>YXex^)RnQ; zW~?6kce;VMHtTN;r8v(c8{=tN38hO^R0&0 z_|?UvyLZXD>Gpj$u{O5edHiQ5?p6JCc-=j=WW8^8ubW#pTe>r@hK#ET|9ALclHInY zp_HZRQ3c}`mWHw#3&7y0zkPk((Q)6?y1F;zIk-HW@$CNQwKuM%Jx`@PPo+J_Ql4XX zUtRYMzcvihH@Byn4lIw9YjbtoGxFN-BMalIz3u*H;~S0Z&bFn24;=1HMNO(=ce2$S0oNsmAVfyASF^Elp;Yz$(@qq%J9@w==_!Wuq1`%!sMbzCiBc2 z21fisCh_nt=tr+JolHyz;sg|wKK?u_Db(b3WqpuS*;^O{QqXLR=g4&!N9?DM%7BDz zS{Ag*?OGJHxz8f{2+X)@iUX}#ojqm-|4CciTV10Lj7JnJN80q4e zS7DoD$CP)GpknYNVJ&%7NY%Xoct9A7na(ZZLB`DGPc)zz&x-)mnqc-LsV;e-hJ#g? zuhBt`P|^WDp^v`1NbK2W{=7g6g3(Klt&|V&bxgo`SOcpG!78ldNS^6)Zm!(Uwt*-TK5a@`B~I zv*eA_fJ+6@nMY%C?wo+@#Xwjtw{IZumS2Bm&<>|j)m? z?YeGV0s67wGPqx#zf*zSohm~sgT}nA(FJPR-}SKvj8csK`7lBO5wJy zA^0cWnH`1w7zgUFJm<>ua<#hXou_>j+niEE5Y$W9_a|f7lW$&Q0oHA1%iEQ~4d(HK zQK0k*=Ffi@;}yb}QFZ$a?w2jD-7gpR0Z7=+)p_> zb6Y&;K~i9OOycVmUYr#{3nZv4LM>gRA`^nW7G*+oSk0k#YO7the_J<}5SGw<4VGRL z)%+IGiH0`awx+9#hU-mU;i%BG$Tfl8cu|Mx)kV$qCfPI)o60qugcHj@n0LVMA!>fF zGv!6lUCbT!)E$ME^XYk z&3VHH&x-_|#-(RmXEY9j2U*?RQ}HPgARg=+4Gb)Hl#F(YI*D?J_1H+d>(KG4%wGTh zI-vTk2h?2x9nzVY6giLH@hi%GQ(!E*JBw9Dqq@`~Fq2S-3-|M5jn(9>RxCNx?1Q2| zF+d{Q^M2{!fj;m{gRd5;$h^!=feT62FU(JYT}!d`pdVAQ{~+Tg;FSa?3!tS$(DF^G z05t{4RUveklbnl7(S{MH6_X5VoicYJ9*KD?Ne>oNn-Z$aVxvIfa(hZcD+)I1KWy@RNQ4`Z!pI+Q1caV<2Ozz7@Y2Pup5jww85U>y06B-0-y- z-g?V&)AH4qH~8jUe%ZglyEB~euSfrK^sAn9WoxRkb-mKN!R-NejH71x@>llW82P|Z zli?j{z77mS$@=yUz5{(VdsokW$97{h<84oJuKS)nX-`+m)Ab%0arpWrbH-WswbNfc zy>udDcdXl6GZj1170s!N=9Lp072ZsH&+VyiM&5|5TiPBP7}u@`T1MywOO(6u_QiCg zFV*N}Gxg%BSUDD^^&Dd%;>bjG)``3o=zVMUrAC2D~yXU+&{mYC0aq-^E z$(KH#49}#)(Ns8^{QO*UZa!)G!XvH5Xacj3(S~bZo_LRMdSGVUPk~9wS$Df5)p2CQ zadgSB>1bJ*`QEiV*VY|JmiXe?o=VnsuN}HO^pnvajov*5v(4UnDf!~d$ydbmD;H9) zTu8orF?lhTw8YEL6^H|b#YJ)#rQaX}!jc)K`C8f_NU2qcu zC8ej6t~2lP&%=JfDlB=pO8`2t!42er?cCtH@*gD)pWWd4;O2bFA)71H<_7prnHw^e z%3Gs1M{oD6?EZH9x7t@4R%h0x(>=qfp5f$y=k8S{Ye(O+j6E>mQChYfWyV}}>&VR` z$@-3W%^jJ#_V10}8BNt4UK+``gp_OV(qLie)^B%ws{8`<4*WkK+=*F$d)2hol&n0k!S$%6mAD`G z6=Ykk-{2Z@70Xo{ob$f9^48NgpI%;AH#a1?2FfmF9wUk<0Wtt}L2|Oi1&OK%;63y3 z2avoDp;zS{kU>V3uVb2mBd=ijDrZ^3z%*3{psp6AoAd!!3lMP7*l}k16s%uQ8cEW4 zm|cY!3+>`BK}b(*m7tX=LrgJ;lE1V4z;1pDBFA;P>0w}~s2$v(E4HV5Qw7nRKO~o%mfS3~XgjxWN zC^IR(4tB!JQKyBh1$KtP3iHwMq)1&DUM-n`T_Z0;B5Q$5Nw|+d?FF5t`3 zS;zoYuh!{0Gd5S+wlihhxze_7YhTiC+N+kkzx=A=)?d-CoCVifilUrXDYQ}*VS{#EC?-S-af1JAdv^J57un}5~rWB$3NuUh+-i-mNHfPvhZ-mGT< z3@U)byYx}5gn7Ll+RU)v1})qSCZM5CYORKeu>}WX;l_Y1ZfDLG+8opbbiF?Yr>_R@ z*J*dbLLAiQ^>VNifext0KlOeJ%#6WYu9V%$bTGxxO=~dDM}WT>R*CZ|i3Dl^+Wg-~}U>GI@r7;e88!irq#MOMP z=@q;NSuef{5jT>|frK2BjbdzJ4kIl^MeH*UygDgT9aYvx<1;fN;K=1LiNP#h9Tm!C zc3Ng57`GD>Wmc4s(ova6k%%f`)3$mshy*SAS-_cZKn8He$yl7Xp1Ju<+PouW-jT68 zvmCD*W;d;s%Wb#&SKHsQ_T1gMS-)%L`EML~&9Ka79M0RCuN-{eS-a^FR!+R@Xv@@l zJ~A`5gGp=8`}M6KX(5}fVl0&(IUu)T?V%r^JTfpAz&IAi=54oZH*LvU2%=fL@TTU? zeQ!1=YX;N&P?8_IZ?5b+1#eYpFz=-K22)?V z;jLCJmbcT?XF3kGhw6{JwQsw1ke3ZPT~;f9~WEkr0KSfbul-3))f^mh0M^kE7cfQ;e9ByAEJknih~8bO=-NSZhKE zLxdsdFIT7yYdg9jElf^|k~F;#721HeU5+n6+%wp{ipqh0z!U=9IIn53_C?x4fl*Qk z69i2QXT2JTnxo-)5J*L-LMVPwW-k*FwwRoR#H%6G@RAKN@iOB3sYS<&BwoBzIM8Bk zf$o&dLuR4-pqD3eI1Cqw0Y64}f~=dn2yz6uK=ZT{iPF4?QGq#9LlRI#7BGopLLe!n zHxAmlU_iq2B7Xr5Uq*`aJ;>Z(elIY*9Z7)sra4*NN&V%{-VOd~(15a>Md!NDSKPAS zw5R!o6yLBiwRSd%xn7WnZB+_Qx_jvz>)^ee8H@GS(9NOcp$*FpBs)Vav@zP1fxWA3 zt941|{tf;B4E&K1tKYQ_egXk|NQX2!7s3FlPO_$J&An#2+jRH&yZuSmvm1Qhrrr4o z3IGuL)(J5G!|vl2<}JRe?`iE@bpqy}HXSz_-qsl)|F*?+{Aumm)%C}Fv~Tz5An#?V zY0s-UHRxpry*p$>C^QwH425I^5^*5jc+!bk2#=uQm9jo@1!F%62Xo2RNg8JkLM)MB z`>7hPhtLTSfGATSR7Iz%JTe*r0bh)41o>=KVGw*Mgh8R95ItoAQAN2Jkk4ao43piM zpim-n=OKQQD#waIh0KAT*B!+UrEn4ch!VPSjC={|;UpR9I;2@G%d!tNdX{@w!LasU zG45Y6_5a1}`v{6OzhfW)jm}h=HrBmkth;?^-MH(9KC9O`xEuCtHREtSFg(LLGH|9x zL0Zdd;p4uoDyxGxXmQqxELLZEMsHebUixCv)R59O{?3SHCp0gz&=aB=nr|mo#vm9X zS@%@R)t%L1IUNKuFiLUXM+RDWO~V}RV>6A-56tr{+X(Y#wU~i!3LrZCg1+$Gy!^#v zRZGgcJ8PhYJX7O=8T(fole-S4Jcm-%hqFdnV`AJpvUEVu|MbS+S!wCCwu0?kab_8K zt#R~q*YOCyvb$^8$`vSr*Q)aozOua#togtour3M{yzj#%=-^F($zl!Nj^7(0NA;fn E1s0@XBLDyZ literal 0 HcmV?d00001 diff --git a/tools/slntools/lib/__pycache__/version_utils.cpython-313.pyc b/tools/slntools/lib/__pycache__/version_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a274e3c0c9f5e5801e74bb9df38d4ba60adda3b GIT binary patch literal 7703 zcmb7JYit`=cD^$l4k?lnNl~v@k~4b560JvKMajmpW7l=;^;1qXqSuWWvm!almq^&wPh`6+9_G3T)ExVW?0kba=Xs+v9TmgeOpCQh zr3C(_H((`9)VU{*tIX3RyTdNo6Sjv%JB*nW z&!}FxH4HQy%VuFU7!zQ<{?aTiptLbc>mna)U%TPDtWqtWN=hpVl*vU&NyemTY8jew z4alo{JQ3I9swOR@s5HI$O;rch=yhdL)y7?}Gr*pJPFY=siKLt6WSVplHw{gf5Jdx=jXEenWU5O?XO@oH>(uxkdP!hAy5Ep1_O-)9T(@Ks~W%p~S z+$UiIVk4B7Icj@_$UGJB;AGnn^s<^zuGz6Bi_{TzhMVwsfX`8)xvFSRMr)$(a1(I2 zSj!#uO!Bg)bnMMCfn%%)qD8Jyyd@!2C_YBdD@`qbNR84XK{@r0a2#^V+tWW{D zowhBC!49MOU66?yD@o{y2ll?!Wzr-~T~?BMJZc^JxM5#buB9jx@gx>2iXL5>He3}U z7%lUw@kA`Ltm+D$y+I6TipCe?aO{RiRehBvqa~Gvr3gV)x55AJe#q{V5Sf9pKFQn# zGDw2_+SCl2L6c_bsw%}7Bz-BaN${sgny$IyC6A5K3wW5uu=wet;r4m|LOC-%oB0e83>0SgiRvTaW&}fQl z5p!juv*skn%N?Dtj^>9fO&)s#-~IL{-`*O`dJkls2X=u-40}pnQfahw=aEDN_-BXm zhYUv=9|};b$FP9}4-iM!rAb?o4{?o-7O0J8;r5utc4c7d79k3(0W}yoh$~vSSG8&s z@CxQ`)C1qN$n7!8?Av=B596AumMK*m&(?T-3teTKx&NTBHhhwSk!mRm0#PE7x`kL( zLLIsa5Hx;zmf~pz?G!gb(GpNMW~f<4AYy`=8mUr1fwm$8YJuTG#f#{xD+$$bGqIZu z4MiqFq5)(RBawt2i5P9Q8f8+ufkXQ%$kL=JlFshUYa7?L20uUe;NW)bi)#n;&g_v~}w9^AFBHYX7_LpLAzC-d=Y5#{??dlT+jkD1%^g0Q z_n!Nw_j2CptaF-nzz%D48qbaCv;>v=+yE+&EX76ufPpe4KT5zhF@b7+-J@e}b3m|Jq8!V^{<#LOmVC3?*T3iowm-$ioR_ zCJzJd90Oh`>yyk`z5~OL7jxL=a6Xpi&<2_{hgA#l)?9NHn$4nFx6*0{$eHgGk`zKh z6?b`tslNvd0+o=+B45K%p0A3-c4ZK^leAx%WThCgH^LChI3^t-@rC`)ki*hilC2%8Qdc1y9nn7=VEhXE(GmsuUhC4ZQ1N-peGAyMe}Rla zWT0(xXk#cdaBpHSpXPz?ccn!aVOt7w0@@0*;}sv#@qS*GhcP?%e;B- zqaVrJ9e+KS>wN95005dfbnkGX`{hs1Kedsz-rx9Nf<=CXc^Ayqw(s8YUv&2t0x#W% zvghd=r1h=hTg2yQ7nz|_u-$M*B1vT#Z@()NSx&{kIzinNiQHIK5~Ut*B(e~vnx2R! z)np2~9g#>Z6^%sbn|O}Utt68vT|uW<11j1uDqxol)Ppu-(~C`E9y}r1hV35A`Y}6z zSr9VA0}c>)iQw`rrDCRx+9T){0WqTJplS1~x{51EI0o^^6eq#}#SB;0eg@f}lK&9; zAG=%r_*|iqNrUNgcb%EQMsr>mEDR2(r|+K0$Qu{)!f>IjBYkE)nio2X0&gG6OgF) z)^@SAYlH4x>^46QKWJ^~YIyGWa5y#+96ml39%r@S@w)oh@SHNXc4F-P*TxQiP^pfO zeQ+>nGLO8FBmBYf!8Ja7&~RIRDay?U>({v0=&CTJ|8Ka^i1Zd-#h}eRF}&z?sf>nX z4lu<`Gi6k_DClomh-S^IuvzEIo?jy@t&B=?p^6V+j;tUkH-k!8m*lb)uvTWasS#({ z8kF}2FZL=lZajZ^lr{e)c&{gI;7vAKqm1(ZuQke}YSs|;Sfgw;lJ-3~9M(KZaSrb+ zOCdgF3ke~6NDM(#;jjo#sHU|?E{30yQhI2YRHtMS;JJN*E^1RQgxxBwnvyP;-tea7 zkP`9hs?>jId}93d{?SrpY#v^HBclq9xu)rB@HJ0R3z;!`g1Y)8!6$=fca@#7X-iO|7mloC-SVIo!r9 zW({5dY*h^b0FMun>CZQV9n{oF={V_S=wF6^?O!2Fli#)xPbavlk6U|pS_g8i1Nqih z(&wLv#M!mu*q?Lk&wRAw7|A(CfahyZ_l~D8=jq!T$$Q4rr+(%3F{<~jebc?+&YZZ{ zlJ~rvK2;bP{`|;;BilDVKlb3*pPj7q{=4VpM>l?let*Yi_eOW-y?gu9=L+rnHs?0x zw*2?5rl-GlcNJO!rN2OLq4Sl3zvpSQ!y^`n!!9xitU(`?7DJ!kgUWp}SH_xJ48Spf zMcfSX)+GyzSP0;6T_=~+=yg;c#nRA{@=8<%7e`Z*dR0K66dn4V6&w7ZC@xT^xmLiT z5@vWl^b{PGPAdtBM6svct+=MTP6v4=Z)QEAj_PBGOdn#^?+VkpqKO4Bj%cqymL|{I zNK1F-NWOU}eY)Ui{>~kER21BP^DUNl_hyA&cHRbm^AP+`)cE)1b4PXC? zOo8_qO+j3+7LV$rFm{N6#v$pbbaP@4 zlL7{fTntwzz8o3WK%@u{Ehx}GK+^k=8BM0lB5=~>>O|eU-DkO?rEk#aRT%ms_}7j= z1_JFO{{5Nxyss~P_A6g+srK06%Q?C-XLCJc;A%oE@8~Ib{e@D+S8#hwF*~h!Yc3dq zAPRf;pCP+XUL{~s2|S;U!ULmPz-6-urp$(_auI?JA=`^CoMqD3vjU$wTq76Y%EV-O zZi!qxMhHGV>diWgMPRH;G7kZ7n=BmWafEEgkC9n$8ZNtI?Z++|g1b>2;(V%nH)v4BnhO?X2ZWIlYWuua*x-Dpu zdK;E-P1lG8?G+p_*+u6Ty)K!y31xXcrW`X4K(Git zJz{=#Dats0G?9t|#vFr+$@gzCi{^wZO}1`q`+ul2U6~E?hV8ogq2Zt^ez$5aiIx=9 z8nv4t4!p-!R@7t+LZT_ELr(;H46k{_mch~t?k2qg9d+v7)@XzDIxzkNGAAK}2Ur_< zYbt%|*Ujzg+*hFdH}d|zbrDbu@N4V9PX9!%e`2S9GT%R0K-<=z^Ym|>-a7K7=hdeU z($eu;7x8q1iGzocy=_P6$_ZWHyZUH0i?ykOdtc^I&fS|)a&9RrNY5Le^4-`E@U;gB zg3*bB&kP|%yhU}XVtPP-g}P2?wt!fnr|j%jI7&SznYz?MUbi!s6+!^@$+~e0KyHYa zF=*R>3ufTQz!_z;pnlb*x>?Y5ZNODE3lC~3to__IoubR&k*%q4qiU}U#s?HBsz7)> z4ho3B)GWh&lhL!%q44)1yQ6#@{Ej)r7909+aK8uJR_V z`+&QJhzVT*wlCpd`*X+`xYt|jeLG$$=aur_{;abfRM_9S>DlnC-^sj}x%q>SzxQz_ zxqV`L;fut>#P-!}^HFBycO2mMbrqZ~cTe11*y$L`bqr>=3_+HFQsyPt5F^At2_m`_7J{?sacCkD76bWArABgZoFc9Arfs z$8k@2ffE23IoB^q-!Dn`Q<3oQyMsBd^S}6u9Oo|*%;9CtcNI+nn4m!W=D|-77J1R` z*|j+XeA-*=AZ;C+Q=d*{)O=ttOMFFfkQ4Eh4#n2(N6opRW6!W&oaUOj{h8N`1d1() z5Thr6_(rp literal 0 HcmV?d00001 diff --git a/tools/slntools/lib/csproj_parser.py b/tools/slntools/lib/csproj_parser.py new file mode 100644 index 000000000..614693ec5 --- /dev/null +++ b/tools/slntools/lib/csproj_parser.py @@ -0,0 +1,274 @@ +""" +Csproj file parsing utilities. + +Provides functions to: +- Find all .csproj files in a directory tree +- Parse csproj files to extract project references and package references +- Generate deterministic GUIDs for projects +""" + +import hashlib +import logging +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Optional + +from .models import CsprojProject + +logger = logging.getLogger(__name__) + +# Default patterns to exclude when scanning for csproj files +DEFAULT_EXCLUDE_DIRS = { + "bin", + "obj", + "node_modules", + ".git", + ".vs", + ".idea", + "third_party", + "packages", + ".nuget", + ".cache", +} + +# Default file patterns to exclude (test fixtures, samples, etc.) +DEFAULT_EXCLUDE_PATTERNS = { + "*.Tests.Fixtures", + "*.Samples", +} + + +def get_deterministic_guid(path: Path, base_path: Optional[Path] = None) -> str: + """ + Generate a deterministic GUID from a path. + + Uses SHA256 hash of the relative path to ensure consistency across runs. + + Args: + path: Path to generate GUID for + base_path: Base path to calculate relative path from (optional) + + Returns: + GUID string in uppercase format (e.g., "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX") + """ + if base_path: + try: + rel_path = path.relative_to(base_path) + except ValueError: + rel_path = path + else: + rel_path = path + + # Normalize path separators and convert to lowercase for consistency + normalized = str(rel_path).replace("\\", "/").lower() + + # Generate SHA256 hash + hash_bytes = hashlib.sha256(normalized.encode("utf-8")).digest() + + # Format as GUID (use first 16 bytes) + guid_hex = hash_bytes[:16].hex().upper() + guid = f"{guid_hex[:8]}-{guid_hex[8:12]}-{guid_hex[12:16]}-{guid_hex[16:20]}-{guid_hex[20:32]}" + + return guid + + +def find_all_csproj( + root_dir: Path, + exclude_dirs: Optional[set[str]] = None, + exclude_patterns: Optional[set[str]] = None, +) -> list[Path]: + """ + Find all .csproj files under a directory. + + Args: + root_dir: Root directory to search + exclude_dirs: Directory names to exclude (defaults to bin, obj, etc.) + exclude_patterns: File name patterns to exclude + + Returns: + List of absolute paths to .csproj files, sorted by path + """ + if exclude_dirs is None: + exclude_dirs = DEFAULT_EXCLUDE_DIRS + if exclude_patterns is None: + exclude_patterns = DEFAULT_EXCLUDE_PATTERNS + + csproj_files: list[Path] = [] + + if not root_dir.exists(): + logger.warning(f"Directory does not exist: {root_dir}") + return csproj_files + + for item in root_dir.rglob("*.csproj"): + # Check if any parent directory should be excluded + skip = False + for parent in item.parents: + if parent.name in exclude_dirs: + skip = True + break + + if skip: + continue + + # Check file name patterns + for pattern in exclude_patterns: + if item.match(pattern): + skip = True + break + + if skip: + continue + + csproj_files.append(item.resolve()) + + return sorted(csproj_files) + + +def parse_csproj( + csproj_path: Path, + base_path: Optional[Path] = None, +) -> Optional[CsprojProject]: + """ + Parse a .csproj file and extract project information. + + Args: + csproj_path: Path to the .csproj file + base_path: Base path for generating deterministic GUID + + Returns: + CsprojProject with parsed information, or None if parsing fails + """ + if not csproj_path.exists(): + logger.error(f"Csproj file does not exist: {csproj_path}") + return None + + try: + tree = ET.parse(csproj_path) + root = tree.getroot() + except ET.ParseError as e: + logger.error(f"Failed to parse XML in {csproj_path}: {e}") + return None + + # Extract project name from file name + name = csproj_path.stem + + # Generate deterministic GUID + guid = get_deterministic_guid(csproj_path, base_path) + + # Parse project references + project_references = _parse_project_references(root, csproj_path) + + # Parse package references + package_references = _parse_package_references(root) + + return CsprojProject( + path=csproj_path.resolve(), + name=name, + guid=guid, + project_references=project_references, + package_references=package_references, + ) + + +def _parse_project_references(root: ET.Element, csproj_path: Path) -> list[Path]: + """ + Parse ProjectReference elements from csproj XML. + + Args: + root: XML root element + csproj_path: Path to the csproj file (for resolving relative paths) + + Returns: + List of resolved absolute paths to referenced projects + """ + references: list[Path] = [] + csproj_dir = csproj_path.parent + + # Handle both with and without namespace + for ref in root.iter(): + if ref.tag.endswith("ProjectReference") or ref.tag == "ProjectReference": + include = ref.get("Include") + if include: + # Normalize path separators + include = include.replace("\\", "/") + + # Resolve relative path + try: + ref_path = (csproj_dir / include).resolve() + if ref_path.exists(): + references.append(ref_path) + else: + logger.warning( + f"Referenced project does not exist: {include} (from {csproj_path})" + ) + except Exception as e: + logger.warning(f"Failed to resolve path {include}: {e}") + + return references + + +def _parse_package_references(root: ET.Element) -> dict[str, str]: + """ + Parse PackageReference elements from csproj XML. + + Args: + root: XML root element + + Returns: + Dictionary mapping package name to version string + """ + packages: dict[str, str] = {} + + for ref in root.iter(): + if ref.tag.endswith("PackageReference") or ref.tag == "PackageReference": + include = ref.get("Include") + version = ref.get("Version") + + if include and version: + packages[include] = version + elif include: + # Version might be in a child element + for child in ref: + if child.tag.endswith("Version") or child.tag == "Version": + if child.text: + packages[include] = child.text.strip() + break + + return packages + + +def get_project_name_from_path(csproj_path: Path) -> str: + """ + Extract project name from csproj file path. + + Args: + csproj_path: Path to csproj file + + Returns: + Project name (file name without extension) + """ + return csproj_path.stem + + +def resolve_project_path( + include_path: str, + from_csproj: Path, +) -> Optional[Path]: + """ + Resolve a ProjectReference Include path to an absolute path. + + Args: + include_path: The Include attribute value + from_csproj: The csproj file containing the reference + + Returns: + Resolved absolute path, or None if resolution fails + """ + # Normalize path separators + include_path = include_path.replace("\\", "/") + + try: + resolved = (from_csproj.parent / include_path).resolve() + return resolved if resolved.exists() else None + except Exception: + return None diff --git a/tools/slntools/lib/dependency_graph.py b/tools/slntools/lib/dependency_graph.py new file mode 100644 index 000000000..82877331b --- /dev/null +++ b/tools/slntools/lib/dependency_graph.py @@ -0,0 +1,282 @@ +""" +Project dependency graph utilities. + +Provides functions to: +- Build a dependency graph from parsed projects +- Get transitive dependencies +- Classify dependencies as internal or external to a module +""" + +import logging +from pathlib import Path +from typing import Optional + +from .models import CsprojProject + +logger = logging.getLogger(__name__) + + +def build_dependency_graph( + projects: list[CsprojProject], +) -> dict[Path, set[Path]]: + """ + Build a dependency graph from a list of projects. + + Args: + projects: List of parsed CsprojProject objects + + Returns: + Dictionary mapping project path to set of dependency paths + """ + graph: dict[Path, set[Path]] = {} + + for project in projects: + graph[project.path] = set(project.project_references) + + return graph + + +def get_transitive_dependencies( + project_path: Path, + graph: dict[Path, set[Path]], + visited: Optional[set[Path]] = None, +) -> set[Path]: + """ + Get all transitive dependencies for a project. + + Handles circular dependencies gracefully by tracking visited nodes. + + Args: + project_path: Path to the project + graph: Dependency graph from build_dependency_graph + visited: Set of already visited paths (for cycle detection) + + Returns: + Set of all transitive dependency paths + """ + if visited is None: + visited = set() + + if project_path in visited: + return set() # Cycle detected + + visited.add(project_path) + all_deps: set[Path] = set() + + direct_deps = graph.get(project_path, set()) + all_deps.update(direct_deps) + + for dep in direct_deps: + transitive = get_transitive_dependencies(dep, graph, visited.copy()) + all_deps.update(transitive) + + return all_deps + + +def classify_dependencies( + project: CsprojProject, + module_dir: Path, + src_root: Path, +) -> dict[str, list[Path]]: + """ + Classify project dependencies as internal or external. + + Args: + project: The project to analyze + module_dir: Root directory of the module + src_root: Root of the src/ directory + + Returns: + Dictionary with keys: + - 'internal': Dependencies within module_dir + - '__Libraries': Dependencies from src/__Libraries/ + - '': Dependencies from other modules + """ + result: dict[str, list[Path]] = {"internal": []} + + module_dir = module_dir.resolve() + src_root = src_root.resolve() + + for ref_path in project.project_references: + ref_path = ref_path.resolve() + + # Check if internal to module + try: + ref_path.relative_to(module_dir) + result["internal"].append(ref_path) + continue + except ValueError: + pass + + # External - classify by source module + category = _get_external_category(ref_path, src_root) + if category not in result: + result[category] = [] + result[category].append(ref_path) + + return result + + +def _get_external_category(ref_path: Path, src_root: Path) -> str: + """ + Determine the category for an external dependency. + + Args: + ref_path: Path to the referenced project + src_root: Root of the src/ directory + + Returns: + Category name (e.g., '__Libraries', 'Authority', 'Scanner') + """ + try: + rel_path = ref_path.relative_to(src_root) + except ValueError: + # Outside of src/ - use 'External' + return "External" + + parts = rel_path.parts + + if len(parts) == 0: + return "External" + + # First part is the module or __Libraries/__Tests etc. + first_part = parts[0] + + if first_part == "__Libraries": + return "__Libraries" + elif first_part == "__Tests": + return "__Tests" + elif first_part == "__Analyzers": + return "__Analyzers" + else: + # It's a module name + return first_part + + +def collect_all_external_dependencies( + projects: list[CsprojProject], + module_dir: Path, + src_root: Path, + project_map: dict[Path, CsprojProject], +) -> dict[str, list[CsprojProject]]: + """ + Collect all external dependencies for a module's projects. + + Includes transitive dependencies. + + Args: + projects: List of projects in the module + module_dir: Root directory of the module + src_root: Root of the src/ directory + project_map: Map from path to CsprojProject for all known projects + + Returns: + Dictionary mapping category to list of external CsprojProject objects + """ + # Build dependency graph for all known projects + all_projects = list(project_map.values()) + graph = build_dependency_graph(all_projects) + + module_dir = module_dir.resolve() + src_root = src_root.resolve() + + # Collect all external dependencies + external_deps: dict[str, set[Path]] = {} + + for project in projects: + # Get all transitive dependencies + all_deps = get_transitive_dependencies(project.path, graph) + + for dep_path in all_deps: + dep_path = dep_path.resolve() + + # Skip if internal to module + try: + dep_path.relative_to(module_dir) + continue + except ValueError: + pass + + # External - classify + category = _get_external_category(dep_path, src_root) + if category not in external_deps: + external_deps[category] = set() + external_deps[category].add(dep_path) + + # Convert paths to CsprojProject objects + result: dict[str, list[CsprojProject]] = {} + for category, paths in external_deps.items(): + result[category] = [] + for path in sorted(paths): + if path in project_map: + result[category].append(project_map[path]) + else: + logger.warning(f"External dependency not in project map: {path}") + + return result + + +def get_module_projects( + module_dir: Path, + all_projects: list[CsprojProject], +) -> list[CsprojProject]: + """ + Get all projects that belong to a module. + + Args: + module_dir: Root directory of the module + all_projects: List of all projects + + Returns: + List of projects within the module directory + """ + module_dir = module_dir.resolve() + result: list[CsprojProject] = [] + + for project in all_projects: + try: + project.path.relative_to(module_dir) + result.append(project) + except ValueError: + pass + + return result + + +def detect_circular_dependencies( + graph: dict[Path, set[Path]], +) -> list[list[Path]]: + """ + Detect circular dependencies in the project graph. + + Args: + graph: Dependency graph + + Returns: + List of cycles (each cycle is a list of paths) + """ + cycles: list[list[Path]] = [] + visited: set[Path] = set() + rec_stack: set[Path] = set() + + def dfs(node: Path, path: list[Path]) -> None: + visited.add(node) + rec_stack.add(node) + path.append(node) + + for neighbor in graph.get(node, set()): + if neighbor not in visited: + dfs(neighbor, path.copy()) + elif neighbor in rec_stack: + # Found a cycle + cycle_start = path.index(neighbor) + cycle = path[cycle_start:] + [neighbor] + cycles.append(cycle) + + rec_stack.remove(node) + + for node in graph: + if node not in visited: + dfs(node, []) + + return cycles diff --git a/tools/slntools/lib/models.py b/tools/slntools/lib/models.py new file mode 100644 index 000000000..f5ef1f78f --- /dev/null +++ b/tools/slntools/lib/models.py @@ -0,0 +1,87 @@ +""" +Data models for solution and project management. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class CsprojProject: + """Represents a .csproj project file.""" + + path: Path # Absolute path to .csproj file + name: str # Project name (without extension) + guid: str # Project GUID (generated deterministically from path) + project_references: list[Path] = field(default_factory=list) # Resolved absolute paths + package_references: dict[str, str] = field(default_factory=dict) # Package name -> version + + def __hash__(self) -> int: + return hash(self.path) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CsprojProject): + return False + return self.path == other.path + + +@dataclass +class SolutionFolder: + """Represents a solution folder in a .sln file.""" + + name: str # Folder display name + guid: str # Folder GUID + path: str # Full path within solution (e.g., "Module/__Libraries") + parent_guid: Optional[str] = None # Parent folder GUID (None for root folders) + children: list["SolutionFolder"] = field(default_factory=list) + projects: list[CsprojProject] = field(default_factory=list) + + def __hash__(self) -> int: + return hash(self.path) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SolutionFolder): + return False + return self.path == other.path + + +@dataclass +class PackageUsage: + """Tracks usage of a NuGet package across the codebase.""" + + package_name: str + usages: dict[Path, str] = field(default_factory=dict) # csproj path -> version string + + def get_all_versions(self) -> list[str]: + """Get list of unique versions used.""" + return list(set(self.usages.values())) + + def get_usage_count(self) -> int: + """Get number of projects using this package.""" + return len(self.usages) + + +@dataclass +class NormalizationChange: + """Represents a version change for a package in a project.""" + + csproj_path: Path + old_version: str + new_version: str + + +@dataclass +class NormalizationResult: + """Result of normalizing a package across the codebase.""" + + package_name: str + target_version: str + changes: list[NormalizationChange] = field(default_factory=list) + skipped_reason: Optional[str] = None + + +# Constants for solution file format +CSHARP_PROJECT_TYPE_GUID = "FAE04EC0-301F-11D3-BF4B-00C04F79EFBC" +SOLUTION_FOLDER_TYPE_GUID = "2150E333-8FDC-42A3-9474-1A3956D46DE8" +BYPASS_MARKER = "# STELLAOPS-MANUAL-SOLUTION" diff --git a/tools/slntools/lib/sln_writer.py b/tools/slntools/lib/sln_writer.py new file mode 100644 index 000000000..137a0d0d8 --- /dev/null +++ b/tools/slntools/lib/sln_writer.py @@ -0,0 +1,381 @@ +""" +Solution file (.sln) generation utilities. + +Provides functions to: +- Build solution folder hierarchy from projects +- Generate complete .sln file content +""" + +import logging +from pathlib import Path +from typing import Optional + +from .csproj_parser import get_deterministic_guid +from .models import ( + BYPASS_MARKER, + CSHARP_PROJECT_TYPE_GUID, + SOLUTION_FOLDER_TYPE_GUID, + CsprojProject, + SolutionFolder, +) + +logger = logging.getLogger(__name__) + +# Solution file header +SOLUTION_HEADER = """\ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +""" + + +def build_folder_hierarchy( + projects: list[CsprojProject], + base_dir: Path, + prefix: str = "", +) -> dict[str, SolutionFolder]: + """ + Build solution folder hierarchy from project paths. + + Creates nested folders matching the physical directory structure. + + Args: + projects: List of projects to organize + base_dir: Base directory for calculating relative paths + prefix: Optional prefix for folder paths (e.g., "__External") + + Returns: + Dictionary mapping folder path to SolutionFolder object + """ + folders: dict[str, SolutionFolder] = {} + base_dir = base_dir.resolve() + + for project in projects: + try: + rel_path = project.path.parent.relative_to(base_dir) + except ValueError: + # Project outside base_dir - skip folder creation + continue + + parts = list(rel_path.parts) + if not parts: + continue + + # Add prefix if specified + if prefix: + parts = [prefix] + list(parts) + + # Create folders for each level + current_path = "" + parent_guid: Optional[str] = None + + for part in parts: + if current_path: + current_path = f"{current_path}/{part}" + else: + current_path = part + + if current_path not in folders: + folder_guid = get_deterministic_guid( + Path(current_path), Path("") + ) + folders[current_path] = SolutionFolder( + name=part, + guid=folder_guid, + path=current_path, + parent_guid=parent_guid, + ) + + parent_guid = folders[current_path].guid + + # Assign project to its folder + if current_path in folders: + folders[current_path].projects.append(project) + + return folders + + +def build_external_folder_hierarchy( + external_groups: dict[str, list[CsprojProject]], + src_root: Path, +) -> dict[str, SolutionFolder]: + """ + Build folder hierarchy for external dependencies. + + Organizes external projects under __External//. + + Args: + external_groups: Dictionary mapping source category to projects + src_root: Root of the src/ directory + + Returns: + Dictionary mapping folder path to SolutionFolder object + """ + folders: dict[str, SolutionFolder] = {} + src_root = src_root.resolve() + + # Create __External root folder + external_root_path = "__External" + external_root_guid = get_deterministic_guid(Path(external_root_path), Path("")) + folders[external_root_path] = SolutionFolder( + name="__External", + guid=external_root_guid, + path=external_root_path, + parent_guid=None, + ) + + for category, projects in sorted(external_groups.items()): + if not projects: + continue + + # Create category folder (e.g., __External/__Libraries, __External/Authority) + category_path = f"{external_root_path}/{category}" + category_guid = get_deterministic_guid(Path(category_path), Path("")) + + if category_path not in folders: + folders[category_path] = SolutionFolder( + name=category, + guid=category_guid, + path=category_path, + parent_guid=external_root_guid, + ) + + # For each project, create intermediate folders based on path within source + for project in projects: + try: + if category == "__Libraries": + # Path relative to src/__Libraries/ + lib_root = src_root / "__Libraries" + rel_path = project.path.parent.relative_to(lib_root) + else: + # Path relative to src// + module_root = src_root / category + rel_path = project.path.parent.relative_to(module_root) + except ValueError: + # Just put directly in category folder + folders[category_path].projects.append(project) + continue + + parts = list(rel_path.parts) + if not parts: + folders[category_path].projects.append(project) + continue + + # Create intermediate folders + current_path = category_path + parent_guid = category_guid + + for part in parts: + current_path = f"{current_path}/{part}" + + if current_path not in folders: + folder_guid = get_deterministic_guid(Path(current_path), Path("")) + folders[current_path] = SolutionFolder( + name=part, + guid=folder_guid, + path=current_path, + parent_guid=parent_guid, + ) + + parent_guid = folders[current_path].guid + + # Assign project to deepest folder + folders[current_path].projects.append(project) + + return folders + + +def generate_solution_content( + sln_path: Path, + projects: list[CsprojProject], + folders: dict[str, SolutionFolder], + external_folders: Optional[dict[str, SolutionFolder]] = None, + add_bypass_marker: bool = False, +) -> str: + """ + Generate complete .sln file content. + + Args: + sln_path: Path where the solution will be written (for relative paths) + projects: List of internal projects + folders: Internal folder hierarchy + external_folders: External dependency folders (optional) + add_bypass_marker: Whether to add the bypass marker comment + + Returns: + Complete .sln file content as string + """ + lines: list[str] = [] + sln_dir = sln_path.parent.resolve() + + # Add header + if add_bypass_marker: + lines.append(BYPASS_MARKER) + lines.append("") + lines.append(SOLUTION_HEADER.rstrip()) + + # Merge folders + all_folders = dict(folders) + if external_folders: + all_folders.update(external_folders) + + # Collect all projects (internal + external from folders) + all_projects: list[CsprojProject] = list(projects) + project_to_folder: dict[Path, str] = {} + + for folder_path, folder in all_folders.items(): + for proj in folder.projects: + if proj not in all_projects: + all_projects.append(proj) + project_to_folder[proj.path] = folder_path + + # Write solution folder entries + for folder_path in sorted(all_folders.keys()): + folder = all_folders[folder_path] + lines.append( + f'Project("{{{SOLUTION_FOLDER_TYPE_GUID}}}") = "{folder.name}", "{folder.name}", "{{{folder.guid}}}"' + ) + lines.append("EndProject") + + # Write project entries + for project in sorted(all_projects, key=lambda p: p.name): + rel_path = _get_relative_path(sln_dir, project.path) + lines.append( + f'Project("{{{CSHARP_PROJECT_TYPE_GUID}}}") = "{project.name}", "{rel_path}", "{{{project.guid}}}"' + ) + lines.append("EndProject") + + # Write Global section + lines.append("Global") + + # SolutionConfigurationPlatforms + lines.append("\tGlobalSection(SolutionConfigurationPlatforms) = preSolution") + lines.append("\t\tDebug|Any CPU = Debug|Any CPU") + lines.append("\t\tRelease|Any CPU = Release|Any CPU") + lines.append("\tEndGlobalSection") + + # ProjectConfigurationPlatforms + lines.append("\tGlobalSection(ProjectConfigurationPlatforms) = postSolution") + for project in sorted(all_projects, key=lambda p: p.name): + guid = project.guid + lines.append(f"\t\t{{{guid}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU") + lines.append(f"\t\t{{{guid}}}.Debug|Any CPU.Build.0 = Debug|Any CPU") + lines.append(f"\t\t{{{guid}}}.Release|Any CPU.ActiveCfg = Release|Any CPU") + lines.append(f"\t\t{{{guid}}}.Release|Any CPU.Build.0 = Release|Any CPU") + lines.append("\tEndGlobalSection") + + # SolutionProperties + lines.append("\tGlobalSection(SolutionProperties) = preSolution") + lines.append("\t\tHideSolutionNode = FALSE") + lines.append("\tEndGlobalSection") + + # NestedProjects - assign folders and projects to parent folders + lines.append("\tGlobalSection(NestedProjects) = preSolution") + + # Nest folders under their parents + for folder_path in sorted(all_folders.keys()): + folder = all_folders[folder_path] + if folder.parent_guid: + lines.append(f"\t\t{{{folder.guid}}} = {{{folder.parent_guid}}}") + + # Nest projects under their folders + for project in sorted(all_projects, key=lambda p: p.name): + if project.path in project_to_folder: + folder_path = project_to_folder[project.path] + folder = all_folders[folder_path] + lines.append(f"\t\t{{{project.guid}}} = {{{folder.guid}}}") + + lines.append("\tEndGlobalSection") + + # ExtensibilityGlobals (required by VS) + lines.append("\tGlobalSection(ExtensibilityGlobals) = postSolution") + # Generate a solution GUID + sln_guid = get_deterministic_guid(sln_path, sln_path.parent.parent) + lines.append(f"\t\tSolutionGuid = {{{sln_guid}}}") + lines.append("\tEndGlobalSection") + + lines.append("EndGlobal") + lines.append("") # Trailing newline + + return "\r\n".join(lines) + + +def _get_relative_path(from_dir: Path, to_path: Path) -> str: + """ + Get relative path from directory to file, using backslashes. + + Args: + from_dir: Directory to calculate from + to_path: Target path + + Returns: + Relative path with backslashes (Windows format for .sln) + """ + try: + rel = to_path.relative_to(from_dir) + return str(rel).replace("/", "\\") + except ValueError: + # Different drive or not relative - use absolute with backslashes + return str(to_path).replace("/", "\\") + + +def has_bypass_marker(sln_path: Path) -> bool: + """ + Check if a solution file has the bypass marker. + + Args: + sln_path: Path to the solution file + + Returns: + True if the bypass marker is found in the first 10 lines + """ + if not sln_path.exists(): + return False + + try: + with open(sln_path, "r", encoding="utf-8-sig") as f: + for i, line in enumerate(f): + if i >= 10: + break + if BYPASS_MARKER in line: + return True + except Exception as e: + logger.warning(f"Failed to read solution file {sln_path}: {e}") + + return False + + +def write_solution_file( + sln_path: Path, + content: str, + dry_run: bool = False, +) -> bool: + """ + Write solution content to file. + + Args: + sln_path: Path to write to + content: Solution file content + dry_run: If True, don't actually write + + Returns: + True if successful (or would be successful in dry run) + """ + if dry_run: + logger.info(f"Would write solution to: {sln_path}") + return True + + try: + # Ensure parent directory exists + sln_path.parent.mkdir(parents=True, exist_ok=True) + + # Write with UTF-8 BOM and CRLF line endings + with open(sln_path, "w", encoding="utf-8-sig", newline="\r\n") as f: + f.write(content) + + logger.info(f"Wrote solution to: {sln_path}") + return True + except Exception as e: + logger.error(f"Failed to write solution {sln_path}: {e}") + return False diff --git a/tools/slntools/lib/version_utils.py b/tools/slntools/lib/version_utils.py new file mode 100644 index 000000000..f918d9085 --- /dev/null +++ b/tools/slntools/lib/version_utils.py @@ -0,0 +1,237 @@ +""" +Version parsing and comparison utilities for NuGet packages. + +Handles SemVer versions with prerelease suffixes. +""" + +import re +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class ParsedVersion: + """Parsed semantic version.""" + + major: int + minor: int + patch: int + prerelease: Optional[str] = None + build_metadata: Optional[str] = None + original: str = "" + + def is_stable(self) -> bool: + """Returns True if this is a stable (non-prerelease) version.""" + return self.prerelease is None + + def __lt__(self, other: "ParsedVersion") -> bool: + """Compare versions following SemVer rules.""" + # Compare major.minor.patch first + self_tuple = (self.major, self.minor, self.patch) + other_tuple = (other.major, other.minor, other.patch) + + if self_tuple != other_tuple: + return self_tuple < other_tuple + + # If equal, prerelease versions are less than stable + if self.prerelease is None and other.prerelease is None: + return False + if self.prerelease is None: + return False # stable > prerelease + if other.prerelease is None: + return True # prerelease < stable + + # Both have prerelease - compare alphanumerically + return self._compare_prerelease(self.prerelease, other.prerelease) < 0 + + def __le__(self, other: "ParsedVersion") -> bool: + return self == other or self < other + + def __gt__(self, other: "ParsedVersion") -> bool: + return other < self + + def __ge__(self, other: "ParsedVersion") -> bool: + return self == other or self > other + + @staticmethod + def _compare_prerelease(a: str, b: str) -> int: + """Compare prerelease strings according to SemVer.""" + a_parts = a.split(".") + b_parts = b.split(".") + + for i in range(max(len(a_parts), len(b_parts))): + if i >= len(a_parts): + return -1 + if i >= len(b_parts): + return 1 + + a_part = a_parts[i] + b_part = b_parts[i] + + # Try numeric comparison first + a_is_num = a_part.isdigit() + b_is_num = b_part.isdigit() + + if a_is_num and b_is_num: + diff = int(a_part) - int(b_part) + if diff != 0: + return diff + elif a_is_num: + return -1 # Numeric < string + elif b_is_num: + return 1 # String > numeric + else: + # Both strings - compare lexically + if a_part < b_part: + return -1 + if a_part > b_part: + return 1 + + return 0 + + +# Regex for parsing NuGet versions +# Matches: 1.2.3, 1.2.3-beta, 1.2.3-beta.1, 1.2.3-rc.1+build, [1.2.3] +VERSION_PATTERN = re.compile( + r"^\[?" # Optional opening bracket + r"(\d+)" # Major (required) + r"(?:\.(\d+))?" # Minor (optional) + r"(?:\.(\d+))?" # Patch (optional) + r"(?:-([a-zA-Z0-9][a-zA-Z0-9.-]*))?" # Prerelease (optional) + r"(?:\+([a-zA-Z0-9][a-zA-Z0-9.-]*))?" # Build metadata (optional) + r"\]?$" # Optional closing bracket +) + +# Pattern for wildcard versions (e.g., 1.0.*) +WILDCARD_PATTERN = re.compile(r"\*") + + +def parse_version(version_str: str) -> Optional[ParsedVersion]: + """ + Parse a NuGet version string. + + Args: + version_str: Version string like "1.2.3", "1.2.3-beta.1", "[1.2.3]" + + Returns: + ParsedVersion if valid, None if invalid or wildcard + """ + if not version_str: + return None + + version_str = version_str.strip() + + # Skip wildcard versions + if WILDCARD_PATTERN.search(version_str): + return None + + match = VERSION_PATTERN.match(version_str) + if not match: + return None + + major = int(match.group(1)) + minor = int(match.group(2)) if match.group(2) else 0 + patch = int(match.group(3)) if match.group(3) else 0 + prerelease = match.group(4) + build_metadata = match.group(5) + + return ParsedVersion( + major=major, + minor=minor, + patch=patch, + prerelease=prerelease, + build_metadata=build_metadata, + original=version_str, + ) + + +def is_stable(version_str: str) -> bool: + """ + Check if a version string represents a stable release. + + Args: + version_str: Version string to check + + Returns: + True if stable (no prerelease suffix), False otherwise + """ + parsed = parse_version(version_str) + if parsed is None: + return False + return parsed.is_stable() + + +def compare_versions(v1: str, v2: str) -> int: + """ + Compare two version strings. + + Args: + v1: First version string + v2: Second version string + + Returns: + -1 if v1 < v2, 0 if equal, 1 if v1 > v2 + Returns 0 if either version is unparseable + """ + parsed_v1 = parse_version(v1) + parsed_v2 = parse_version(v2) + + if parsed_v1 is None or parsed_v2 is None: + return 0 + + if parsed_v1 < parsed_v2: + return -1 + if parsed_v1 > parsed_v2: + return 1 + return 0 + + +def select_latest_stable(versions: list[str]) -> Optional[str]: + """ + Select the latest stable version from a list. + + Args: + versions: List of version strings + + Returns: + Latest stable version string, or None if no stable versions exist + """ + stable_versions: list[tuple[ParsedVersion, str]] = [] + + for v in versions: + parsed = parse_version(v) + if parsed is not None and parsed.is_stable(): + stable_versions.append((parsed, v)) + + if not stable_versions: + return None + + # Sort by parsed version and return the original string of the max + stable_versions.sort(key=lambda x: x[0], reverse=True) + return stable_versions[0][1] + + +def normalize_version_string(version_str: str) -> str: + """ + Normalize a version string to a canonical form. + + Strips brackets, whitespace, and normalizes format. + + Args: + version_str: Version string to normalize + + Returns: + Normalized version string + """ + parsed = parse_version(version_str) + if parsed is None: + return version_str.strip() + + # Rebuild canonical form + result = f"{parsed.major}.{parsed.minor}.{parsed.patch}" + if parsed.prerelease: + result += f"-{parsed.prerelease}" + if parsed.build_metadata: + result += f"+{parsed.build_metadata}" + + return result diff --git a/tools/slntools/nuget_normalizer.py b/tools/slntools/nuget_normalizer.py new file mode 100644 index 000000000..f4bcca454 --- /dev/null +++ b/tools/slntools/nuget_normalizer.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 +""" +StellaOps NuGet Version Normalizer. + +Scans all .csproj files and normalizes NuGet package versions to the latest stable. + +IMPORTANT: Packages centrally managed in Directory.Build.props (via PackageReference Update) +are automatically excluded from normalization. These packages are reported separately. + +Usage: + python nuget_normalizer.py [OPTIONS] + +Options: + --src-root PATH Root of src/ directory (default: ./src) + --repo-root PATH Root of repository (default: parent of src-root) + --dry-run Report without making changes + --report PATH Write JSON report to file + --exclude PACKAGE Exclude package from normalization (repeatable) + --check CI mode: exit 1 if normalization needed + -v, --verbose Verbose output +""" + +import argparse +import json +import logging +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +from lib.csproj_parser import find_all_csproj +from lib.models import NormalizationChange, NormalizationResult, PackageUsage +from lib.version_utils import is_stable, parse_version, select_latest_stable + +logger = logging.getLogger(__name__) + + +def find_directory_build_props(repo_root: Path) -> list[Path]: + """ + Find all Directory.Build.props files in the repository. + + Args: + repo_root: Root of the repository + + Returns: + List of paths to Directory.Build.props files + """ + props_files = [] + for props_file in repo_root.rglob("Directory.Build.props"): + # Skip common exclusion directories + parts = props_file.parts + if any(p in ("bin", "obj", "node_modules", ".git") for p in parts): + continue + props_files.append(props_file) + return props_files + + +def scan_centrally_managed_packages(repo_root: Path) -> dict[str, tuple[str, Path]]: + """ + Scan Directory.Build.props files for centrally managed package versions. + + These are packages defined with + which override versions in individual csproj files. + + Args: + repo_root: Root of the repository + + Returns: + Dictionary mapping package name to (version, props_file_path) + """ + centrally_managed: dict[str, tuple[str, Path]] = {} + + props_files = find_directory_build_props(repo_root) + logger.info(f"Scanning {len(props_files)} Directory.Build.props files for centrally managed packages") + + # Pattern for PackageReference Update (central version management) + # + update_pattern = re.compile( + r']*Version\s*=\s*"([^"]+)"', + re.IGNORECASE, + ) + + # Alternative pattern when Version comes first + update_pattern_alt = re.compile( + r']*Version\s*=\s*"([^"]+)"[^>]*Update\s*=\s*"([^"]+)"', + re.IGNORECASE, + ) + + for props_file in props_files: + try: + content = props_file.read_text(encoding="utf-8") + except Exception as e: + logger.warning(f"Failed to read {props_file}: {e}") + continue + + # Find PackageReference Update elements + for match in update_pattern.finditer(content): + package_name = match.group(1) + version = match.group(2) + # Store with the props file path for reporting + if package_name not in centrally_managed: + centrally_managed[package_name] = (version, props_file) + logger.debug(f"Found centrally managed: {package_name} v{version} in {props_file}") + + for match in update_pattern_alt.finditer(content): + version = match.group(1) + package_name = match.group(2) + if package_name not in centrally_managed: + centrally_managed[package_name] = (version, props_file) + logger.debug(f"Found centrally managed: {package_name} v{version} in {props_file}") + + logger.info(f"Found {len(centrally_managed)} centrally managed packages") + return centrally_managed + + +def setup_logging(verbose: bool) -> None: + """Configure logging based on verbosity.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(levelname)s: %(message)s", + ) + + +def scan_all_packages(src_root: Path) -> dict[str, PackageUsage]: + """ + Scan all .csproj files and collect package references. + + Args: + src_root: Root of src/ directory + + Returns: + Dictionary mapping package name to PackageUsage + """ + packages: dict[str, PackageUsage] = {} + csproj_files = find_all_csproj(src_root) + + logger.info(f"Scanning {len(csproj_files)} .csproj files for package references") + + # Regex for PackageReference + # Matches: + # Also handles multi-line and various attribute orderings + package_ref_pattern = re.compile( + r']*Include\s*=\s*"([^"]+)"[^>]*Version\s*=\s*"([^"]+)"', + re.IGNORECASE, + ) + + # Alternative pattern for when Version comes first + package_ref_pattern_alt = re.compile( + r']*Version\s*=\s*"([^"]+)"[^>]*Include\s*=\s*"([^"]+)"', + re.IGNORECASE, + ) + + for csproj_path in csproj_files: + try: + content = csproj_path.read_text(encoding="utf-8") + except Exception as e: + logger.warning(f"Failed to read {csproj_path}: {e}") + continue + + # Find all PackageReference elements + for match in package_ref_pattern.finditer(content): + package_name = match.group(1) + version = match.group(2) + + if package_name not in packages: + packages[package_name] = PackageUsage(package_name=package_name) + + packages[package_name].usages[csproj_path] = version + + # Also try alternative pattern + for match in package_ref_pattern_alt.finditer(content): + version = match.group(1) + package_name = match.group(2) + + if package_name not in packages: + packages[package_name] = PackageUsage(package_name=package_name) + + packages[package_name].usages[csproj_path] = version + + logger.info(f"Found {len(packages)} unique packages") + return packages + + +def calculate_normalizations( + packages: dict[str, PackageUsage], + exclude_packages: set[str], + centrally_managed: dict[str, tuple[str, Path]] | None = None, +) -> tuple[list[NormalizationResult], list[tuple[str, str, Path]]]: + """ + Calculate which packages need version normalization. + + Args: + packages: Package usage data + exclude_packages: Package names to exclude + centrally_managed: Packages managed in Directory.Build.props (auto-excluded) + + Returns: + Tuple of (normalization results, list of centrally managed packages that were skipped) + """ + results: list[NormalizationResult] = [] + centrally_skipped: list[tuple[str, str, Path]] = [] + + if centrally_managed is None: + centrally_managed = {} + + for package_name, usage in sorted(packages.items()): + # Skip centrally managed packages + if package_name in centrally_managed: + version, props_file = centrally_managed[package_name] + centrally_skipped.append((package_name, version, props_file)) + logger.debug(f"Skipping centrally managed package: {package_name} (v{version} in {props_file})") + continue + + if package_name in exclude_packages: + logger.debug(f"Excluding package: {package_name}") + continue + + versions = usage.get_all_versions() + + # Skip if only one version + if len(versions) <= 1: + continue + + # Check if any versions are wildcards or unparseable + parseable_versions = [v for v in versions if parse_version(v) is not None] + + if not parseable_versions: + results.append( + NormalizationResult( + package_name=package_name, + target_version="", + skipped_reason="No parseable versions found", + ) + ) + continue + + # Select latest stable version + target_version = select_latest_stable(parseable_versions) + + if target_version is None: + # Try to find any version (including prereleases) + parsed = [ + (parse_version(v), v) + for v in parseable_versions + if parse_version(v) is not None + ] + if parsed: + parsed.sort(key=lambda x: x[0], reverse=True) + target_version = parsed[0][1] + results.append( + NormalizationResult( + package_name=package_name, + target_version=target_version, + skipped_reason="Only prerelease versions available", + ) + ) + continue + else: + results.append( + NormalizationResult( + package_name=package_name, + target_version="", + skipped_reason="No stable versions found", + ) + ) + continue + + # Create normalization result with changes + result = NormalizationResult( + package_name=package_name, + target_version=target_version, + ) + + for csproj_path, current_version in usage.usages.items(): + if current_version != target_version: + result.changes.append( + NormalizationChange( + csproj_path=csproj_path, + old_version=current_version, + new_version=target_version, + ) + ) + + if result.changes: + results.append(result) + + return results, centrally_skipped + + +def apply_normalizations( + normalizations: list[NormalizationResult], + dry_run: bool = False, +) -> int: + """ + Apply version normalizations to csproj files. + + Args: + normalizations: List of normalization results + dry_run: If True, don't actually modify files + + Returns: + Number of files modified + """ + files_modified: set[Path] = set() + + for result in normalizations: + if result.skipped_reason: + continue + + for change in result.changes: + csproj_path = change.csproj_path + + if dry_run: + logger.info( + f"Would update {result.package_name} in {csproj_path.name}: " + f"{change.old_version} -> {change.new_version}" + ) + files_modified.add(csproj_path) + continue + + try: + content = csproj_path.read_text(encoding="utf-8") + + # Replace the specific package version + # Pattern matches the PackageReference for this specific package + pattern = re.compile( + rf'(]*Include\s*=\s*"{re.escape(result.package_name)}"' + rf'[^>]*Version\s*=\s*"){re.escape(change.old_version)}(")', + re.IGNORECASE, + ) + + new_content, count = pattern.subn( + rf"\g<1>{change.new_version}\g<2>", + content, + ) + + if count > 0: + csproj_path.write_text(new_content, encoding="utf-8") + files_modified.add(csproj_path) + logger.info( + f"Updated {result.package_name} in {csproj_path.name}: " + f"{change.old_version} -> {change.new_version}" + ) + else: + # Try alternative pattern + pattern_alt = re.compile( + rf'(]*Version\s*=\s*"){re.escape(change.old_version)}"' + rf'([^>]*Include\s*=\s*"{re.escape(result.package_name)}")', + re.IGNORECASE, + ) + + new_content, count = pattern_alt.subn( + rf'\g<1>{change.new_version}"\g<2>', + content, + ) + + if count > 0: + csproj_path.write_text(new_content, encoding="utf-8") + files_modified.add(csproj_path) + logger.info( + f"Updated {result.package_name} in {csproj_path.name}: " + f"{change.old_version} -> {change.new_version}" + ) + else: + logger.warning( + f"Could not find pattern to update {result.package_name} " + f"in {csproj_path}" + ) + + except Exception as e: + logger.error(f"Failed to update {csproj_path}: {e}") + + return len(files_modified) + + +def generate_report( + packages: dict[str, PackageUsage], + normalizations: list[NormalizationResult], + centrally_skipped: list[tuple[str, str, Path]] | None = None, +) -> dict: + """ + Generate a JSON report of the normalization. + + Args: + packages: Package usage data + normalizations: Normalization results + centrally_skipped: Packages skipped due to central management + + Returns: + Report dictionary + """ + if centrally_skipped is None: + centrally_skipped = [] + + # Count changes + packages_normalized = sum( + 1 for n in normalizations if n.changes and not n.skipped_reason + ) + files_modified = len( + set( + change.csproj_path + for n in normalizations + for change in n.changes + if not n.skipped_reason + ) + ) + + report = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "summary": { + "packages_scanned": len(packages), + "packages_with_inconsistencies": len(normalizations), + "packages_normalized": packages_normalized, + "files_modified": files_modified, + "packages_centrally_managed": len(centrally_skipped), + }, + "normalizations": [], + "skipped": [], + "centrally_managed": [], + } + + for result in normalizations: + if result.skipped_reason: + report["skipped"].append( + { + "package": result.package_name, + "reason": result.skipped_reason, + "versions": packages[result.package_name].get_all_versions() + if result.package_name in packages + else [], + } + ) + elif result.changes: + report["normalizations"].append( + { + "package": result.package_name, + "target_version": result.target_version, + "changes": [ + { + "file": str(change.csproj_path), + "old": change.old_version, + "new": change.new_version, + } + for change in result.changes + ], + } + ) + + # Add centrally managed packages + for package_name, version, props_file in centrally_skipped: + report["centrally_managed"].append( + { + "package": package_name, + "version": version, + "managed_in": str(props_file), + } + ) + + return report + + +def print_summary( + packages: dict[str, PackageUsage], + normalizations: list[NormalizationResult], + centrally_skipped: list[tuple[str, str, Path]], + dry_run: bool, +) -> None: + """Print a summary of the normalization.""" + print("\n" + "=" * 60) + print("NuGet Version Normalization Summary") + print("=" * 60) + + changes_needed = [n for n in normalizations if n.changes and not n.skipped_reason] + skipped = [n for n in normalizations if n.skipped_reason] + + print(f"\nPackages scanned: {len(packages)}") + print(f"Packages with version inconsistencies: {len(normalizations)}") + print(f"Packages to normalize: {len(changes_needed)}") + print(f"Packages skipped (other reasons): {len(skipped)}") + print(f"Packages centrally managed (auto-skipped): {len(centrally_skipped)}") + + if centrally_skipped: + print("\nCentrally managed packages (in Directory.Build.props):") + for package_name, version, props_file in sorted(centrally_skipped, key=lambda x: x[0]): + rel_path = props_file.name if len(str(props_file)) > 50 else props_file + print(f" {package_name}: v{version} ({rel_path})") + + if changes_needed: + print("\nPackages to normalize:") + for result in sorted(changes_needed, key=lambda x: x.package_name): + old_versions = set(c.old_version for c in result.changes) + print( + f" {result.package_name}: {', '.join(sorted(old_versions))} -> {result.target_version}" + ) + + if skipped and logger.isEnabledFor(logging.DEBUG): + print("\nSkipped packages:") + for result in sorted(skipped, key=lambda x: x.package_name): + print(f" {result.package_name}: {result.skipped_reason}") + + if dry_run: + print("\n[DRY RUN - No files were modified]") + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Normalize NuGet package versions across all csproj files", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument( + "--src-root", + type=Path, + default=Path("src"), + help="Root of src/ directory (default: ./src)", + ) + parser.add_argument( + "--repo-root", + type=Path, + default=None, + help="Root of repository for Directory.Build.props scanning (default: parent of src-root)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report without making changes", + ) + parser.add_argument( + "--report", + type=Path, + help="Write JSON report to file", + ) + parser.add_argument( + "--exclude", + action="append", + dest="exclude_packages", + default=[], + help="Exclude package from normalization (repeatable)", + ) + parser.add_argument( + "--check", + action="store_true", + help="CI mode: exit 1 if normalization needed", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Verbose output", + ) + + args = parser.parse_args() + setup_logging(args.verbose) + + # Resolve src root + src_root = args.src_root.resolve() + if not src_root.exists(): + logger.error(f"Source root does not exist: {src_root}") + return 1 + + # Resolve repo root (for Directory.Build.props scanning) + repo_root = args.repo_root.resolve() if args.repo_root else src_root.parent + if not repo_root.exists(): + logger.error(f"Repository root does not exist: {repo_root}") + return 1 + + logger.info(f"Source root: {src_root}") + logger.info(f"Repository root: {repo_root}") + + # Scan for centrally managed packages in Directory.Build.props + centrally_managed = scan_centrally_managed_packages(repo_root) + + # Scan all packages + packages = scan_all_packages(src_root) + + if not packages: + logger.info("No packages found") + return 0 + + # Calculate normalizations (excluding centrally managed packages) + exclude_set = set(args.exclude_packages) + normalizations, centrally_skipped = calculate_normalizations( + packages, exclude_set, centrally_managed + ) + + # Generate report + report = generate_report(packages, normalizations, centrally_skipped) + + # Write report if requested + if args.report: + try: + args.report.write_text( + json.dumps(report, indent=2, default=str), + encoding="utf-8", + ) + logger.info(f"Report written to: {args.report}") + except Exception as e: + logger.error(f"Failed to write report: {e}") + + # Print summary + print_summary(packages, normalizations, centrally_skipped, args.dry_run or args.check) + + # Check mode - just report if normalization is needed + if args.check: + changes_needed = [n for n in normalizations if n.changes and not n.skipped_reason] + if changes_needed: + logger.error("Version normalization needed") + return 1 + logger.info("All package versions are consistent") + return 0 + + # Apply normalizations + if not args.dry_run: + files_modified = apply_normalizations(normalizations, dry_run=False) + print(f"\nModified {files_modified} files") + else: + apply_normalizations(normalizations, dry_run=True) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/slntools/sln_generator.py b/tools/slntools/sln_generator.py new file mode 100644 index 000000000..54e323bf5 --- /dev/null +++ b/tools/slntools/sln_generator.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +StellaOps Solution Generator. + +Generates consistent .sln files for: +- Main solution (src/StellaOps.sln) with all projects +- Module solutions (src//StellaOps..sln) with external deps in __External/ + +Usage: + python sln_generator.py [OPTIONS] + +Options: + --src-root PATH Root of src/ directory (default: ./src) + --main-only Only regenerate main solution + --module NAME Regenerate specific module solution only + --all Regenerate all solutions (default) + --dry-run Show changes without writing + --check CI mode: exit 1 if solutions need updating + -v, --verbose Verbose output +""" + +import argparse +import logging +import sys +from pathlib import Path + +from lib.csproj_parser import find_all_csproj, parse_csproj +from lib.dependency_graph import ( + collect_all_external_dependencies, + get_module_projects, +) +from lib.models import CsprojProject +from lib.sln_writer import ( + build_external_folder_hierarchy, + build_folder_hierarchy, + generate_solution_content, + has_bypass_marker, + write_solution_file, +) + +logger = logging.getLogger(__name__) + +# Directories under src/ that are modules (have their own solutions) +# Excludes special directories like __Libraries, __Tests, __Analyzers, Web, etc. +EXCLUDED_FROM_MODULE_SOLUTIONS = { + "__Libraries", + "__Tests", + "__Analyzers", + ".nuget", + ".cache", + ".vs", + "Web", # Angular project, not .NET + "plugins", + "app", + "Api", + "Sdk", + "DevPortal", + "Mirror", + "Provenance", + "Symbols", + "Unknowns", +} + + +def setup_logging(verbose: bool) -> None: + """Configure logging based on verbosity.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(levelname)s: %(message)s", + ) + + +def discover_modules(src_root: Path) -> list[Path]: + """ + Discover all module directories under src/. + + A module is a directory that: + - Is a direct child of src/ + - Is not in EXCLUDED_FROM_MODULE_SOLUTIONS + - Contains at least one .csproj file + + Returns: + List of absolute paths to module directories + """ + modules: list[Path] = [] + + for item in src_root.iterdir(): + if not item.is_dir(): + continue + + if item.name in EXCLUDED_FROM_MODULE_SOLUTIONS: + continue + + if item.name.startswith("."): + continue + + # Check if it contains any csproj files + csproj_files = list(item.rglob("*.csproj")) + if csproj_files: + modules.append(item.resolve()) + + return sorted(modules) + + +def load_all_projects(src_root: Path) -> tuple[list[CsprojProject], dict[Path, CsprojProject]]: + """ + Load and parse all projects under src/. + + Returns: + Tuple of (list of all projects, map from path to project) + """ + csproj_files = find_all_csproj(src_root) + logger.info(f"Found {len(csproj_files)} .csproj files") + + projects: list[CsprojProject] = [] + project_map: dict[Path, CsprojProject] = {} + + for csproj_path in csproj_files: + project = parse_csproj(csproj_path, src_root) + if project: + projects.append(project) + project_map[project.path] = project + else: + logger.warning(f"Failed to parse: {csproj_path}") + + logger.info(f"Successfully parsed {len(projects)} projects") + return projects, project_map + + +def generate_main_solution( + src_root: Path, + projects: list[CsprojProject], + dry_run: bool = False, +) -> bool: + """ + Generate the main StellaOps.sln with all projects. + + Args: + src_root: Root of src/ directory + projects: All parsed projects + dry_run: If True, don't write files + + Returns: + True if successful + """ + sln_path = src_root / "StellaOps.sln" + + # Check for bypass marker + if has_bypass_marker(sln_path): + logger.info(f"Skipping {sln_path} (has bypass marker)") + return True + + logger.info(f"Generating main solution: {sln_path}") + + # Build folder hierarchy matching physical structure + folders = build_folder_hierarchy(projects, src_root) + + # Generate solution content + content = generate_solution_content( + sln_path=sln_path, + projects=[], # Projects are in folders + folders=folders, + external_folders=None, + add_bypass_marker=False, + ) + + return write_solution_file(sln_path, content, dry_run) + + +def generate_module_solution( + module_dir: Path, + src_root: Path, + all_projects: list[CsprojProject], + project_map: dict[Path, CsprojProject], + dry_run: bool = False, +) -> bool: + """ + Generate a module-specific solution. + + Args: + module_dir: Root directory of the module + src_root: Root of src/ directory + all_projects: All parsed projects + project_map: Map from path to project + dry_run: If True, don't write files + + Returns: + True if successful + """ + module_name = module_dir.name + sln_path = module_dir / f"StellaOps.{module_name}.sln" + + # Check for bypass marker + if has_bypass_marker(sln_path): + logger.info(f"Skipping {sln_path} (has bypass marker)") + return True + + logger.info(f"Generating module solution: {sln_path}") + + # Get projects within this module + module_projects = get_module_projects(module_dir, all_projects) + + if not module_projects: + logger.warning(f"No projects found in module: {module_name}") + return True + + logger.debug(f" Found {len(module_projects)} projects in module") + + # Build internal folder hierarchy + internal_folders = build_folder_hierarchy(module_projects, module_dir) + + # Collect external dependencies + external_groups = collect_all_external_dependencies( + projects=module_projects, + module_dir=module_dir, + src_root=src_root, + project_map=project_map, + ) + + # Build external folder hierarchy + external_folders = {} + if external_groups: + external_folders = build_external_folder_hierarchy(external_groups, src_root) + ext_count = sum(len(v) for v in external_groups.values()) + logger.debug(f" Found {ext_count} external dependencies") + + # Generate solution content + content = generate_solution_content( + sln_path=sln_path, + projects=[], # Projects are in folders + folders=internal_folders, + external_folders=external_folders, + add_bypass_marker=False, + ) + + return write_solution_file(sln_path, content, dry_run) + + +def check_solutions_up_to_date( + src_root: Path, + modules: list[Path], + all_projects: list[CsprojProject], + project_map: dict[Path, CsprojProject], +) -> bool: + """ + Check if solutions need updating (for --check mode). + + Args: + src_root: Root of src/ directory + modules: List of module directories + all_projects: All parsed projects + project_map: Map from path to project + + Returns: + True if all solutions are up to date + """ + # This is a simplified check - in a real implementation, + # you would compare generated content with existing files + logger.info("Checking if solutions are up to date...") + + # For now, just check if files exist + main_sln = src_root / "StellaOps.sln" + if not main_sln.exists(): + logger.error(f"Main solution missing: {main_sln}") + return False + + for module_dir in modules: + module_name = module_dir.name + module_sln = module_dir / f"StellaOps.{module_name}.sln" + + if has_bypass_marker(module_sln): + continue + + if not module_sln.exists(): + logger.error(f"Module solution missing: {module_sln}") + return False + + logger.info("All solutions appear to be up to date") + return True + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Generate StellaOps solution files", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument( + "--src-root", + type=Path, + default=Path("src"), + help="Root of src/ directory (default: ./src)", + ) + parser.add_argument( + "--main-only", + action="store_true", + help="Only regenerate main solution", + ) + parser.add_argument( + "--module", + type=str, + help="Regenerate specific module solution only", + ) + parser.add_argument( + "--all", + action="store_true", + dest="regenerate_all", + help="Regenerate all solutions (default)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show changes without writing", + ) + parser.add_argument( + "--check", + action="store_true", + help="CI mode: exit 1 if solutions need updating", + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Verbose output", + ) + + args = parser.parse_args() + setup_logging(args.verbose) + + # Resolve src root + src_root = args.src_root.resolve() + if not src_root.exists(): + logger.error(f"Source root does not exist: {src_root}") + return 1 + + logger.info(f"Source root: {src_root}") + + # Load all projects + all_projects, project_map = load_all_projects(src_root) + + if not all_projects: + logger.error("No projects found") + return 1 + + # Discover modules + modules = discover_modules(src_root) + logger.info(f"Discovered {len(modules)} modules") + + # Check mode + if args.check: + if check_solutions_up_to_date(src_root, modules, all_projects, project_map): + return 0 + return 1 + + # Determine what to generate + success = True + + if args.module: + # Specific module only + module_dir = src_root / args.module + if not module_dir.exists(): + logger.error(f"Module directory does not exist: {module_dir}") + return 1 + + success = generate_module_solution( + module_dir, src_root, all_projects, project_map, args.dry_run + ) + + elif args.main_only: + # Main solution only + success = generate_main_solution(src_root, all_projects, args.dry_run) + + else: + # Generate all (default) + # Main solution + if not generate_main_solution(src_root, all_projects, args.dry_run): + success = False + + # Module solutions + for module_dir in modules: + if not generate_module_solution( + module_dir, src_root, all_projects, project_map, args.dry_run + ): + success = False + + if args.dry_run: + logger.info("Dry run complete - no files were modified") + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main())