diff --git a/DOCS.md b/DOCS.md index 3dd09873..e1d3e140 100644 --- a/DOCS.md +++ b/DOCS.md @@ -335,6 +335,8 @@ vg evidence export [--out ] [--regime ] If a command cannot serialize or write a JSON or JSONL file — permission denied, a missing parent directory, a path that is a directory, a full disk, or a value that cannot be serialized — that is an operational error. The command exits `1`. The message names the file. Check the path and permissions, or choose another `--out` or `--bundle`. +`vg evidence verify` reads the bundle before it reports `verified`, `unverified`, or `failed`. A missing bundle exits `3`. A bundle whose envelope or `result.json` is truncated, is not valid JSON, or is not an evidence document this version can read exits `1`. The message names the envelope or the result and tells you to restore the bundle or re-create it with `vg evidence exposure --bundle`. It does not print the file's contents or a stack trace. A readable bundle that was signed and then edited still reports `failed`. + No language model touches any figure in the evidence path, and every determination carries an evidence-not-compliance disclaimer. Vibgrate Evidence produces evidence to support your obligations under a regime; it does not determine compliance and is not legal advice. --- diff --git a/README.md b/README.md index 2e2bb26f..b4f7b872 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,8 @@ vg evidence release acme-gateway 3.2.1 --image ghcr.io/acme/gateway:3.2.1 --ship | `unverified` | Cryptographically intact and unmodified, but the signer is not pinned — real, and not yet trusted by you | | `failed` | Bad signature, or a `result.json` that no longer matches what was signed | +A truncated, invalid, or unreadable bundle is not one of those states. `vg evidence verify` exits 1 and tells you to restore the bundle or re-create it with `vg evidence exposure --bundle`. The message does not include the file's contents. + Exit codes make it a CI gate: **0** no exposure · **2** exposure found · **3** undetermined, needs manual review · **1** operational error. Evidence state lives in `.vibgrate/evidence/`. The Ed25519 signing key is minted on first use at `.vibgrate/attest-key.pem` (mode `0600`, with a `.pub` beside it) unless you point at your own with `VG_ATTEST_KEY` — back it up, and never commit it. diff --git a/src/reporting/commands/evidence/bundle.ts b/src/reporting/commands/evidence/bundle.ts index 8b7500c6..cdbdacc4 100644 --- a/src/reporting/commands/evidence/bundle.ts +++ b/src/reporting/commands/evidence/bundle.ts @@ -161,6 +161,220 @@ export function verifyEvidenceEnvelope( }; } +const RESULT_SCHEMA = 'evidence-1'; +const RECREATE = 'Restore the bundle, or re-create it with `vg evidence exposure --bundle`.'; + +/** + * An evidence bundle on disk cannot be loaded. + * + * The message is the operator-facing error: what failed, and how to restore + * the bundle. It never includes file contents, parser excerpts, environment + * values, or any other bytes from the artifact (those can carry credentials). + * `code` is {@link ExitCode.ERROR} so `vg evidence verify` exits non-zero. + * This is a read failure, distinct from a signature or digest mismatch on a + * bundle that loaded. + */ +export class EvidenceBundleLoadError extends CliError { + readonly isEvidenceBundleLoadError = true; + readonly kind: 'corrupt' | 'schema'; + + constructor(message: string, kind: 'corrupt' | 'schema') { + super(message, ExitCode.ERROR); + this.name = 'EvidenceBundleLoadError'; + this.kind = kind; + } +} + +export interface LoadedEvidenceBundle { + envelopePath: string; + envelope: DsseEnvelope; + result?: ExposureResult; + /** Present when result.json loaded and timestamp.tsr is on disk. */ + timestampToken?: Buffer; +} + +function corruptJsonMessage(which: 'envelope' | 'result'): string { + const noun = which === 'envelope' ? 'evidence envelope' : 'evidence result'; + return `The ${noun} is truncated or not valid JSON. ${RECREATE}`; +} + +function unreadableMessage(): string { + return `The evidence bundle could not be read. ${RECREATE}`; +} + +function shapeMessage(which: 'envelope' | 'result'): string { + const noun = which === 'envelope' ? 'evidence envelope' : 'evidence result'; + return `The ${noun} is not a readable evidence bundle. ${RECREATE}`; +} + +function schemaMessage(which: 'envelope' | 'result', version: unknown): string { + const echoed = which === 'result' ? echoableResultSchema(version) : echoableAttestation(version); + const noun = which === 'result' ? 'evidence result' : 'evidence envelope'; + const got = echoed ? `schema \`${echoed}\`` : 'a schema this version of vg cannot read'; + const reads = which === 'result' ? `result schema \`${RESULT_SCHEMA}\`` : `predicate \`${EVIDENCE_PREDICATE_TYPE}\``; + return `The ${noun} uses ${got} (this version reads ${reads}). ${RECREATE}`; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** A result schema token we are willing to echo. Anything else stays out of the message. */ +function echoableResultSchema(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 32) return null; + return /^evidence-\d{1,4}$/.test(value) ? value : null; +} + +/** + * An attestation token we are willing to echo: a DSSE payload type or a + * Vibgrate predicate URL. Arbitrary strings stay out of the message. + */ +function echoableAttestation(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 96) return null; + if (/^application\/vnd\.[a-z0-9.+-]{1,40}$/.test(value)) return value; + if (/^https:\/\/vibgrate\.com\/attestation\/[a-z0-9-]{1,48}\/v\d{1,4}$/.test(value)) return value; + if (/^https:\/\/in-toto\.io\/Statement\/v\d{1,4}$/.test(value)) return value; + return null; +} + +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function errnoCode(err: unknown): string { + if (typeof err === 'object' && err !== null && 'code' in err) { + const code = (err as { code?: unknown }).code; + if (typeof code === 'string') return code; + } + return ''; +} + +function readBundleText(file: string): string { + try { + return fs.readFileSync(file, 'utf8'); + } catch (err) { + if (errnoCode(err) === 'ENOENT') { + throw new CliError(`no evidence.intoto.jsonl at ${file}`, ExitCode.NOT_FOUND); + } + throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt'); + } +} + +function readBundleBytes(file: string): Buffer { + try { + return fs.readFileSync(file); + } catch { + throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt'); + } +} + +function parseJsonOrCorrupt(text: string, which: 'envelope' | 'result'): unknown { + const body = stripBom(text).trim(); + if (!body) throw new EvidenceBundleLoadError(corruptJsonMessage(which), 'corrupt'); + try { + return JSON.parse(body); + } catch { + throw new EvidenceBundleLoadError(corruptJsonMessage(which), 'corrupt'); + } +} + +/** First non-empty JSONL record. The envelope file is one JSON object per line. */ +function firstJsonlRecord(text: string): string { + const line = stripBom(text).split('\n').find((entry) => entry.trim().length > 0); + return line?.trim() ?? ''; +} + +const PREDICATE_STRINGS = ['evidenceId', 'regime', 'advisoryId', 'overallStatus', 'resultDigest'] as const; + +function assertEvidenceEnvelope(value: unknown): DsseEnvelope { + if (!isPlainObject(value) || typeof value.payload !== 'string' || !Array.isArray(value.signatures)) { + throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt'); + } + if (value.payloadType !== DSSE_PAYLOAD_TYPE) { + throw new EvidenceBundleLoadError(schemaMessage('envelope', value.payloadType), 'schema'); + } + let statement: unknown; + try { + statement = JSON.parse(Buffer.from(value.payload, 'base64').toString('utf8')); + } catch { + throw new EvidenceBundleLoadError(corruptJsonMessage('envelope'), 'corrupt'); + } + if (!isPlainObject(statement)) { + throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt'); + } + if (statement._type !== IN_TOTO_STATEMENT_TYPE) { + throw new EvidenceBundleLoadError(schemaMessage('envelope', statement._type), 'schema'); + } + if (statement.predicateType !== EVIDENCE_PREDICATE_TYPE) { + throw new EvidenceBundleLoadError(schemaMessage('envelope', statement.predicateType), 'schema'); + } + const predicate = statement.predicate; + if (!isPlainObject(predicate) || !PREDICATE_STRINGS.every((key) => typeof predicate[key] === 'string')) { + throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt'); + } + return value as unknown as DsseEnvelope; +} + +function assertEvidenceResult(value: unknown): ExposureResult { + if (!isPlainObject(value)) throw new EvidenceBundleLoadError(shapeMessage('result'), 'corrupt'); + if (value.schemaVersion !== RESULT_SCHEMA) { + throw new EvidenceBundleLoadError(schemaMessage('result', value.schemaVersion), 'schema'); + } + const advisory = value.advisory; + const meta = value.meta; + const readable = + typeof value.overallStatus === 'string' && + isPlainObject(advisory) && + typeof advisory.id === 'string' && + Array.isArray(value.products) && + isPlainObject(meta) && + typeof meta.evidenceId === 'string'; + if (!readable) throw new EvidenceBundleLoadError(shapeMessage('result'), 'corrupt'); + return value as unknown as ExposureResult; +} + +function resolveEnvelopePath(bundlePath: string): string { + const abs = path.resolve(bundlePath); + let st: fs.Stats; + try { + st = fs.statSync(abs); + } catch (err) { + if (errnoCode(err) === 'ENOENT' || errnoCode(err) === 'ENOTDIR') { + throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND); + } + throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt'); + } + const envPath = st.isDirectory() ? path.join(abs, 'evidence.intoto.jsonl') : abs; + if (!fs.existsSync(envPath)) { + throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND); + } + return envPath; +} + +/** + * Load an evidence bundle for offline verification. + * + * Throws {@link EvidenceBundleLoadError} when the envelope or `result.json` + * is truncated, not JSON, or not a schema this version of vg can read. + * A missing bundle is a not-found {@link CliError}. The message names the + * failure and how to restore the bundle; it does not include the document. + */ +export function loadEvidenceBundle(bundlePath: string): LoadedEvidenceBundle { + const envelopePath = resolveEnvelopePath(bundlePath); + const envelope = assertEvidenceEnvelope( + parseJsonOrCorrupt(firstJsonlRecord(readBundleText(envelopePath)), 'envelope'), + ); + const resultPath = path.join(path.dirname(envelopePath), 'result.json'); + let result: ExposureResult | undefined; + if (fs.existsSync(resultPath)) { + result = assertEvidenceResult(parseJsonOrCorrupt(readBundleText(resultPath), 'result')); + } + let timestampToken: Buffer | undefined; + const tsrPath = path.join(path.dirname(envelopePath), 'timestamp.tsr'); + if (result && fs.existsSync(tsrPath)) timestampToken = readBundleBytes(tsrPath); + return { envelopePath, envelope, result, timestampToken }; +} + const DEFAULT_KEY = 'attest-key.pem'; /** @@ -282,6 +496,8 @@ function verifyDoc(input: WriteBundleInput): string { '- `unverified` — cryptographically intact but the signer is not pinned, or the', ' bundle is unsigned. We never fabricate a pass.', '- `failed` — the signature is bad or `result.json` no longer matches its digest.', + '- A truncated, invalid, or unreadable bundle is an error, not `failed`.', + ' Restore the original files, or re-create the bundle.', '', input.timestampToken ? `Timestamp: an RFC 3161 token is included as \`timestamp.tsr\` (trusted time\n${input.result.meta.timestamp.value}). Verify it fully with your TSA's CA:\n\`\`\`\nopenssl ts -verify -in timestamp.tsr -data result.json -CAfile \n\`\`\`\n\`vg evidence verify\` confirms the token's imprint binds to result.json and\nsurfaces the trusted time; it does not re-verify the TSA signature chain.` diff --git a/src/reporting/commands/evidence/index.ts b/src/reporting/commands/evidence/index.ts index 1c3b54ad..a247412e 100644 --- a/src/reporting/commands/evidence/index.ts +++ b/src/reporting/commands/evidence/index.ts @@ -21,7 +21,7 @@ import { resolveAdvisory } from './advisory.js'; import { buildRelease } from './release.js'; import { buildPushPayload, describePushResult, type PushAttestation, type PushResponse } from './push-payload.js'; import { buildPack } from './pack.js'; -import { buildEvidenceStatement, signEvidenceStatement, verifyEvidenceEnvelope, resolveSigningKey, writeBundle } from './bundle.js'; +import { buildEvidenceStatement, signEvidenceStatement, verifyEvidenceEnvelope, resolveSigningKey, writeBundle, loadEvidenceBundle } from './bundle.js'; import { synthesizeAdvisory, undeterminedFields, recordDrill, hasRecentDrill } from './drill.js'; import { formatExposure, formatReadiness, formatRegimeList } from './format.js'; import { resolveDsn } from '../../credentials.js'; @@ -385,28 +385,30 @@ const drillCmd = new Command('drill') } }); +function readPinnedPublicKey(file: string): string { + try { + return fs.readFileSync(path.resolve(file), 'utf8'); + } catch { + throw new CliError(`could not read the public key at ${file}. Pass --pub with a PEM file.`, ExitCode.USAGE_ERROR); + } +} + // ── verify ── const verifyCmd = new Command('verify') .description('Verify an evidence bundle offline (no Vibgrate needed)') .argument('', 'Path to an evidence bundle directory or evidence.intoto.jsonl') .option('--pub ', 'Public key PEM to pin the signer (establish trust)') .action((bundlePath: string, opts) => { - const abs = path.resolve(bundlePath); - const envPath = fs.statSync(abs).isDirectory() ? path.join(abs, 'evidence.intoto.jsonl') : abs; - if (!fs.existsSync(envPath)) throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND); - const envelope = JSON.parse(fs.readFileSync(envPath, 'utf8').trim().split('\n')[0]) as DsseEnvelope; - const resultPath = path.join(path.dirname(envPath), 'result.json'); - const result = fs.existsSync(resultPath) ? (JSON.parse(fs.readFileSync(resultPath, 'utf8')) as ExposureResult) : undefined; - const publicKeyPem = opts.pub ? fs.readFileSync(path.resolve(opts.pub as string), 'utf8') : undefined; - const v = verifyEvidenceEnvelope(envelope, { publicKeyPem, result }); + const loaded = loadEvidenceBundle(bundlePath); + const publicKeyPem = opts.pub ? readPinnedPublicKey(opts.pub as string) : undefined; + const v = verifyEvidenceEnvelope(loaded.envelope, { publicKeyPem, result: loaded.result }); const color = v.status === 'verified' ? chalk.green : v.status === 'failed' ? chalk.red : chalk.yellow; console.log(' ' + color(v.status.toUpperCase()) + ` ${v.reason}`); if (v.evidenceId) console.log(' ' + chalk.dim(`evidence ${v.evidenceId} · regime ${v.regime} · advisory ${v.advisoryId} · ${v.overallStatus}`)); // RFC 3161 timestamp, when the bundle carries one. - const tsrPath = path.join(path.dirname(envPath), 'timestamp.tsr'); - if (fs.existsSync(tsrPath) && result) { - const t = verifyTimestamp(fs.readFileSync(tsrPath), exposureSubjectDigest(result)); + if (loaded.timestampToken && loaded.result) { + const t = verifyTimestamp(loaded.timestampToken, exposureSubjectDigest(loaded.result)); const tcolor = t.imprintMatches ? chalk.green : chalk.red; console.log(' ' + tcolor('TIMESTAMP') + ` ${t.reason}`); } diff --git a/test/evidence-verify-load.test.ts b/test/evidence-verify-load.test.ts new file mode 100644 index 00000000..759b595a --- /dev/null +++ b/test/evidence-verify-load.test.ts @@ -0,0 +1,286 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { main } from '../src/cli.js'; +import { DSSE_PAYLOAD_TYPE } from '../src/engine/attest.js'; +import { + EvidenceBundleLoadError, + buildEvidenceStatement, + loadEvidenceBundle, + signEvidenceStatement, + verifyEvidenceEnvelope, +} from '../src/reporting/commands/evidence/bundle.js'; +import type { ExposureResult } from '../src/reporting/commands/evidence/types.js'; +import { CliError, ExitCode } from '../src/util/exit.js'; + +/** + * A truncated, invalid, or schema-broken evidence bundle must fail as an + * operator error: what went wrong, how to restore it, non-zero exit, and + * none of the file's bytes. + */ + +// Distinct file bytes. A token-shaped value here trips the secret scan. +const SECRET = 'placeholder-file-contents-must-not-leak'; +const RESTORE = 'Restore the bundle, or re-create it with `vg evidence exposure --bundle`'; +const EVIDENCE_PREDICATE = 'https://vibgrate.com/attestation/regulatory-evidence/v1'; + +const dirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + delete process.env.VG_EVIDENCE_VERIFY_TEST; + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-evidence-verify-')); + dirs.push(dir); + return dir; +} + +function resultFixture(): ExposureResult { + return { + schemaVersion: 'evidence-1', + regime: 'cra', + advisory: { id: 'CVE-2026-1000', sourceProvenance: 'local', kevListed: false }, + overallStatus: 'not-affected', + products: [], + meta: { + evidenceId: 'ev_fixture', + dataPackVersion: 'none', + kernelVersion: 'test', + timestamp: { source: 'local-clock', value: '2026-01-01T00:00:00.000Z' }, + }, + }; +} + +function signedBundle(dir: string, result: ExposureResult = resultFixture()): { pubPem: string } { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); + const pubPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const envelope = signEvidenceStatement(buildEvidenceStatement(result, '1.0.0'), privateKey); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'evidence.intoto.jsonl'), `${JSON.stringify(envelope)}\n`); + fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`); + return { pubPem }; +} + +function writeEnvelope(dir: string, body: string, resultBody?: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'evidence.intoto.jsonl'), body); + if (resultBody !== undefined) fs.writeFileSync(path.join(dir, 'result.json'), resultBody); +} + +function assertSafe(err: EvidenceBundleLoadError): void { + expect(err).toBeInstanceOf(EvidenceBundleLoadError); + expect(err).toBeInstanceOf(CliError); + expect(err.name).toBe('EvidenceBundleLoadError'); + expect(err.code).toBe(ExitCode.ERROR); + expect(err.code).not.toBe(0); + expect(err.message).toContain(RESTORE); + expect(err.message).not.toContain(SECRET); + expect(err.message).not.toContain('ghp_'); + expect(err.message).not.toContain('Unexpected token'); + expect(err.message).not.toContain('Unexpected end'); + expect(err.message).not.toContain('SyntaxError'); + expect(err.message).not.toMatch(/\n/); + expect(process.env.VG_EVIDENCE_VERIFY_TEST).toBe(SECRET); + expect(err.message).not.toContain('VG_EVIDENCE_VERIFY_TEST'); +} + +function catchLoad(bundlePath: string): EvidenceBundleLoadError { + process.env.VG_EVIDENCE_VERIFY_TEST = SECRET; + let caught: unknown; + try { + loadEvidenceBundle(bundlePath); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(EvidenceBundleLoadError); + const err = caught as EvidenceBundleLoadError; + assertSafe(err); + return err; +} + +function envelopeShell(statement: unknown): string { + return `${JSON.stringify({ + payloadType: DSSE_PAYLOAD_TYPE, + payload: Buffer.from(JSON.stringify(statement), 'utf8').toString('base64'), + signatures: [], + })}\n`; +} + +describe('loadEvidenceBundle', () => { + it('loads a signed bundle and verifies it when the signer is pinned', () => { + const dir = tempDir(); + const { pubPem } = signedBundle(dir); + const loaded = loadEvidenceBundle(dir); + expect(loaded.result?.schemaVersion).toBe('evidence-1'); + expect(verifyEvidenceEnvelope(loaded.envelope, { publicKeyPem: pubPem, result: loaded.result }).status).toBe('verified'); + }); + + it('still reports failed when a readable result no longer matches the signed digest', () => { + const dir = tempDir(); + const result = resultFixture(); + const { pubPem } = signedBundle(dir, result); + const tampered: ExposureResult = { ...result, overallStatus: 'affected' }; + fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify(tampered, null, 2)}\n`); + const loaded = loadEvidenceBundle(dir); + expect(verifyEvidenceEnvelope(loaded.envelope, { publicKeyPem: pubPem, result: loaded.result }).status).toBe('failed'); + }); + + it('rejects a truncated envelope without echoing file contents', () => { + const dir = tempDir(); + signedBundle(dir); + const file = path.join(dir, 'evidence.intoto.jsonl'); + const full = fs.readFileSync(file, 'utf8'); + fs.writeFileSync(file, `${full.slice(0, Math.max(8, Math.floor(full.length / 2)))}${SECRET}`); + const err = catchLoad(dir); + expect(err.kind).toBe('corrupt'); + expect(err.message).toMatch(/evidence envelope is truncated or not valid JSON/); + }); + + it('rejects invalid JSON without echoing file contents', () => { + const dir = tempDir(); + writeEnvelope(dir, `not-json ${SECRET}\n`); + const err = catchLoad(path.join(dir, 'evidence.intoto.jsonl')); + expect(err.kind).toBe('corrupt'); + expect(err.message).toMatch(/evidence envelope is truncated or not valid JSON/); + }); + + it('rejects a schema this vg cannot read without echoing the document', () => { + const dir = tempDir(); + writeEnvelope(dir, envelopeShell({ + _type: 'https://in-toto.io/Statement/v1', + predicateType: 'https://vibgrate.com/attestation/code-graph/v1', + predicate: { note: SECRET }, + leaked: SECRET, + })); + const err = catchLoad(dir); + expect(err.kind).toBe('schema'); + expect(err.message).toContain('https://vibgrate.com/attestation/code-graph/v1'); + expect(err.message).toContain(EVIDENCE_PREDICATE); + }); + + it('does not echo a predicate type that is not an attestation token', () => { + const dir = tempDir(); + writeEnvelope(dir, envelopeShell({ + _type: 'https://in-toto.io/Statement/v1', + predicateType: SECRET, + predicate: { note: SECRET }, + })); + const err = catchLoad(dir); + expect(err.kind).toBe('schema'); + expect(err.message).toMatch(/cannot read/); + expect(err.message).not.toContain('schema `'); + }); + + it('rejects a truncated result.json beside a readable envelope', () => { + const dir = tempDir(); + signedBundle(dir); + const resultPath = path.join(dir, 'result.json'); + const full = fs.readFileSync(resultPath, 'utf8'); + fs.writeFileSync(resultPath, `${full.slice(0, Math.floor(full.length / 2))}${SECRET}`); + const err = catchLoad(dir); + expect(err.kind).toBe('corrupt'); + expect(err.message).toMatch(/evidence result is truncated or not valid JSON/); + }); + + it('rejects a result schema this vg cannot read', () => { + const dir = tempDir(); + signedBundle(dir); + fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify({ + schemaVersion: 'evidence-0', + note: SECRET, + overallStatus: 'affected', + })}\n`); + const err = catchLoad(dir); + expect(err.kind).toBe('schema'); + expect(err.message).toContain('schema `evidence-0`'); + expect(err.message).toContain('evidence-1'); + }); + + it('reports a missing bundle as not found, not a load failure', () => { + const dir = tempDir(); + const missing = path.join(dir, 'absent'); + expect(() => loadEvidenceBundle(missing)).toThrow(CliError); + try { + loadEvidenceBundle(missing); + } catch (err) { + expect(err).toBeInstanceOf(CliError); + expect(err).not.toBeInstanceOf(EvidenceBundleLoadError); + expect((err as CliError).code).toBe(ExitCode.NOT_FOUND); + expect((err as CliError).message).toMatch(/no evidence\.intoto\.jsonl/); + expect((err as CliError).message).not.toContain('ENOENT'); + } + }); +}); + +describe('vg evidence verify', () => { + async function runVerify(args: string[]): Promise<{ exits: number[]; stderr: string; stdout: string }> { + const exits: number[] = []; + const errSpy = vi.spyOn(process.stderr, 'write'); + const logSpy = vi.spyOn(console, 'log'); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exits.push(code ?? -1); + return undefined as never; + }) as typeof process.exit); + const strip = (value: string): string => value.replace(/\u001b\[[0-9;]*m/g, ''); + + await main(['node', 'vg', 'evidence', 'verify', ...args]); + await new Promise((resolve) => setImmediate(resolve)); + const stderr = strip(errSpy.mock.calls.map((call) => String(call[0])).join('')); + const stdout = strip(logSpy.mock.calls.map((call) => call.map((part) => String(part)).join(' ')).join('\n')); + return { exits, stderr, stdout }; + } + + it('exits non-zero from the CLI entry on a truncated bundle, without a stack', async () => { + const dir = tempDir(); + writeEnvelope(dir, `{"payload":"${SECRET}"`); + const { exits, stderr } = await runVerify([dir]); + expect(exits).toEqual([ExitCode.ERROR]); + expect(stderr).toContain('error:'); + expect(stderr).toContain(RESTORE); + expect(stderr).toContain('truncated or not valid JSON'); + expect(stderr).not.toContain(SECRET); + expect(stderr).not.toContain('Unexpected token'); + expect(stderr).not.toContain('SyntaxError'); + expect(stderr).not.toContain('re-run with --json'); + expect(stderr).not.toMatch(/\n\s+at /); + }); + + it('exits non-zero on invalid JSON and on a schema mismatch', async () => { + const dir = tempDir(); + writeEnvelope(dir, `not-json ${SECRET}\n`); + const invalid = await runVerify([path.join(dir, 'evidence.intoto.jsonl')]); + expect(invalid.exits).toEqual([ExitCode.ERROR]); + expect(invalid.stderr).toContain('truncated or not valid JSON'); + expect(invalid.stderr).not.toContain(SECRET); + expect(invalid.stderr).not.toMatch(/\n\s+at /); + + const schemaDir = tempDir(); + writeEnvelope(schemaDir, envelopeShell({ + _type: 'https://in-toto.io/Statement/v9', + leaked: SECRET, + })); + const schema = await runVerify([schemaDir]); + expect(schema.exits).toEqual([ExitCode.ERROR]); + expect(schema.stderr).toContain('schema `https://in-toto.io/Statement/v9`'); + expect(schema.stderr).toContain(RESTORE); + expect(schema.stderr).not.toContain(SECRET); + expect(schema.stderr).not.toContain('SyntaxError'); + expect(schema.stderr).not.toMatch(/\n\s+at /); + }); + + it('still prints an honest unverified state for a readable bundle', async () => { + const dir = tempDir(); + signedBundle(dir); + const { exits, stdout, stderr } = await runVerify([dir]); + expect(exits).toEqual([]); + expect(stdout).toMatch(/UNVERIFIED/); + expect(stderr).not.toContain(SECRET); + expect(process.exitCode).toBe(ExitCode.GATE_FAILED); + }); +});