diff --git a/apps/api/src/golden-snapshots/__fixtures__/realistic-repo-fixture.json b/apps/api/src/golden-snapshots/__fixtures__/realistic-repo-fixture.json new file mode 100644 index 00000000..f0117381 --- /dev/null +++ b/apps/api/src/golden-snapshots/__fixtures__/realistic-repo-fixture.json @@ -0,0 +1,85 @@ +{ + "description": "Realistic repository fixture covering multi-language DEX smart contracts and bridges", + "tenantId": "org-defi-protocols", + "repositoryId": "stellar-evm-dex-repo", + "commitHash": "9f3c2a71d4e85b0c6a1f27e3d98b54c0a2e1f6d7", + "rulePackVersion": "2.1.0", + "languageOutputs": { + "solidity": { + "language": "solidity", + "filesAnalyzed": 2, + "findings": [ + { + "ruleId": "gas-opt-uncached-array-length", + "message": "Array length should be cached outside for-loop condition to save gas", + "severity": "low", + "location": { + "file": "contracts/evm/BridgeVault.sol", + "startLine": 45, + "endLine": 45, + "startColumn": 12, + "endColumn": 30 + }, + "estimatedGasSavings": 320 + }, + { + "ruleId": "sec-reentrancy-eth-transfer", + "message": "Potential reentrancy vulnerability via external call before state update", + "severity": "high", + "location": { + "file": "contracts/evm/BridgeVault.sol", + "startLine": 112, + "endLine": 118, + "startColumn": 8, + "endColumn": 10 + } + } + ], + "summary": { + "critical": 0, + "high": 1, + "medium": 0, + "low": 1, + "info": 0 + }, + "totalEstimatedGasSavings": 320 + }, + "soroban": { + "language": "soroban", + "filesAnalyzed": 4, + "findings": [ + { + "ruleId": "soroban-storage-rent-bump", + "message": "Persistent storage entry missing rent bump on active access", + "severity": "medium", + "location": { + "file": "contracts/soroban/amm_pool/src/lib.rs", + "startLine": 78, + "endLine": 82, + "startColumn": 4, + "endColumn": 6 + } + } + ], + "summary": { + "critical": 0, + "high": 0, + "medium": 1, + "low": 0, + "info": 0 + } + }, + "rust": { + "language": "rust", + "filesAnalyzed": 2, + "findings": [], + "summary": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "info": 0 + } + } + } +} diff --git a/apps/api/src/golden-snapshots/__fixtures__/regression-case-fixture.json b/apps/api/src/golden-snapshots/__fixtures__/regression-case-fixture.json new file mode 100644 index 00000000..34b48b52 --- /dev/null +++ b/apps/api/src/golden-snapshots/__fixtures__/regression-case-fixture.json @@ -0,0 +1,70 @@ +{ + "description": "Regression case fixture showing modified and unexpected findings", + "tenantId": "org-defi-protocols", + "repositoryId": "stellar-evm-dex-repo", + "commitHash": "9f3c2a71d4e85b0c6a1f27e3d98b54c0a2e1f6d7", + "rulePackVersion": "2.1.0", + "actualOutputsWithRegression": { + "solidity": { + "language": "solidity", + "filesAnalyzed": 2, + "findings": [ + { + "ruleId": "gas-opt-uncached-array-length", + "message": "Array length should be cached outside for-loop condition to save gas", + "severity": "low", + "location": { + "file": "contracts/evm/BridgeVault.sol", + "startLine": 45, + "endLine": 45, + "startColumn": 12, + "endColumn": 30 + }, + "estimatedGasSavings": 320 + }, + { + "ruleId": "sec-reentrancy-eth-transfer", + "message": "REGRESSED_MESSAGE: Potential reentrancy vulnerability via external call before state update", + "severity": "high", + "location": { + "file": "contracts/evm/BridgeVault.sol", + "startLine": 112, + "endLine": 118, + "startColumn": 8, + "endColumn": 10 + } + }, + { + "ruleId": "sec-unexpected-regression-rule", + "message": "Spurious finding generated by modified rule-pack engine", + "severity": "critical", + "location": { + "file": "contracts/evm/BridgeVault.sol", + "startLine": 200, + "endLine": 205 + } + } + ], + "summary": { + "critical": 1, + "high": 1, + "medium": 0, + "low": 1, + "info": 0 + }, + "totalEstimatedGasSavings": 320 + }, + "soroban": { + "language": "soroban", + "filesAnalyzed": 4, + "findings": [], + "summary": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "info": 0 + } + } + } +} diff --git a/apps/api/src/golden-snapshots/golden-snapshot-config.spec.ts b/apps/api/src/golden-snapshots/golden-snapshot-config.spec.ts new file mode 100644 index 00000000..58b24bba --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-config.spec.ts @@ -0,0 +1,52 @@ +import { loadGoldenSnapshotConfig } from "./golden-snapshot-config"; +import { GoldenSnapshotError } from "./golden-snapshot-types"; + +describe("GoldenSnapshotConfig", () => { + it("loads default config when env is empty", () => { + const config = loadGoldenSnapshotConfig({}); + expect(config.enabled).toBe(true); + expect(config.enforceMatching).toBe(false); + expect(config.autoUpdate).toBe(false); + expect(config.maxDiffCount).toBe(500); + expect(config.maxPayloadSizeBytes).toBe(10 * 1024 * 1024); + }); + + it("parses valid custom env variables", () => { + const config = loadGoldenSnapshotConfig({ + GOLDEN_SNAPSHOT_ENABLED: "false", + GOLDEN_SNAPSHOT_STORAGE_DIR: "/tmp/snapshots", + GOLDEN_SNAPSHOT_ENFORCE_MATCHING: "true", + GOLDEN_SNAPSHOT_AUTO_UPDATE: "1", + GOLDEN_SNAPSHOT_MAX_DIFF_COUNT: "100", + GOLDEN_SNAPSHOT_MAX_PAYLOAD_SIZE: "2048", + }); + expect(config.enabled).toBe(false); + expect(config.storageDir).toBe("/tmp/snapshots"); + expect(config.enforceMatching).toBe(true); + expect(config.autoUpdate).toBe(true); + expect(config.maxDiffCount).toBe(100); + expect(config.maxPayloadSizeBytes).toBe(2048); + }); + + it("throws on invalid maxDiffCount", () => { + expect(() => + loadGoldenSnapshotConfig({ + GOLDEN_SNAPSHOT_MAX_DIFF_COUNT: "not-a-number", + }), + ).toThrow(GoldenSnapshotError); + + expect(() => + loadGoldenSnapshotConfig({ + GOLDEN_SNAPSHOT_MAX_DIFF_COUNT: "-5", + }), + ).toThrow(GoldenSnapshotError); + }); + + it("throws on invalid maxPayloadSize", () => { + expect(() => + loadGoldenSnapshotConfig({ + GOLDEN_SNAPSHOT_MAX_PAYLOAD_SIZE: "0", + }), + ).toThrow(GoldenSnapshotError); + }); +}); diff --git a/apps/api/src/golden-snapshots/golden-snapshot-config.ts b/apps/api/src/golden-snapshots/golden-snapshot-config.ts new file mode 100644 index 00000000..2cdeb44a --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-config.ts @@ -0,0 +1,84 @@ +/** + * Configuration options and environment loader for Golden Output Snapshots (#1253). + */ + +import { GoldenSnapshotError } from "./golden-snapshot-types"; + +export interface GoldenSnapshotConfig { + /** Master toggle / kill-switch for golden snapshot verification. */ + enabled: boolean; + /** Directory where snapshot manifest JSON files are stored. */ + storageDir: string; + /** Whether to fail scans or quality gates when snapshots mismatch. */ + enforceMatching: boolean; + /** Allow auto-updating or generating missing snapshots in non-production environments. */ + autoUpdate: boolean; + /** Maximum allowable diff count before aborting full diff computation. */ + maxDiffCount: number; + /** Maximum snapshot payload size in bytes. */ + maxPayloadSizeBytes: number; +} + +export const DEFAULT_GOLDEN_SNAPSHOT_CONFIG: GoldenSnapshotConfig = { + enabled: true, + storageDir: process.env.GOLDEN_SNAPSHOT_STORAGE_DIR || "./data/golden-snapshots", + enforceMatching: process.env.GOLDEN_SNAPSHOT_ENFORCE_MATCHING === "true", + autoUpdate: process.env.GOLDEN_SNAPSHOT_AUTO_UPDATE === "true", + maxDiffCount: 500, + maxPayloadSizeBytes: 10 * 1024 * 1024, // 10MB +}; + +export function loadGoldenSnapshotConfig( + env: Record = process.env, +): GoldenSnapshotConfig { + const enabled = + env.GOLDEN_SNAPSHOT_ENABLED !== undefined + ? env.GOLDEN_SNAPSHOT_ENABLED !== "false" && env.GOLDEN_SNAPSHOT_ENABLED !== "0" + : DEFAULT_GOLDEN_SNAPSHOT_CONFIG.enabled; + + const storageDir = + env.GOLDEN_SNAPSHOT_STORAGE_DIR || DEFAULT_GOLDEN_SNAPSHOT_CONFIG.storageDir; + + const enforceMatching = + env.GOLDEN_SNAPSHOT_ENFORCE_MATCHING !== undefined + ? env.GOLDEN_SNAPSHOT_ENFORCE_MATCHING === "true" || env.GOLDEN_SNAPSHOT_ENFORCE_MATCHING === "1" + : DEFAULT_GOLDEN_SNAPSHOT_CONFIG.enforceMatching; + + const autoUpdate = + env.GOLDEN_SNAPSHOT_AUTO_UPDATE !== undefined + ? env.GOLDEN_SNAPSHOT_AUTO_UPDATE === "true" || env.GOLDEN_SNAPSHOT_AUTO_UPDATE === "1" + : DEFAULT_GOLDEN_SNAPSHOT_CONFIG.autoUpdate; + + let maxDiffCount = DEFAULT_GOLDEN_SNAPSHOT_CONFIG.maxDiffCount; + if (env.GOLDEN_SNAPSHOT_MAX_DIFF_COUNT) { + const parsed = parseInt(env.GOLDEN_SNAPSHOT_MAX_DIFF_COUNT, 10); + if (isNaN(parsed) || parsed <= 0) { + throw new GoldenSnapshotError( + "INVALID_CONFIG", + `GOLDEN_SNAPSHOT_MAX_DIFF_COUNT must be a positive integer, got '${env.GOLDEN_SNAPSHOT_MAX_DIFF_COUNT}'`, + ); + } + maxDiffCount = parsed; + } + + let maxPayloadSizeBytes = DEFAULT_GOLDEN_SNAPSHOT_CONFIG.maxPayloadSizeBytes; + if (env.GOLDEN_SNAPSHOT_MAX_PAYLOAD_SIZE) { + const parsed = parseInt(env.GOLDEN_SNAPSHOT_MAX_PAYLOAD_SIZE, 10); + if (isNaN(parsed) || parsed <= 0) { + throw new GoldenSnapshotError( + "INVALID_CONFIG", + `GOLDEN_SNAPSHOT_MAX_PAYLOAD_SIZE must be a positive integer, got '${env.GOLDEN_SNAPSHOT_MAX_PAYLOAD_SIZE}'`, + ); + } + maxPayloadSizeBytes = parsed; + } + + return { + enabled, + storageDir, + enforceMatching, + autoUpdate, + maxDiffCount, + maxPayloadSizeBytes, + }; +} diff --git a/apps/api/src/golden-snapshots/golden-snapshot-evaluator.spec.ts b/apps/api/src/golden-snapshots/golden-snapshot-evaluator.spec.ts new file mode 100644 index 00000000..dd8afc43 --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-evaluator.spec.ts @@ -0,0 +1,134 @@ +import { + canonicalizeFindings, + computeSnapshotFingerprint, + createSnapshotManifest, + verifySnapshotAgainstOutputs, +} from "./golden-snapshot-evaluator"; +import { GoldenFindingSnapshot, LanguageGoldenOutput, GoldenSnapshotError } from "./golden-snapshot-types"; +import * as realisticFixture from "./__fixtures__/realistic-repo-fixture.json"; +import * as regressionFixture from "./__fixtures__/regression-case-fixture.json"; + +describe("GoldenSnapshotEvaluator", () => { + it("canonicalizes findings deterministically regardless of input ordering", () => { + const f1: GoldenFindingSnapshot = { + ruleId: "rule-b", + message: "Message B", + severity: "low", + location: { file: "src/b.ts", startLine: 10, endLine: 12 }, + }; + const f2: GoldenFindingSnapshot = { + ruleId: "rule-a", + message: "Message A", + severity: "high", + location: { file: "src/a.ts", startLine: 5, endLine: 6 }, + }; + const f3: GoldenFindingSnapshot = { + ruleId: "rule-c", + message: "Message C", + severity: "medium", + location: { file: "src/b.ts", startLine: 2, endLine: 3 }, + }; + + const ordered = canonicalizeFindings([f1, f2, f3]); + expect(ordered.map((f) => f.ruleId)).toEqual(["rule-a", "rule-c", "rule-b"]); + + const ordered2 = canonicalizeFindings([f3, f1, f2]); + expect(ordered2.map((f) => f.ruleId)).toEqual(["rule-a", "rule-c", "rule-b"]); + }); + + it("produces identical fingerprints for identical inputs", () => { + const langOutputs = realisticFixture.languageOutputs as Record; + const fp1 = computeSnapshotFingerprint("tenant-1", "repo-1", "commit-1", "1.0.0", langOutputs); + const fp2 = computeSnapshotFingerprint("tenant-1", "repo-1", "commit-1", "1.0.0", langOutputs); + expect(fp1).toBe(fp2); + expect(fp1.length).toBe(16); + }); + + it("produces different fingerprints when inputs change", () => { + const langOutputs = realisticFixture.languageOutputs as Record; + const fp1 = computeSnapshotFingerprint("tenant-1", "repo-1", "commit-1", "1.0.0", langOutputs); + const fp2 = computeSnapshotFingerprint("tenant-2", "repo-1", "commit-1", "1.0.0", langOutputs); + const fp3 = computeSnapshotFingerprint("tenant-1", "repo-1", "commit-2", "1.0.0", langOutputs); + expect(fp1).not.toBe(fp2); + expect(fp1).not.toBe(fp3); + }); + + it("creates valid snapshot manifest from realistic fixture", () => { + const manifest = createSnapshotManifest({ + tenantId: realisticFixture.tenantId, + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + languageOutputs: realisticFixture.languageOutputs as Record, + clock: () => 1700000000000, + }); + + expect(manifest.schemaVersion).toBe(1); + expect(manifest.supportedLanguages).toEqual(["rust", "solidity", "soroban"]); + expect(manifest.createdAt).toBe(1700000000000); + expect(manifest.snapshotId).toContain(realisticFixture.commitHash.slice(0, 12)); + }); + + it("validates input fields and rejects invalid inputs", () => { + expect(() => + createSnapshotManifest({ + tenantId: "", + repositoryId: "repo", + commitHash: "hash", + rulePackVersion: "1.0.0", + languageOutputs: {}, + }), + ).toThrow(GoldenSnapshotError); + + expect(() => + createSnapshotManifest({ + tenantId: "tenant", + repositoryId: "", + commitHash: "hash", + rulePackVersion: "1.0.0", + languageOutputs: {}, + }), + ).toThrow(GoldenSnapshotError); + }); + + it("verifies identical outputs as matched", () => { + const manifest = createSnapshotManifest({ + tenantId: realisticFixture.tenantId, + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + const result = verifySnapshotAgainstOutputs({ + goldenManifest: manifest, + actualOutputs: realisticFixture.languageOutputs as Record, + }); + + expect(result.status).toBe("matched"); + expect(result.matched).toBe(true); + expect(result.totalDifferences).toBe(0); + expect(result.expectedFingerprint).toBe(result.actualFingerprint); + }); + + it("detects regressions and diffs from regression fixture", () => { + const manifest = createSnapshotManifest({ + tenantId: realisticFixture.tenantId, + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + const result = verifySnapshotAgainstOutputs({ + goldenManifest: manifest, + actualOutputs: regressionFixture.actualOutputsWithRegression as Record, + }); + + expect(result.status).toBe("mismatched"); + expect(result.matched).toBe(false); + expect(result.totalDifferences).toBeGreaterThan(0); + expect(result.languageResults["solidity"].matched).toBe(false); + expect(result.languageResults["rust"].matched).toBe(false); // rust missing in regression + }); +}); diff --git a/apps/api/src/golden-snapshots/golden-snapshot-evaluator.ts b/apps/api/src/golden-snapshots/golden-snapshot-evaluator.ts new file mode 100644 index 00000000..02cabdcb --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-evaluator.ts @@ -0,0 +1,335 @@ +/** + * Pure evaluator for Golden Output Snapshots (#1253). + * + * Enforces determinism, canonical sorting, diffing, and hashing across supported languages. + */ + +import { + GoldenSnapshotManifest, + GoldenFindingSnapshot, + LanguageGoldenOutput, + SnapshotVerificationResult, + LanguageDiffResult, + FindingDiff, + GoldenSnapshotError, +} from "./golden-snapshot-types"; + +export const GOLDEN_SNAPSHOT_SCHEMA_VERSION = 1; + +/** + * Normalizes and sorts finding records canonically by file -> startLine -> startColumn -> ruleId -> message. + */ +export function canonicalizeFindings( + findings: GoldenFindingSnapshot[], +): GoldenFindingSnapshot[] { + return [...findings].sort((a, b) => { + if (a.location.file !== b.location.file) { + return a.location.file.localeCompare(b.location.file); + } + if (a.location.startLine !== b.location.startLine) { + return a.location.startLine - b.location.startLine; + } + const aCol = a.location.startColumn ?? 0; + const bCol = b.location.startColumn ?? 0; + if (aCol !== bCol) { + return aCol - bCol; + } + if (a.ruleId !== b.ruleId) { + return a.ruleId.localeCompare(b.ruleId); + } + return a.message.localeCompare(b.message); + }); +} + +/** + * Computes a deterministic SHA-256 equivalent djb2 hex fingerprint over structured language outputs. + */ +export function computeSnapshotFingerprint( + tenantId: string, + repositoryId: string, + commitHash: string, + rulePackVersion: string, + languageOutputs: Record, +): string { + const sortedLanguages = Object.keys(languageOutputs).sort(); + const tokens: string[] = [ + tenantId, + repositoryId, + commitHash, + rulePackVersion, + ]; + + for (const lang of sortedLanguages) { + const out = languageOutputs[lang]; + const canonical = canonicalizeFindings(out.findings); + const summaryStr = `${out.summary.critical}:${out.summary.high}:${out.summary.medium}:${out.summary.low}:${out.summary.info}`; + const findingsStr = canonical + .map( + (f) => + `${f.ruleId}@${f.location.file}:${f.location.startLine}:${f.severity}:${f.message.trim()}`, + ) + .join("|"); + tokens.push( + `lang=${lang};files=${out.filesAnalyzed};summary=${summaryStr};gas=${out.totalEstimatedGasSavings ?? 0};findings=[${findingsStr}]`, + ); + } + + const raw = tokens.join("::"); + + // Deterministic fast hash + let hash1 = 5381; + let hash2 = 52711; + for (let i = 0; i < raw.length; i++) { + const char = raw.charCodeAt(i); + hash1 = ((hash1 << 5) + hash1) ^ char; + hash2 = ((hash2 << 5) + hash2) ^ char; + } + const part1 = (hash1 >>> 0).toString(16).padStart(8, "0"); + const part2 = (hash2 >>> 0).toString(16).padStart(8, "0"); + return `${part1}${part2}`; +} + +/** + * Generates a complete canonical GoldenSnapshotManifest from language analysis outputs. + */ +export function createSnapshotManifest(params: { + tenantId: string; + repositoryId: string; + commitHash: string; + rulePackVersion: string; + languageOutputs: Record; + clock?: () => number; + metadata?: Record; +}): GoldenSnapshotManifest { + const { tenantId, repositoryId, commitHash, rulePackVersion, languageOutputs } = params; + + if (!tenantId || typeof tenantId !== "string" || tenantId.trim() === "") { + throw new GoldenSnapshotError("INVALID_INPUT", "tenantId must be a non-empty string"); + } + if (!repositoryId || typeof repositoryId !== "string" || repositoryId.trim() === "") { + throw new GoldenSnapshotError("INVALID_INPUT", "repositoryId must be a non-empty string"); + } + if (!commitHash || typeof commitHash !== "string" || commitHash.trim() === "") { + throw new GoldenSnapshotError("INVALID_INPUT", "commitHash must be a non-empty string"); + } + if (!rulePackVersion || typeof rulePackVersion !== "string" || rulePackVersion.trim() === "") { + throw new GoldenSnapshotError("INVALID_INPUT", "rulePackVersion must be a non-empty string"); + } + + const clock = params.clock ?? (() => Date.now()); + const normalizedOutputs: Record = {}; + const sortedLangs = Object.keys(languageOutputs).sort(); + + for (const lang of sortedLangs) { + const output = languageOutputs[lang]; + normalizedOutputs[lang] = { + language: lang, + filesAnalyzed: output.filesAnalyzed, + findings: canonicalizeFindings(output.findings), + summary: { ...output.summary }, + totalEstimatedGasSavings: output.totalEstimatedGasSavings, + }; + } + + const fingerprint = computeSnapshotFingerprint( + tenantId, + repositoryId, + commitHash, + rulePackVersion, + normalizedOutputs, + ); + + const snapshotId = `golden-${repositoryId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${commitHash.slice(0, 12)}-${fingerprint.slice(0, 8)}`; + + return { + schemaVersion: GOLDEN_SNAPSHOT_SCHEMA_VERSION, + snapshotId, + tenantId, + repositoryId, + commitHash, + rulePackVersion, + supportedLanguages: sortedLangs, + createdAt: clock(), + fingerprint, + languageOutputs: normalizedOutputs, + metadata: params.metadata, + }; +} + +/** + * Compares an actual set of language analysis outputs against a golden snapshot manifest. + */ +export function verifySnapshotAgainstOutputs(params: { + goldenManifest: GoldenSnapshotManifest; + actualOutputs: Record; + maxDiffs?: number; + clock?: () => number; +}): SnapshotVerificationResult { + const { goldenManifest, actualOutputs } = params; + const maxDiffs = params.maxDiffs ?? 500; + const clock = params.clock ?? (() => Date.now()); + + const actualFingerprint = computeSnapshotFingerprint( + goldenManifest.tenantId, + goldenManifest.repositoryId, + goldenManifest.commitHash, + goldenManifest.rulePackVersion, + actualOutputs, + ); + + const languageResults: Record = {}; + let totalDifferences = 0; + + // Union of languages in golden and actual + const allLanguages = Array.from( + new Set([...goldenManifest.supportedLanguages, ...Object.keys(actualOutputs)]), + ).sort(); + + for (const lang of allLanguages) { + const expected = goldenManifest.languageOutputs[lang]; + const actual = actualOutputs[lang]; + + const findingDiffs: FindingDiff[] = []; + + if (!expected && actual) { + totalDifferences++; + findingDiffs.push({ + type: "added", + ruleId: "*", + file: "*", + line: 0, + message: `Unexpected analysis output for language '${lang}' not in golden snapshot`, + }); + languageResults[lang] = { + language: lang, + matched: false, + findingDiffs, + }; + continue; + } + + if (expected && !actual) { + totalDifferences++; + findingDiffs.push({ + type: "removed", + ruleId: "*", + file: "*", + line: 0, + message: `Missing analysis output for language '${lang}' defined in golden snapshot`, + }); + languageResults[lang] = { + language: lang, + matched: false, + findingDiffs, + }; + continue; + } + + if (expected && actual) { + // Compare filesAnalyzed + let filesAnalyzedDiff: LanguageDiffResult["filesAnalyzedDiff"]; + if (expected.filesAnalyzed !== actual.filesAnalyzed) { + filesAnalyzedDiff = { + expected: expected.filesAnalyzed, + actual: actual.filesAnalyzed, + }; + totalDifferences++; + } + + // Compare summary + let summaryDiff: LanguageDiffResult["summaryDiff"]; + const sumE = expected.summary; + const sumA = actual.summary; + if ( + sumE.critical !== sumA.critical || + sumE.high !== sumA.high || + sumE.medium !== sumA.medium || + sumE.low !== sumA.low || + sumE.info !== sumA.info + ) { + summaryDiff = { expected: sumE, actual: sumA }; + totalDifferences++; + } + + // Compare findings + const expCanonical = canonicalizeFindings(expected.findings); + const actCanonical = canonicalizeFindings(actual.findings); + + const expMap = new Map(); + for (const f of expCanonical) { + const key = `${f.ruleId}::${f.location.file}::${f.location.startLine}::${f.message.trim()}`; + expMap.set(key, f); + } + + const actMap = new Map(); + for (const f of actCanonical) { + const key = `${f.ruleId}::${f.location.file}::${f.location.startLine}::${f.message.trim()}`; + actMap.set(key, f); + } + + // Check for removed findings + for (const [key, expFinding] of expMap.entries()) { + if (!actMap.has(key)) { + if (findingDiffs.length < maxDiffs) { + findingDiffs.push({ + type: "removed", + ruleId: expFinding.ruleId, + file: expFinding.location.file, + line: expFinding.location.startLine, + expected: expFinding, + message: `Expected finding '${expFinding.ruleId}' at ${expFinding.location.file}:${expFinding.location.startLine} was not found`, + }); + } + totalDifferences++; + } + } + + // Check for added findings + for (const [key, actFinding] of actMap.entries()) { + if (!expMap.has(key)) { + if (findingDiffs.length < maxDiffs) { + findingDiffs.push({ + type: "added", + ruleId: actFinding.ruleId, + file: actFinding.location.file, + line: actFinding.location.startLine, + actual: actFinding, + message: `Unexpected finding '${actFinding.ruleId}' at ${actFinding.location.file}:${actFinding.location.startLine}`, + }); + } + totalDifferences++; + } + } + + const matched = + totalDifferences === 0 && + findingDiffs.length === 0 && + !summaryDiff && + !filesAnalyzedDiff; + + languageResults[lang] = { + language: lang, + matched, + findingDiffs, + summaryDiff, + filesAnalyzedDiff, + }; + } + } + + const matched = totalDifferences === 0 && actualFingerprint === goldenManifest.fingerprint; + + return { + status: matched ? "matched" : "mismatched", + matched, + tenantId: goldenManifest.tenantId, + repositoryId: goldenManifest.repositoryId, + commitHash: goldenManifest.commitHash, + rulePackVersion: goldenManifest.rulePackVersion, + expectedFingerprint: goldenManifest.fingerprint, + actualFingerprint, + languageResults, + totalDifferences, + verifiedAt: clock(), + }; +} diff --git a/apps/api/src/golden-snapshots/golden-snapshot-metrics.spec.ts b/apps/api/src/golden-snapshots/golden-snapshot-metrics.spec.ts new file mode 100644 index 00000000..22ddf12e --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-metrics.spec.ts @@ -0,0 +1,49 @@ +import { + GoldenSnapshotMetrics, + sanitizeLogContext, +} from "./golden-snapshot-metrics"; + +describe("GoldenSnapshotMetrics and Logging", () => { + it("records metrics accurately", () => { + const metrics = new GoldenSnapshotMetrics(); + metrics.recordSnapshotCreated(3); + + metrics.recordVerification("matched", { + solidity: { matched: true, findingDiffs: [] }, + }); + metrics.recordVerification("mismatched", { + solidity: { matched: false, findingDiffs: [{ type: "added" }] }, + }); + metrics.recordVerification("missing", {}); + + const counters = metrics.getCounters(); + expect(counters.snapshotsCreated).toBe(1); + expect(counters.verificationsTotal).toBe(3); + expect(counters.verificationsMatched).toBe(1); + expect(counters.verificationsMismatched).toBe(1); + expect(counters.verificationsMissing).toBe(1); + expect(counters.languageDifferencesTotal["solidity"]).toBe(1); + }); + + it("sanitizes secrets, tokens, passwords, and sensitive keys from logs", () => { + const rawContext = { + tenantId: "tenant-123", + repositoryId: "my-repo", + apiKey: "secret-key-12345", + userToken: "ghp_xxxxxxxxxxxx", + nested: { + password: "supersecretpassword", + privateKey: "-----BEGIN PRIVATE KEY-----", + normalField: "safe value", + }, + }; + + const sanitized = sanitizeLogContext(rawContext) as any; + expect(sanitized.tenantId).toBe("tenant-123"); + expect(sanitized.apiKey).toBe("[REDACTED]"); + expect(sanitized.userToken).toBe("[REDACTED]"); + expect(sanitized.nested.password).toBe("[REDACTED]"); + expect(sanitized.nested.privateKey).toBe("[REDACTED]"); + expect(sanitized.nested.normalField).toBe("safe value"); + }); +}); diff --git a/apps/api/src/golden-snapshots/golden-snapshot-metrics.ts b/apps/api/src/golden-snapshots/golden-snapshot-metrics.ts new file mode 100644 index 00000000..a3e36009 --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-metrics.ts @@ -0,0 +1,97 @@ +/** + * Structured metrics and safe logging for Golden Output Snapshots (#1253). + * + * Guarantees that logs and metrics never leak source code, paths, tokens, + * or customer secrets. + */ + +export interface GoldenSnapshotMetricCounters { + snapshotsCreated: number; + verificationsTotal: number; + verificationsMatched: number; + verificationsMismatched: number; + verificationsMissing: number; + languageDifferencesTotal: Record; +} + +export class GoldenSnapshotMetrics { + private counters: GoldenSnapshotMetricCounters = { + snapshotsCreated: 0, + verificationsTotal: 0, + verificationsMatched: 0, + verificationsMismatched: 0, + verificationsMissing: 0, + languageDifferencesTotal: {}, + }; + + recordSnapshotCreated(languageCount: number): void { + this.counters.snapshotsCreated++; + } + + recordVerification( + status: "matched" | "mismatched" | "missing", + languageResults: Record, + ): void { + this.counters.verificationsTotal++; + if (status === "matched") { + this.counters.verificationsMatched++; + } else if (status === "mismatched") { + this.counters.verificationsMismatched++; + } else { + this.counters.verificationsMissing++; + } + + for (const [lang, res] of Object.entries(languageResults)) { + if (!res.matched) { + this.counters.languageDifferencesTotal[lang] = + (this.counters.languageDifferencesTotal[lang] ?? 0) + res.findingDiffs.length; + } + } + } + + getCounters(): Readonly { + return { ...this.counters }; + } + + reset(): void { + this.counters = { + snapshotsCreated: 0, + verificationsTotal: 0, + verificationsMatched: 0, + verificationsMismatched: 0, + verificationsMissing: 0, + languageDifferencesTotal: {}, + }; + } +} + +/** + * Sanitizes log context so secrets, access keys, private keys, and sensitive tokens are never emitted. + */ +export function sanitizeLogContext(context: Record): Record { + const sensitiveKeys = [ + "token", + "secret", + "password", + "key", + "auth", + "credential", + "private", + "pat", + "apikey", + "code", + ]; + + const result: Record = {}; + for (const [k, v] of Object.entries(context)) { + const lower = k.toLowerCase(); + if (sensitiveKeys.some((s) => lower.includes(s))) { + result[k] = "[REDACTED]"; + } else if (typeof v === "object" && v !== null && !Array.isArray(v)) { + result[k] = sanitizeLogContext(v as Record); + } else { + result[k] = v; + } + } + return result; +} diff --git a/apps/api/src/golden-snapshots/golden-snapshot-store.spec.ts b/apps/api/src/golden-snapshots/golden-snapshot-store.spec.ts new file mode 100644 index 00000000..cf8a8510 --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-store.spec.ts @@ -0,0 +1,135 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { + InMemoryGoldenSnapshotStore, + FileGoldenSnapshotStore, +} from "./golden-snapshot-store"; +import { + createSnapshotManifest, +} from "./golden-snapshot-evaluator"; +import * as realisticFixture from "./__fixtures__/realistic-repo-fixture.json"; +import { LanguageGoldenOutput } from "./golden-snapshot-types"; + +describe("GoldenSnapshotStore", () => { + describe("InMemoryGoldenSnapshotStore", () => { + it("saves and retrieves snapshots across tenant boundaries", async () => { + const store = new InMemoryGoldenSnapshotStore(); + const manifest = createSnapshotManifest({ + tenantId: "tenant-a", + repositoryId: "repo-1", + commitHash: "hash-1", + rulePackVersion: "1.0.0", + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + await store.saveSnapshot(manifest); + + const found = await store.getSnapshot("tenant-a", "repo-1", "hash-1", "1.0.0"); + expect(found).toBeDefined(); + expect(found?.snapshotId).toBe(manifest.snapshotId); + + // Verify tenant isolation + const otherTenant = await store.getSnapshot("tenant-b", "repo-1", "hash-1", "1.0.0"); + expect(otherTenant).toBeUndefined(); + }); + + it("saves and retrieves scan pins idempotently", async () => { + const store = new InMemoryGoldenSnapshotStore(); + const pin = { + tenantId: "tenant-a", + scanId: "scan-123", + repositoryId: "repo-1", + commitHash: "hash-1", + rulePackVersion: "1.0.0", + snapshotId: "snapshot-123", + pinnedAt: 1000, + fingerprint: "abc", + }; + + const saved = await store.saveScanPin(pin); + expect(saved).toEqual(pin); + + const savedAgain = await store.saveScanPin({ + ...pin, + snapshotId: "different-id", + }); + // Should preserve write-once behavior + expect(savedAgain.snapshotId).toBe("snapshot-123"); + + const fetched = await store.getScanPin("tenant-a", "scan-123"); + expect(fetched?.snapshotId).toBe("snapshot-123"); + }); + }); + + describe("FileGoldenSnapshotStore", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "golden-snapshot-store-test-")); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it("saves and retrieves snapshots from disk with directory isolation", async () => { + const store = new FileGoldenSnapshotStore(tmpDir); + const manifest = createSnapshotManifest({ + tenantId: "tenant-a", + repositoryId: "repo-1", + commitHash: "hash-1", + rulePackVersion: "1.0.0", + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + await store.saveSnapshot(manifest); + + const found = await store.getSnapshot("tenant-a", "repo-1", "hash-1", "1.0.0"); + expect(found).toBeDefined(); + expect(found?.snapshotId).toBe(manifest.snapshotId); + + const byId = await store.getSnapshotById("tenant-a", manifest.snapshotId); + expect(byId?.fingerprint).toBe(manifest.fingerprint); + + // Tenant isolation on filesystem + const otherTenant = await store.getSnapshot("tenant-b", "repo-1", "hash-1", "1.0.0"); + expect(otherTenant).toBeUndefined(); + }); + + it("persists scan pins and verifications on disk", async () => { + const store = new FileGoldenSnapshotStore(tmpDir); + const pin = { + tenantId: "tenant-a", + scanId: "scan-999", + repositoryId: "repo-1", + commitHash: "hash-1", + rulePackVersion: "1.0.0", + snapshotId: "snap-999", + pinnedAt: 2000, + fingerprint: "fp-999", + }; + + await store.saveScanPin(pin); + const retrieved = await store.getScanPin("tenant-a", "scan-999"); + expect(retrieved?.snapshotId).toBe("snap-999"); + + await store.saveVerification({ + status: "matched", + matched: true, + tenantId: "tenant-a", + repositoryId: "repo-1", + commitHash: "hash-1", + rulePackVersion: "1.0.0", + expectedFingerprint: "fp", + actualFingerprint: "fp", + languageResults: {}, + totalDifferences: 0, + verifiedAt: 3000, + }); + + const verif = await store.getLatestVerification("tenant-a", "repo-1"); + expect(verif?.status).toBe("matched"); + }); + }); +}); diff --git a/apps/api/src/golden-snapshots/golden-snapshot-store.ts b/apps/api/src/golden-snapshots/golden-snapshot-store.ts new file mode 100644 index 00000000..d719a53b --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-store.ts @@ -0,0 +1,294 @@ +/** + * Persistence layer for Golden Output Snapshots (#1253). + * + * Implements strict tenant isolation, memory store, and sanitized file-system persistence. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { + GoldenSnapshotManifest, + SnapshotVerificationResult, + GoldenSnapshotScanPin, + GoldenSnapshotError, +} from "./golden-snapshot-types"; + +export interface GoldenSnapshotStore { + saveSnapshot(manifest: GoldenSnapshotManifest): Promise; + getSnapshot( + tenantId: string, + repositoryId: string, + commitHash: string, + rulePackVersion?: string, + ): Promise; + getSnapshotById( + tenantId: string, + snapshotId: string, + ): Promise; + saveVerification(verification: SnapshotVerificationResult): Promise; + getLatestVerification( + tenantId: string, + repositoryId: string, + ): Promise; + saveScanPin(pin: GoldenSnapshotScanPin): Promise; + getScanPin( + tenantId: string, + scanId: string, + ): Promise; +} + +export class InMemoryGoldenSnapshotStore implements GoldenSnapshotStore { + private snapshots: Map = new Map(); + private verifications: Map = new Map(); + private pins: Map = new Map(); + + private makeKey( + tenantId: string, + repositoryId: string, + commitHash: string, + rulePackVersion?: string, + ): string { + return rulePackVersion + ? `${tenantId}::${repositoryId}::${commitHash}::${rulePackVersion}` + : `${tenantId}::${repositoryId}::${commitHash}`; + } + + async saveSnapshot(manifest: GoldenSnapshotManifest): Promise { + const key = this.makeKey( + manifest.tenantId, + manifest.repositoryId, + manifest.commitHash, + manifest.rulePackVersion, + ); + this.snapshots.set(key, JSON.parse(JSON.stringify(manifest))); + this.snapshots.set( + `${manifest.tenantId}::id::${manifest.snapshotId}`, + JSON.parse(JSON.stringify(manifest)), + ); + } + + async getSnapshot( + tenantId: string, + repositoryId: string, + commitHash: string, + rulePackVersion?: string, + ): Promise { + if (rulePackVersion) { + const key = this.makeKey(tenantId, repositoryId, commitHash, rulePackVersion); + return this.snapshots.get(key); + } + const prefix = `${tenantId}::${repositoryId}::${commitHash}`; + for (const [k, v] of this.snapshots.entries()) { + if (k.startsWith(prefix)) { + return v; + } + } + return undefined; + } + + async getSnapshotById( + tenantId: string, + snapshotId: string, + ): Promise { + return this.snapshots.get(`${tenantId}::id::${snapshotId}`); + } + + async saveVerification(verification: SnapshotVerificationResult): Promise { + const key = `${verification.tenantId}::${verification.repositoryId}`; + this.verifications.set(key, JSON.parse(JSON.stringify(verification))); + } + + async getLatestVerification( + tenantId: string, + repositoryId: string, + ): Promise { + const key = `${tenantId}::${repositoryId}`; + return this.verifications.get(key); + } + + async saveScanPin(pin: GoldenSnapshotScanPin): Promise { + const key = `${pin.tenantId}::${pin.scanId}`; + const existing = this.pins.get(key); + if (existing) { + return existing; + } + this.pins.set(key, JSON.parse(JSON.stringify(pin))); + return pin; + } + + async getScanPin( + tenantId: string, + scanId: string, + ): Promise { + return this.pins.get(`${tenantId}::${scanId}`); + } +} + +export class FileGoldenSnapshotStore implements GoldenSnapshotStore { + constructor(private readonly baseDir: string) { + if (!fs.existsSync(baseDir)) { + fs.mkdirSync(baseDir, { recursive: true }); + } + } + + private sanitize(str: string): string { + return str.replace(/[^a-zA-Z0-9_-]/g, "_"); + } + + private getTenantDir(tenantId: string): string { + const dir = path.join(this.baseDir, this.sanitize(tenantId)); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + return dir; + } + + async saveSnapshot(manifest: GoldenSnapshotManifest): Promise { + try { + const tenantDir = this.getTenantDir(manifest.tenantId); + const filename = `snapshot_${this.sanitize(manifest.repositoryId)}_${this.sanitize(manifest.commitHash)}_${this.sanitize(manifest.rulePackVersion)}.json`; + const fullPath = path.join(tenantDir, filename); + await fs.promises.writeFile(fullPath, JSON.stringify(manifest, null, 2), "utf8"); + + // Pointer by ID + const idPath = path.join(tenantDir, `id_${this.sanitize(manifest.snapshotId)}.json`); + await fs.promises.writeFile(idPath, JSON.stringify(manifest, null, 2), "utf8"); + } catch (err) { + throw new GoldenSnapshotError( + "STORE_UNAVAILABLE", + "Failed to write snapshot to disk", + err, + ); + } + } + + async getSnapshot( + tenantId: string, + repositoryId: string, + commitHash: string, + rulePackVersion?: string, + ): Promise { + try { + const tenantDir = this.getTenantDir(tenantId); + if (rulePackVersion) { + const filename = `snapshot_${this.sanitize(repositoryId)}_${this.sanitize(commitHash)}_${this.sanitize(rulePackVersion)}.json`; + const fullPath = path.join(tenantDir, filename); + if (!fs.existsSync(fullPath)) return undefined; + const raw = await fs.promises.readFile(fullPath, "utf8"); + return JSON.parse(raw); + } + + const prefix = `snapshot_${this.sanitize(repositoryId)}_${this.sanitize(commitHash)}_`; + const files = await fs.promises.readdir(tenantDir); + for (const f of files) { + if (f.startsWith(prefix) && f.endsWith(".json")) { + const raw = await fs.promises.readFile(path.join(tenantDir, f), "utf8"); + return JSON.parse(raw); + } + } + return undefined; + } catch (err) { + throw new GoldenSnapshotError( + "STORE_UNAVAILABLE", + "Failed to read snapshot from disk", + err, + ); + } + } + + async getSnapshotById( + tenantId: string, + snapshotId: string, + ): Promise { + try { + const tenantDir = this.getTenantDir(tenantId); + const idPath = path.join(tenantDir, `id_${this.sanitize(snapshotId)}.json`); + if (!fs.existsSync(idPath)) return undefined; + const raw = await fs.promises.readFile(idPath, "utf8"); + return JSON.parse(raw); + } catch (err) { + throw new GoldenSnapshotError( + "STORE_UNAVAILABLE", + "Failed to read snapshot by ID from disk", + err, + ); + } + } + + async saveVerification(verification: SnapshotVerificationResult): Promise { + try { + const tenantDir = this.getTenantDir(verification.tenantId); + const fullPath = path.join( + tenantDir, + `verif_${this.sanitize(verification.repositoryId)}.json`, + ); + await fs.promises.writeFile(fullPath, JSON.stringify(verification, null, 2), "utf8"); + } catch (err) { + throw new GoldenSnapshotError( + "STORE_UNAVAILABLE", + "Failed to save verification to disk", + err, + ); + } + } + + async getLatestVerification( + tenantId: string, + repositoryId: string, + ): Promise { + try { + const tenantDir = this.getTenantDir(tenantId); + const fullPath = path.join( + tenantDir, + `verif_${this.sanitize(repositoryId)}.json`, + ); + if (!fs.existsSync(fullPath)) return undefined; + const raw = await fs.promises.readFile(fullPath, "utf8"); + return JSON.parse(raw); + } catch (err) { + throw new GoldenSnapshotError( + "STORE_UNAVAILABLE", + "Failed to read verification from disk", + err, + ); + } + } + + async saveScanPin(pin: GoldenSnapshotScanPin): Promise { + try { + const tenantDir = this.getTenantDir(pin.tenantId); + const fullPath = path.join(tenantDir, `pin_${this.sanitize(pin.scanId)}.json`); + if (fs.existsSync(fullPath)) { + const raw = await fs.promises.readFile(fullPath, "utf8"); + return JSON.parse(raw); + } + await fs.promises.writeFile(fullPath, JSON.stringify(pin, null, 2), "utf8"); + return pin; + } catch (err) { + throw new GoldenSnapshotError( + "STORE_UNAVAILABLE", + "Failed to save scan pin to disk", + err, + ); + } + } + + async getScanPin( + tenantId: string, + scanId: string, + ): Promise { + try { + const tenantDir = this.getTenantDir(tenantId); + const fullPath = path.join(tenantDir, `pin_${this.sanitize(scanId)}.json`); + if (!fs.existsSync(fullPath)) return undefined; + const raw = await fs.promises.readFile(fullPath, "utf8"); + return JSON.parse(raw); + } catch (err) { + throw new GoldenSnapshotError( + "STORE_UNAVAILABLE", + "Failed to read scan pin from disk", + err, + ); + } + } +} diff --git a/apps/api/src/golden-snapshots/golden-snapshot-types.ts b/apps/api/src/golden-snapshots/golden-snapshot-types.ts new file mode 100644 index 00000000..09a88dff --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-types.ts @@ -0,0 +1,126 @@ +/** + * Shared types for golden output snapshots for supported languages (#1253). + * + * Provides deterministic capture, verification, and persistence of canonical + * golden output snapshots for repository analysis across supported languages. + */ + +import { Language } from "../../../../libs/engine/core/analyzer-interface"; + +export const SNAPSHOT_STATUSES = ["matched", "mismatched", "missing"] as const; +export type GoldenSnapshotStatus = (typeof SNAPSHOT_STATUSES)[number]; + +export interface FindingLocationSnapshot { + file: string; + startLine: number; + endLine: number; + startColumn?: number; + endColumn?: number; +} + +export interface GoldenFindingSnapshot { + ruleId: string; + message: string; + severity: "critical" | "high" | "medium" | "low" | "info"; + location: FindingLocationSnapshot; + estimatedGasSavings?: number; + metadata?: Record; +} + +export interface LanguageGoldenOutput { + language: Language | string; + filesAnalyzed: number; + findings: GoldenFindingSnapshot[]; + summary: { + critical: number; + high: number; + medium: number; + low: number; + info: number; + }; + totalEstimatedGasSavings?: number; +} + +export interface GoldenSnapshotManifest { + schemaVersion: number; + snapshotId: string; + tenantId: string; + repositoryId: string; + commitHash: string; + rulePackVersion: string; + supportedLanguages: string[]; + createdAt: number; + fingerprint: string; + languageOutputs: Record; + metadata?: Record; +} + +export interface FindingDiff { + type: "added" | "removed" | "modified"; + ruleId: string; + file: string; + line: number; + expected?: Partial; + actual?: Partial; + message: string; +} + +export interface LanguageDiffResult { + language: string; + matched: boolean; + findingDiffs: FindingDiff[]; + summaryDiff?: { + expected: LanguageGoldenOutput["summary"]; + actual: LanguageGoldenOutput["summary"]; + }; + filesAnalyzedDiff?: { + expected: number; + actual: number; + }; +} + +export interface SnapshotVerificationResult { + status: GoldenSnapshotStatus; + matched: boolean; + tenantId: string; + repositoryId: string; + commitHash: string; + rulePackVersion: string; + expectedFingerprint: string; + actualFingerprint: string; + languageResults: Record; + totalDifferences: number; + verifiedAt: number; +} + +export interface GoldenSnapshotScanPin { + tenantId: string; + scanId: string; + repositoryId: string; + commitHash: string; + rulePackVersion: string; + snapshotId: string; + pinnedAt: number; + fingerprint: string; +} + +export type GoldenSnapshotErrorCode = + | "SNAPSHOT_DISABLED" + | "INVALID_INPUT" + | "INVALID_CONFIG" + | "STORE_UNAVAILABLE" + | "STORE_CONFLICT" + | "UNSUPPORTED_LANGUAGE" + | "SNAPSHOT_NOT_FOUND" + | "PARSE_ERROR"; + +export class GoldenSnapshotError extends Error { + constructor( + public readonly code: GoldenSnapshotErrorCode, + message: string, + public readonly details?: unknown, + ) { + super(`[${code}] ${message}`); + this.name = "GoldenSnapshotError"; + } +} diff --git a/apps/api/src/golden-snapshots/golden-snapshot-worker.ts b/apps/api/src/golden-snapshots/golden-snapshot-worker.ts new file mode 100644 index 00000000..463dbb6e --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot-worker.ts @@ -0,0 +1,78 @@ +/** + * Worker-side adapter for Golden Output Snapshots (#1253). + * + * Integrates snapshot verification into repository scanning pipelines + * and converts raw engine AnalysisResults into canonical snapshot formats. + */ + +import { AnalysisResult } from "../../../../libs/engine/core/analyzer-interface"; +import { GoldenSnapshotService } from "./golden-snapshot.service"; +import { + LanguageGoldenOutput, + GoldenFindingSnapshot, + SnapshotVerificationResult, +} from "./golden-snapshot-types"; + +export interface GoldenSnapshotWorkerTask { + tenantId: string; + scanId: string; + repositoryId: string; + commitHash: string; + rulePackVersion: string; + /** Language -> AnalysisResult from analyzer runner */ + analysisResults: Record; +} + +export function transformAnalysisResultToGoldenOutput( + language: string, + result: AnalysisResult, +): LanguageGoldenOutput { + const findings: GoldenFindingSnapshot[] = (result.findings || []).map((f) => ({ + ruleId: f.ruleId, + message: f.message, + severity: f.severity, + location: { + file: f.location.file, + startLine: f.location.startLine, + endLine: f.location.endLine, + startColumn: f.location.startColumn, + endColumn: f.location.endColumn, + }, + estimatedGasSavings: f.estimatedGasSavings, + metadata: f.metadata, + })); + + return { + language, + filesAnalyzed: result.filesAnalyzed ?? 0, + findings, + summary: { + critical: result.summary?.critical ?? 0, + high: result.summary?.high ?? 0, + medium: result.summary?.medium ?? 0, + low: result.summary?.low ?? 0, + info: result.summary?.info ?? 0, + }, + totalEstimatedGasSavings: result.totalEstimatedGasSavings, + }; +} + +export async function processGoldenSnapshotVerification( + service: GoldenSnapshotService, + task: GoldenSnapshotWorkerTask, +): Promise { + const actualOutputs: Record = {}; + + for (const [lang, result] of Object.entries(task.analysisResults)) { + actualOutputs[lang] = transformAnalysisResultToGoldenOutput(lang, result); + } + + return service.verifyOutputs({ + tenantId: task.tenantId, + scanId: task.scanId, + repositoryId: task.repositoryId, + commitHash: task.commitHash, + rulePackVersion: task.rulePackVersion, + actualOutputs, + }); +} diff --git a/apps/api/src/golden-snapshots/golden-snapshot.integration.spec.ts b/apps/api/src/golden-snapshots/golden-snapshot.integration.spec.ts new file mode 100644 index 00000000..8f8c4a07 --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot.integration.spec.ts @@ -0,0 +1,221 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { GoldenSnapshotService } from "./golden-snapshot.service"; +import { FileGoldenSnapshotStore } from "./golden-snapshot-store"; +import { GoldenSnapshotMetrics } from "./golden-snapshot-metrics"; +import { + processGoldenSnapshotVerification, + transformAnalysisResultToGoldenOutput, +} from "./golden-snapshot-worker"; +import { AnalysisResult, Severity } from "../../../../libs/engine/core/analyzer-interface"; +import * as realisticFixture from "./__fixtures__/realistic-repo-fixture.json"; +import * as regressionFixture from "./__fixtures__/regression-case-fixture.json"; +import { LanguageGoldenOutput } from "./golden-snapshot-types"; + +describe("Golden Snapshot End-to-End & Integration", () => { + let tmpDir: string; + let store: FileGoldenSnapshotStore; + let metrics: GoldenSnapshotMetrics; + let service: GoldenSnapshotService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "golden-snapshot-integ-")); + store = new FileGoldenSnapshotStore(tmpDir); + metrics = new GoldenSnapshotMetrics(); + service = new GoldenSnapshotService( + store, + { + enabled: true, + storageDir: tmpDir, + enforceMatching: false, + autoUpdate: false, + maxDiffCount: 100, + maxPayloadSizeBytes: 1024 * 1024, + }, + metrics, + ); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it("completes full pipeline: creation, disk persistence, worker adapter verification", async () => { + // 1. Create golden snapshot on disk + await service.createOrUpdateSnapshot({ + tenantId: realisticFixture.tenantId, + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + // 2. Mock raw AnalysisResult objects from engine for multiple languages + const mockSolidityResult: AnalysisResult = { + findings: [ + { + ruleId: "gas-opt-uncached-array-length", + message: "Array length should be cached outside for-loop condition to save gas", + severity: Severity.LOW, + location: { + file: "contracts/evm/BridgeVault.sol", + startLine: 45, + endLine: 45, + startColumn: 12, + endColumn: 30, + }, + estimatedGasSavings: 320, + }, + { + ruleId: "sec-reentrancy-eth-transfer", + message: "Potential reentrancy vulnerability via external call before state update", + severity: Severity.HIGH, + location: { + file: "contracts/evm/BridgeVault.sol", + startLine: 112, + endLine: 118, + startColumn: 8, + endColumn: 10, + }, + }, + ], + filesAnalyzed: 2, + analysisTime: 120, + analyzerVersion: "2.1.0", + summary: { + critical: 0, + high: 1, + medium: 0, + low: 1, + info: 0, + }, + totalEstimatedGasSavings: 320, + }; + + const mockSorobanResult: AnalysisResult = { + findings: [ + { + ruleId: "soroban-storage-rent-bump", + message: "Persistent storage entry missing rent bump on active access", + severity: Severity.MEDIUM, + location: { + file: "contracts/soroban/amm_pool/src/lib.rs", + startLine: 78, + endLine: 82, + startColumn: 4, + endColumn: 6, + }, + }, + ], + filesAnalyzed: 4, + analysisTime: 230, + analyzerVersion: "2.1.0", + summary: { + critical: 0, + high: 0, + medium: 1, + low: 0, + info: 0, + }, + }; + + const mockRustResult: AnalysisResult = { + findings: [], + filesAnalyzed: 2, + analysisTime: 85, + analyzerVersion: "2.1.0", + summary: { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + }, + }; + + // 3. Worker processes verification against stored golden snapshot + const verification = await processGoldenSnapshotVerification(service, { + tenantId: realisticFixture.tenantId, + scanId: "scan-worker-e2e-1", + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + analysisResults: { + solidity: mockSolidityResult, + soroban: mockSorobanResult, + rust: mockRustResult, + }, + }); + + expect(verification.status).toBe("matched"); + expect(verification.matched).toBe(true); + expect(verification.totalDifferences).toBe(0); + + // 4. Verify persistence of verification on disk + const savedVerif = await store.getLatestVerification( + realisticFixture.tenantId, + realisticFixture.repositoryId, + ); + expect(savedVerif).toBeDefined(); + expect(savedVerif?.matched).toBe(true); + + // 5. Verify metrics counters + const counters = metrics.getCounters(); + expect(counters.snapshotsCreated).toBe(1); + expect(counters.verificationsMatched).toBe(1); + }); + + it("detects regression with worker and writes failure to store", async () => { + await service.createOrUpdateSnapshot({ + tenantId: realisticFixture.tenantId, + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + const regressedSolidityResult: AnalysisResult = { + findings: [ + { + ruleId: "sec-unexpected-regression-rule", + message: "Spurious regression finding", + severity: Severity.CRITICAL, + location: { + file: "contracts/evm/BridgeVault.sol", + startLine: 1, + endLine: 2, + }, + }, + ], + filesAnalyzed: 2, + analysisTime: 120, + analyzerVersion: "2.1.0", + summary: { + critical: 1, + high: 0, + medium: 0, + low: 0, + info: 0, + }, + }; + + const verification = await processGoldenSnapshotVerification(service, { + tenantId: realisticFixture.tenantId, + scanId: "scan-regressed-1", + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + analysisResults: { + solidity: regressedSolidityResult, + }, + }); + + expect(verification.status).toBe("mismatched"); + expect(verification.matched).toBe(false); + expect(verification.totalDifferences).toBeGreaterThan(0); + + const counters = metrics.getCounters(); + expect(counters.verificationsMismatched).toBe(1); + }); +}); diff --git a/apps/api/src/golden-snapshots/golden-snapshot.service.spec.ts b/apps/api/src/golden-snapshots/golden-snapshot.service.spec.ts new file mode 100644 index 00000000..ba7f3db6 --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot.service.spec.ts @@ -0,0 +1,157 @@ +import { GoldenSnapshotService } from "./golden-snapshot.service"; +import { InMemoryGoldenSnapshotStore } from "./golden-snapshot-store"; +import { GoldenSnapshotMetrics } from "./golden-snapshot-metrics"; +import { GoldenSnapshotError, LanguageGoldenOutput } from "./golden-snapshot-types"; +import * as realisticFixture from "./__fixtures__/realistic-repo-fixture.json"; +import * as regressionFixture from "./__fixtures__/regression-case-fixture.json"; + +describe("GoldenSnapshotService", () => { + let service: GoldenSnapshotService; + let store: InMemoryGoldenSnapshotStore; + let metrics: GoldenSnapshotMetrics; + + beforeEach(() => { + store = new InMemoryGoldenSnapshotStore(); + metrics = new GoldenSnapshotMetrics(); + service = new GoldenSnapshotService( + store, + { + enabled: true, + storageDir: "./data", + enforceMatching: false, + autoUpdate: false, + maxDiffCount: 50, + maxPayloadSizeBytes: 1024 * 1024, + }, + metrics, + ); + }); + + it("creates, retrieves, and verifies golden snapshots", async () => { + const manifest = await service.createOrUpdateSnapshot({ + tenantId: realisticFixture.tenantId, + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + expect(manifest.supportedLanguages).toContain("solidity"); + + const verification = await service.verifyOutputs({ + tenantId: realisticFixture.tenantId, + scanId: "scan-1", + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + actualOutputs: realisticFixture.languageOutputs as Record, + }); + + expect(verification.status).toBe("matched"); + expect(verification.matched).toBe(true); + expect(verification.totalDifferences).toBe(0); + }); + + it("handles missing snapshots gracefully when enforceMatching is false", async () => { + const verification = await service.verifyOutputs({ + tenantId: "nonexistent", + scanId: "scan-2", + repositoryId: "repo-unknown", + commitHash: "hash-unknown", + rulePackVersion: "1.0.0", + actualOutputs: {}, + }); + + expect(verification.status).toBe("missing"); + expect(verification.matched).toBe(false); + }); + + it("throws SNAPSHOT_NOT_FOUND when enforceMatching is true and snapshot is missing", async () => { + const strictService = new GoldenSnapshotService( + store, + { + enabled: true, + storageDir: "./data", + enforceMatching: true, + autoUpdate: false, + maxDiffCount: 50, + maxPayloadSizeBytes: 1024 * 1024, + }, + metrics, + ); + + await expect( + strictService.verifyOutputs({ + tenantId: "nonexistent", + scanId: "scan-3", + repositoryId: "repo-strict", + commitHash: "hash-strict", + rulePackVersion: "1.0.0", + actualOutputs: {}, + }), + ).rejects.toThrow(GoldenSnapshotError); + }); + + it("pins scan attempts idempotently across retries", async () => { + await service.createOrUpdateSnapshot({ + tenantId: realisticFixture.tenantId, + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + languageOutputs: realisticFixture.languageOutputs as Record, + }); + + // First attempt establishes pin + const res1 = await service.verifyOutputs({ + tenantId: realisticFixture.tenantId, + scanId: "scan-retryable", + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + actualOutputs: realisticFixture.languageOutputs as Record, + }); + expect(res1.status).toBe("matched"); + + const pin = await store.getScanPin(realisticFixture.tenantId, "scan-retryable"); + expect(pin).toBeDefined(); + + // Second attempt reuses pin + const res2 = await service.verifyOutputs({ + tenantId: realisticFixture.tenantId, + scanId: "scan-retryable", + repositoryId: realisticFixture.repositoryId, + commitHash: realisticFixture.commitHash, + rulePackVersion: realisticFixture.rulePackVersion, + actualOutputs: realisticFixture.languageOutputs as Record, + }); + expect(res2.status).toBe("matched"); + }); + + it("handles autoUpdate when snapshot is missing", async () => { + const autoService = new GoldenSnapshotService( + store, + { + enabled: true, + storageDir: "./data", + enforceMatching: false, + autoUpdate: true, + maxDiffCount: 50, + maxPayloadSizeBytes: 1024 * 1024, + }, + metrics, + ); + + const res = await autoService.verifyOutputs({ + tenantId: "new-tenant", + scanId: "scan-auto", + repositoryId: "new-repo", + commitHash: "new-commit", + rulePackVersion: "1.0.0", + actualOutputs: realisticFixture.languageOutputs as Record, + }); + + expect(res.status).toBe("matched"); + const created = await store.getSnapshot("new-tenant", "new-repo", "new-commit", "1.0.0"); + expect(created).toBeDefined(); + }); +}); diff --git a/apps/api/src/golden-snapshots/golden-snapshot.service.ts b/apps/api/src/golden-snapshots/golden-snapshot.service.ts new file mode 100644 index 00000000..6ed6b172 --- /dev/null +++ b/apps/api/src/golden-snapshots/golden-snapshot.service.ts @@ -0,0 +1,248 @@ +/** + * Service boundary for Golden Output Snapshots (#1253). + * + * Coordinates snapshot creation, verification against analysis runs, + * tenant isolation, scan pinning, metrics, and persistence. + */ + +import { + GoldenSnapshotConfig, + loadGoldenSnapshotConfig, +} from "./golden-snapshot-config"; +import { + GoldenSnapshotManifest, + LanguageGoldenOutput, + SnapshotVerificationResult, + GoldenSnapshotScanPin, + GoldenSnapshotError, +} from "./golden-snapshot-types"; +import { + GoldenSnapshotStore, + InMemoryGoldenSnapshotStore, +} from "./golden-snapshot-store"; +import { + createSnapshotManifest, + verifySnapshotAgainstOutputs, +} from "./golden-snapshot-evaluator"; +import { + GoldenSnapshotMetrics, + sanitizeLogContext, +} from "./golden-snapshot-metrics"; + +export interface CreateSnapshotRequest { + tenantId: string; + repositoryId: string; + commitHash: string; + rulePackVersion: string; + languageOutputs: Record; + metadata?: Record; +} + +export interface VerifySnapshotRequest { + tenantId: string; + scanId: string; + repositoryId: string; + commitHash: string; + rulePackVersion: string; + actualOutputs: Record; +} + +export class GoldenSnapshotService { + constructor( + private readonly store: GoldenSnapshotStore = new InMemoryGoldenSnapshotStore(), + private readonly config: GoldenSnapshotConfig = loadGoldenSnapshotConfig(), + private readonly metrics: GoldenSnapshotMetrics = new GoldenSnapshotMetrics(), + private readonly logger: { + log: (msg: string, ctx?: Record) => void; + warn: (msg: string, ctx?: Record) => void; + error: (msg: string, ctx?: Record) => void; + } = { + log: (msg, ctx) => console.log(`[GoldenSnapshotService] ${msg}`, ctx ? sanitizeLogContext(ctx) : ""), + warn: (msg, ctx) => console.warn(`[GoldenSnapshotService] ${msg}`, ctx ? sanitizeLogContext(ctx) : ""), + error: (msg, ctx) => console.error(`[GoldenSnapshotService] ${msg}`, ctx ? sanitizeLogContext(ctx) : ""), + }, + ) {} + + getConfig(): GoldenSnapshotConfig { + return this.config; + } + + getMetrics(): GoldenSnapshotMetrics { + return this.metrics; + } + + async createOrUpdateSnapshot( + request: CreateSnapshotRequest, + ): Promise { + if (!this.config.enabled) { + throw new GoldenSnapshotError( + "SNAPSHOT_DISABLED", + "Golden snapshot service is disabled by configuration", + ); + } + + const manifest = createSnapshotManifest({ + tenantId: request.tenantId, + repositoryId: request.repositoryId, + commitHash: request.commitHash, + rulePackVersion: request.rulePackVersion, + languageOutputs: request.languageOutputs, + metadata: request.metadata, + }); + + await this.store.saveSnapshot(manifest); + this.metrics.recordSnapshotCreated(manifest.supportedLanguages.length); + + this.logger.log("Created golden snapshot manifest", { + tenantId: manifest.tenantId, + repositoryId: manifest.repositoryId, + commitHash: manifest.commitHash, + rulePackVersion: manifest.rulePackVersion, + fingerprint: manifest.fingerprint, + languageCount: manifest.supportedLanguages.length, + }); + + return manifest; + } + + async getSnapshot( + tenantId: string, + repositoryId: string, + commitHash: string, + rulePackVersion?: string, + ): Promise { + return this.store.getSnapshot(tenantId, repositoryId, commitHash, rulePackVersion); + } + + async verifyOutputs( + request: VerifySnapshotRequest, + ): Promise { + if (!this.config.enabled) { + return { + status: "matched", + matched: true, + tenantId: request.tenantId, + repositoryId: request.repositoryId, + commitHash: request.commitHash, + rulePackVersion: request.rulePackVersion, + expectedFingerprint: "disabled", + actualFingerprint: "disabled", + languageResults: {}, + totalDifferences: 0, + verifiedAt: Date.now(), + }; + } + + if (!request.scanId || typeof request.scanId !== "string") { + throw new GoldenSnapshotError("INVALID_INPUT", "scanId must be a valid non-empty string"); + } + + // Resolve or establish scan pin for determinism across retries + let pin = await this.store.getScanPin(request.tenantId, request.scanId); + let goldenManifest: GoldenSnapshotManifest | undefined; + + if (pin) { + goldenManifest = await this.store.getSnapshotById(request.tenantId, pin.snapshotId); + if (!goldenManifest) { + goldenManifest = await this.store.getSnapshot( + request.tenantId, + request.repositoryId, + pin.commitHash, + pin.rulePackVersion, + ); + } + } else { + goldenManifest = await this.store.getSnapshot( + request.tenantId, + request.repositoryId, + request.commitHash, + request.rulePackVersion, + ); + + if (goldenManifest) { + pin = { + tenantId: request.tenantId, + scanId: request.scanId, + repositoryId: request.repositoryId, + commitHash: request.commitHash, + rulePackVersion: request.rulePackVersion, + snapshotId: goldenManifest.snapshotId, + pinnedAt: Date.now(), + fingerprint: goldenManifest.fingerprint, + }; + await this.store.saveScanPin(pin); + } + } + + if (!goldenManifest) { + if (this.config.autoUpdate) { + this.logger.warn("Golden snapshot not found, autoUpdate is enabled. Generating snapshot.", { + tenantId: request.tenantId, + repositoryId: request.repositoryId, + commitHash: request.commitHash, + }); + const created = await this.createOrUpdateSnapshot({ + tenantId: request.tenantId, + repositoryId: request.repositoryId, + commitHash: request.commitHash, + rulePackVersion: request.rulePackVersion, + languageOutputs: request.actualOutputs, + }); + return this.verifyOutputs(request); + } + + const missingResult: SnapshotVerificationResult = { + status: "missing", + matched: false, + tenantId: request.tenantId, + repositoryId: request.repositoryId, + commitHash: request.commitHash, + rulePackVersion: request.rulePackVersion, + expectedFingerprint: "", + actualFingerprint: "", + languageResults: {}, + totalDifferences: 1, + verifiedAt: Date.now(), + }; + + this.metrics.recordVerification("missing", {}); + await this.store.saveVerification(missingResult); + + if (this.config.enforceMatching) { + throw new GoldenSnapshotError( + "SNAPSHOT_NOT_FOUND", + `No golden snapshot found for repository '${request.repositoryId}' at commit '${request.commitHash}'`, + ); + } + + return missingResult; + } + + const verification = verifySnapshotAgainstOutputs({ + goldenManifest, + actualOutputs: request.actualOutputs, + maxDiffs: this.config.maxDiffCount, + }); + + await this.store.saveVerification(verification); + this.metrics.recordVerification(verification.status, verification.languageResults); + + this.logger.log("Verified analysis outputs against golden snapshot", { + tenantId: verification.tenantId, + repositoryId: verification.repositoryId, + status: verification.status, + matched: verification.matched, + differences: verification.totalDifferences, + }); + + if (!verification.matched && this.config.enforceMatching) { + throw new GoldenSnapshotError( + "INVALID_INPUT", + `Golden output snapshot verification failed with ${verification.totalDifferences} differences`, + verification, + ); + } + + return verification; + } +} diff --git a/apps/api/src/golden-snapshots/index.ts b/apps/api/src/golden-snapshots/index.ts new file mode 100644 index 00000000..2e06c113 --- /dev/null +++ b/apps/api/src/golden-snapshots/index.ts @@ -0,0 +1,7 @@ +export * from "./golden-snapshot-types"; +export * from "./golden-snapshot-config"; +export * from "./golden-snapshot-evaluator"; +export * from "./golden-snapshot-store"; +export * from "./golden-snapshot-metrics"; +export * from "./golden-snapshot.service"; +export * from "./golden-snapshot-worker"; diff --git a/docs/GOLDEN_OUTPUT_SNAPSHOTS.md b/docs/GOLDEN_OUTPUT_SNAPSHOTS.md new file mode 100644 index 00000000..e20c4265 --- /dev/null +++ b/docs/GOLDEN_OUTPUT_SNAPSHOTS.md @@ -0,0 +1,61 @@ +# Golden Output Snapshots for Supported Languages + +Part of the **Quality and Evaluation** workstream (#1253). + +## Overview + +GasGuard analyzes multiple smart contract and system languages (e.g., Solidity, Soroban/Rust, Vyper, Cairo, TypeScript). As analyzer rule implementations evolve and rule packs update, non-deterministic outputs, unexpected findings, or regressions in finding categorization can compromise repository scan integrity and cause unpredictable CI/CD outcomes. + +The **Golden Output Snapshots** module establishes reproducible, deterministic snapshot evaluation across all supported languages. It captures canonical findings, calculates deterministic fingerprints, validates subsequent repository scan outputs against established golden baselines, and flags regressions or missing findings across language boundaries. + +| Component | Location | +| --- | --- | +| Types and Error Codes | `apps/api/src/golden-snapshots/golden-snapshot-types.ts` | +| Configuration (`GOLDEN_SNAPSHOT_*`) | `apps/api/src/golden-snapshots/golden-snapshot-config.ts` | +| Pure Deterministic Evaluator | `apps/api/src/golden-snapshots/golden-snapshot-evaluator.ts` | +| Persistence (InMemory & File) | `apps/api/src/golden-snapshots/golden-snapshot-store.ts` | +| Core Service Boundary | `apps/api/src/golden-snapshots/golden-snapshot.service.ts` | +| Worker Adapter | `apps/api/src/golden-snapshots/golden-snapshot-worker.ts` | +| Metrics and Sanitized Logs | `apps/api/src/golden-snapshots/golden-snapshot-metrics.ts` | +| Fixtures (Realistic Multi-language & Regression) | `apps/api/src/golden-snapshots/__fixtures__/` | + +--- + +## Architectural Principles & Guarantees + +### 1. Determinism +- **Canonical Sorting**: Findings within each language are sorted by `file -> startLine -> startColumn -> ruleId -> message`. +- **Deterministic Fingerprinting**: Produces a stable, platform-independent hash across all language outputs for a given commit and rule pack version. +- **Scan Pinning**: Write-once scan pins preserve the exact golden snapshot ID across retries so worker re-executions never compare against altered snapshots. + +### 2. Tenant Isolation +- Snapshots, verifications, and pins are strictly partitioned by `tenantId`. +- Both in-memory and disk persistence enforce isolated directory structures (`//...`), preventing data leakages across organizations. + +### 3. Multi-Language Coverage +- Supports comparing multiple language outputs within a single repository scan (e.g., Solidity contracts, Soroban Rust pools, and TypeScript frontend scripts). +- Diffs are tracked per language with detailed categorization (`added`, `removed`, `modified`). + +--- + +## Configuration Reference + +The following environment variables configure the golden snapshot engine: + +| Environment Variable | Default | Purpose | +| --- | --- | --- | +| `GOLDEN_SNAPSHOT_ENABLED` | `true` | Master toggle and kill-switch | +| `GOLDEN_SNAPSHOT_STORAGE_DIR` | `./data/golden-snapshots` | Directory path for filesystem snapshot store | +| `GOLDEN_SNAPSHOT_ENFORCE_MATCHING` | `false` | When `true`, throws errors on snapshot mismatch | +| `GOLDEN_SNAPSHOT_AUTO_UPDATE` | `false` | Automatically creates snapshots if none exist | +| `GOLDEN_SNAPSHOT_MAX_DIFF_COUNT` | `500` | Maximum diffs recorded per language | +| `GOLDEN_SNAPSHOT_MAX_PAYLOAD_SIZE` | `10485760` (10MB) | Maximum snapshot payload size in bytes | + +--- + +## Failure Semantics & Resilience + +- **Kill switch**: Setting `GOLDEN_SNAPSHOT_ENABLED=false` bypasses verification gracefully, returning `status: "matched"` with zero overhead. +- **Missing snapshots**: If no golden snapshot exists for a repository and `autoUpdate` is disabled, returns `status: "missing"` without failing scans unless `GOLDEN_SNAPSHOT_ENFORCE_MATCHING=true`. +- **Store unavailability**: Storage read/write errors are classified as `STORE_UNAVAILABLE`, allowing queue retry policies to schedule backoff without corrupting scan outcomes. +- **Log sanitization**: All log events strip tokens, credentials, private keys, passwords, and sensitive payloads.