From 89dcfa8b620b1cc171103c9278180c399996a5fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 9 Oct 2026 22:02:00 +0000 Subject: [PATCH 1/2] fix(evidence): actionable error when verify reads a corrupt bundle Truncated, invalid JSON, and schema-broken evidence bundles now fail vg evidence verify with a stable operator error and a non-zero exit. The message names the failure and does not include bundle contents. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 7 + DOCS.md | 2 + README.md | 2 + .../commands/evidence/bundle-load.test.ts | 280 ++++++++++++++++++ src/reporting/commands/evidence/bundle.ts | 182 ++++++++++++ src/reporting/commands/evidence/index.ts | 17 +- 6 files changed, 479 insertions(+), 11 deletions(-) create mode 100644 src/reporting/commands/evidence/bundle-load.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 887e092..738634c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ backward compatible. ### Fixed +- **`vg evidence verify` names a truncated or unreadable bundle.** A bundle + that is truncated, not valid JSON, or not a schema this version can read + exits `1`. The message names the failure — the evidence envelope or the + evidence result — and says to restore the bundle or write a new one with + `vg evidence exposure --bundle `. It does not include the bundle + contents. A failure to write a bundle stays a separate error. (#287) + - **SBOM component order no longer follows scan or filesystem order.** `vg sbom export` and CycloneDX/SPDX graph export sort a component by its Package URL when one is written, otherwise by package name, then by version. diff --git a/DOCS.md b/DOCS.md index b6b753c..c20065e 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 checks a signature. A bundle that is truncated, not valid JSON, or not a schema this version can read is also an operational error and exits `1`. The message names the failure — the evidence envelope or the evidence result — and says to restore the original bundle or write a new one with `vg evidence exposure --bundle `. It does not include the bundle contents. That read error is separate from a failure to write a bundle. + 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 2e2bb26..ba8cf59 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 bundle that is truncated, not valid JSON, or not a schema this version of vg can read does not get a trust state. `vg evidence verify` exits 1 and names the failure. Restore the bundle, or write a new one with `vg evidence exposure --bundle `. The message does not include the bundle. + 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-load.test.ts b/src/reporting/commands/evidence/bundle-load.test.ts new file mode 100644 index 0000000..55806ab --- /dev/null +++ b/src/reporting/commands/evidence/bundle-load.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DSSE_PAYLOAD_TYPE } from '../../../engine/attest.js'; +import { CliError, ExitCode } from '../../../util/exit.js'; +import { EvidenceBundleLoadError, loadEvidenceBundle } from './bundle.js'; + +/** + * `vg evidence verify` must fail closed on a truncated or schema-broken + * bundle: a stable operator error, a non-zero exit, and none of the file + * bytes or environment. That read error is not a serialize/write failure. + */ + +const FILE_SECRET = 'ghp_EvidenceFileMustNotLeak'; +const ENV_SECRET = 'npm_EvidenceEnvMustNotLeak'; +const RESTORE = 'Restore the original bundle, or write a new one with `vg evidence exposure --bundle `.'; +const WRITE_FAILURE = /cannot write|output could not be serialized|choose another --/; + +const dirs: string[] = []; + +afterEach(() => { + delete process.env.VG_EVIDENCE_SECRET; + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function temp(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-evidence-load-')); + dirs.push(dir); + return dir; +} + +function envelope(over: Record = {}): Record { + return { + payloadType: DSSE_PAYLOAD_TYPE, + payload: Buffer.from('{}', 'utf8').toString('base64'), + signatures: [{ keyid: 'abc', sig: 'AA==' }], + ...over, + }; +} + +function result(over: Record = {}): Record { + return { + schemaVersion: 'evidence-1', + regime: 'cra', + advisory: { id: 'CVE-2026-0001', sourceProvenance: 'test', kevListed: false }, + overallStatus: 'not-affected', + products: [], + meta: { + evidenceId: 'ev-test', + dataPackVersion: 'none', + kernelVersion: 'evidence-1', + timestamp: { source: 'local-clock', value: '2026-01-01T00:00:00.000Z' }, + }, + ...over, + }; +} + +function writeEnvelope(dir: string, body: string): string { + const file = path.join(dir, 'evidence.intoto.jsonl'); + fs.writeFileSync(file, body); + return file; +} + +function assertLoadError(err: unknown, kind: 'corrupt' | 'schema', message: string): EvidenceBundleLoadError { + expect(err).toBeInstanceOf(EvidenceBundleLoadError); + const load = err as EvidenceBundleLoadError; + expect(load).toBeInstanceOf(CliError); + expect(load.name).toBe('EvidenceBundleLoadError'); + expect(load.kind).toBe(kind); + expect(load.code).toBe(ExitCode.ERROR); + expect(load.code).not.toBe(0); + expect(load.message).toBe(message); + expect(load.message).toContain(RESTORE); + expect(load.message).not.toContain(FILE_SECRET); + expect(load.message).not.toContain(ENV_SECRET); + expect(load.message).not.toContain('ghp_'); + expect(load.message).not.toContain('npm_'); + expect(load.message).not.toContain('Unexpected token'); + expect(load.message).not.toContain('Unexpected end'); + expect(load.message).not.toMatch(WRITE_FAILURE); + expect(load.message).not.toMatch(/\n\s*at /); + expect(load.stack ?? '').not.toContain(FILE_SECRET); + return load; +} + +function catchLoad(bundlePath: string): unknown { + process.env.VG_EVIDENCE_SECRET = ENV_SECRET; + try { + loadEvidenceBundle(bundlePath); + return undefined; + } catch (err) { + return err; + } +} + +describe('loadEvidenceBundle', () => { + it('loads a readable envelope and a pretty-printed result', () => { + const dir = temp(); + const env = envelope(); + writeEnvelope(dir, `${JSON.stringify(env)}\n`); + fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify(result(), null, 2)}\n`); + const loaded = loadEvidenceBundle(dir); + expect(loaded.envelope).toEqual(env); + expect(loaded.result).toMatchObject({ schemaVersion: 'evidence-1', meta: { evidenceId: 'ev-test' } }); + expect(loaded.envelopePath).toBe(path.join(dir, 'evidence.intoto.jsonl')); + }); + + it('rejects a truncated envelope without echoing file or env contents', () => { + const dir = temp(); + const file = writeEnvelope( + dir, + `{"payloadType":"${DSSE_PAYLOAD_TYPE}","note":"${FILE_SECRET}","payload":"`, + ); + const again = catchLoad(dir); + const first = catchLoad(file); + expect(assertLoadError( + first, + 'corrupt', + `The evidence envelope is truncated or not valid JSON. ${RESTORE}`, + ).message).toBe((again as EvidenceBundleLoadError).message); + }); + + it('rejects invalid JSON that is not a truncated prefix', () => { + const dir = temp(); + writeEnvelope(dir, `not-json ${FILE_SECRET}\n`); + assertLoadError( + catchLoad(dir), + 'corrupt', + `The evidence envelope is truncated or not valid JSON. ${RESTORE}`, + ); + }); + + it('rejects a truncated result.json beside a readable envelope', () => { + const dir = temp(); + writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); + fs.writeFileSync(path.join(dir, 'result.json'), `{"schemaVersion":"evidence-1","note":"${FILE_SECRET}"`); + assertLoadError( + catchLoad(dir), + 'corrupt', + `The evidence result is truncated or not valid JSON. ${RESTORE}`, + ); + }); + + it('rejects a schema this version cannot read without echoing the token', () => { + const dir = temp(); + writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); + fs.writeFileSync( + path.join(dir, 'result.json'), + JSON.stringify(result({ schemaVersion: FILE_SECRET, note: FILE_SECRET })), + ); + assertLoadError( + catchLoad(dir), + 'schema', + `The evidence result uses a schema this version of vg cannot read (this version reads evidence-1). ${RESTORE}`, + ); + }); + + it('names an evidence schema version that is safe to echo', () => { + const dir = temp(); + writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); + fs.writeFileSync(path.join(dir, 'result.json'), JSON.stringify(result({ schemaVersion: 'evidence-2' }))); + assertLoadError( + catchLoad(dir), + 'schema', + `The evidence result uses schema \`evidence-2\` (this version reads evidence-1). ${RESTORE}`, + ); + }); + + it('rejects JSON that is not a DSSE envelope', () => { + const dir = temp(); + writeEnvelope(dir, `${JSON.stringify({ schemaVersion: 'evidence-1', note: FILE_SECRET })}\n`); + assertLoadError( + catchLoad(dir), + 'schema', + `The evidence envelope is not a readable DSSE envelope. ${RESTORE}`, + ); + }); + + it('rejects an evidence-1 document that is missing the result shape', () => { + const dir = temp(); + writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); + fs.writeFileSync(path.join(dir, 'result.json'), `{"schemaVersion":"evidence-1","note":"${FILE_SECRET}"}`); + assertLoadError( + catchLoad(dir), + 'schema', + `The evidence result is not a readable evidence result. ${RESTORE}`, + ); + }); + + it('reports a missing bundle as not found, without a read error', () => { + const dir = temp(); + const missing = path.join(dir, 'missing-bundle'); + let caught: unknown; + try { + loadEvidenceBundle(missing); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(CliError); + expect(caught).not.toBeInstanceOf(EvidenceBundleLoadError); + const err = caught as CliError; + expect(err.code).toBe(ExitCode.NOT_FOUND); + expect(err.message).toBe(`no evidence.intoto.jsonl at ${missing}`); + expect(err.message).not.toContain('ENOENT'); + expect(err.message).not.toMatch(WRITE_FAILURE); + }); +}); + +describe('vg evidence verify CLI', () => { + const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + const cli = path.join(pkgRoot, 'src/cli.ts'); + + function run(bundlePath: string): { status: number | null; signal: NodeJS.Signals | null; stderr: string; stdout: string; error?: Error } { + const res = spawnSync(process.execPath, ['--import', 'tsx', cli, 'evidence', 'verify', bundlePath], { + cwd: pkgRoot, + encoding: 'utf8', + timeout: 60_000, + env: { + ...process.env, + NO_COLOR: '1', + VIBGRATE_NO_KERNEL: '1', + VIBGRATE_DSN: '', + VG_EVIDENCE_SECRET: ENV_SECRET, + }, + }); + return { + status: res.status, + signal: res.signal, + stderr: res.stderr ?? '', + stdout: res.stdout ?? '', + error: res.error, + }; + } + + function assertCli(bundlePath: string, message: string): void { + const res = run(bundlePath); + expect(res.error).toBeUndefined(); + expect(res.signal).toBeNull(); + expect(res.status).toBe(ExitCode.ERROR); + expect(res.stderr).toContain(`error: ${message}`); + expect(res.stderr).not.toContain(FILE_SECRET); + expect(res.stderr).not.toContain(ENV_SECRET); + expect(res.stderr).not.toContain('Unexpected token'); + expect(res.stderr).not.toContain('Unexpected end'); + expect(res.stderr).not.toContain('SyntaxError'); + expect(res.stderr).not.toMatch(/\bat (?:JSON\.parse|readFile|loadEvidenceBundle)/); + expect(res.stderr).not.toMatch(WRITE_FAILURE); + expect(res.stdout).not.toContain(FILE_SECRET); + expect(res.stdout).not.toContain(ENV_SECRET); + expect(res.stdout).not.toMatch(/\b(VERIFIED|UNVERIFIED|FAILED)\b/); + } + + it('exits non-zero on a truncated bundle and does not print a stack', () => { + const dir = temp(); + writeEnvelope(dir, `{"note":"${FILE_SECRET}","payload":`); + assertCli(dir, `The evidence envelope is truncated or not valid JSON. ${RESTORE}`); + }, 60_000); + + it('exits non-zero on invalid JSON', () => { + const dir = temp(); + writeEnvelope(dir, `{not json, "token":"${FILE_SECRET}"}`); + assertCli(dir, `The evidence envelope is truncated or not valid JSON. ${RESTORE}`); + }, 60_000); + + it('exits non-zero on a schema this version cannot read', () => { + const dir = temp(); + writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); + fs.writeFileSync( + path.join(dir, 'result.json'), + JSON.stringify(result({ schemaVersion: 'evidence-2', leak: FILE_SECRET })), + ); + assertCli( + dir, + `The evidence result uses schema \`evidence-2\` (this version reads evidence-1). ${RESTORE}`, + ); + }, 60_000); +}); diff --git a/src/reporting/commands/evidence/bundle.ts b/src/reporting/commands/evidence/bundle.ts index 8b7500c..2032577 100644 --- a/src/reporting/commands/evidence/bundle.ts +++ b/src/reporting/commands/evidence/bundle.ts @@ -161,6 +161,188 @@ export function verifyEvidenceEnvelope( }; } +const EVIDENCE_RESULT_SCHEMA = 'evidence-1'; +const RESTORE_BUNDLE = + 'Restore the original bundle, or write a new one with `vg evidence exposure --bundle `.'; + +/** + * An evidence bundle on disk cannot be loaded for `vg evidence verify`. + * + * This is a read failure: the file is truncated, not JSON, or not a schema + * this version can read. It is not a failure to serialize or write a bundle. + * The message names what failed and how to restore the bundle. It never + * includes file contents, environment values, or other secret material. + * `code` is {@link ExitCode.ERROR} so the command exits non-zero. + */ +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 { + /** Absolute path of the DSSE envelope that was read. */ + envelopePath: string; + envelope: DsseEnvelope; + /** Present when a sibling result.json was read and accepted. */ + result?: ExposureResult; +} + +/** + * Load the envelope (and result.json, when it sits beside the envelope) for + * `vg evidence verify`. + * + * `bundlePath` may be a bundle directory or the envelope file itself. A + * missing bundle is {@link CliError} with {@link ExitCode.NOT_FOUND}. A + * present file that cannot be read, is truncated or not JSON, or is not a + * schema this version can read throws {@link EvidenceBundleLoadError}. + * The same bytes always produce the same error. + */ +export function loadEvidenceBundle(bundlePath: string): LoadedEvidenceBundle { + const abs = path.resolve(bundlePath); + let stat: fs.Stats; + try { + stat = fs.statSync(abs); + } catch (err) { + if (nodeCode(err) === 'ENOENT') { + throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND); + } + throw new EvidenceBundleLoadError(unreadableMessage('bundle'), 'corrupt'); + } + + const envelopePath = stat.isDirectory() ? path.join(abs, 'evidence.intoto.jsonl') : abs; + if (!fs.existsSync(envelopePath)) { + throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND); + } + + const envelope = assertEnvelope(parseBundleJson(readUtf8(envelopePath, 'envelope'), 'envelope')); + const resultPath = path.join(path.dirname(envelopePath), 'result.json'); + const result = fs.existsSync(resultPath) + ? assertResult(parseBundleJson(readUtf8(resultPath, 'result'), 'result')) + : undefined; + return { envelopePath, envelope, result }; +} + +function nodeCode(cause: unknown): string { + if (typeof cause === 'object' && cause !== null && 'code' in cause) { + const code = (cause as { code?: unknown }).code; + if (typeof code === 'string') return code; + } + return ''; +} + +function readUtf8(file: string, which: 'envelope' | 'result'): string { + try { + return fs.readFileSync(file, 'utf8'); + } catch { + throw new EvidenceBundleLoadError(unreadableMessage(which), 'corrupt'); + } +} + +/** Envelope files are JSONL: only the first record is the DSSE document. */ +function firstJsonLine(text: string): string { + const trimmed = text.trim(); + const newline = trimmed.indexOf('\n'); + const line = newline === -1 ? trimmed : trimmed.slice(0, newline); + return line.trim(); +} + +function parseBundleJson(text: string, which: 'envelope' | 'result'): unknown { + const body = which === 'envelope' ? firstJsonLine(text) : text.trim(); + if (!body) throw new EvidenceBundleLoadError(corruptMessage(which), 'corrupt'); + try { + return JSON.parse(body); + } catch { + throw new EvidenceBundleLoadError(corruptMessage(which), 'corrupt'); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** A schema token we are willing to echo. Anything else stays out of the message. */ +function echoableEvidenceSchema(value: unknown): string | null { + if (typeof value !== 'string' || value.length > 16) return null; + return /^evidence-\d{1,4}$/.test(value) ? value : null; +} + +function assertEnvelope(value: unknown): DsseEnvelope { + if ( + !isPlainObject(value) || + value.payloadType !== DSSE_PAYLOAD_TYPE || + typeof value.payload !== 'string' || + value.payload.length === 0 || + !isSignatureList(value.signatures) + ) { + throw new EvidenceBundleLoadError(envelopeShapeMessage(), 'schema'); + } + return value as unknown as DsseEnvelope; +} + +function isSignatureList(value: unknown): boolean { + if (!Array.isArray(value)) return false; + for (const sig of value) { + if (!isPlainObject(sig)) return false; + if (typeof sig.keyid !== 'string' || typeof sig.sig !== 'string') return false; + if (sig.publicKey !== undefined && typeof sig.publicKey !== 'string') return false; + } + return true; +} + +function assertResult(value: unknown): ExposureResult { + if (!isPlainObject(value)) { + throw new EvidenceBundleLoadError(resultShapeMessage(), 'schema'); + } + if (value.schemaVersion !== EVIDENCE_RESULT_SCHEMA) { + throw new EvidenceBundleLoadError(resultSchemaMessage(value.schemaVersion), 'schema'); + } + if (!isReadableResult(value)) { + throw new EvidenceBundleLoadError(resultShapeMessage(), 'schema'); + } + return value as unknown as ExposureResult; +} + +function isReadableResult(value: Record): boolean { + return ( + typeof value.regime === 'string' && + isPlainObject(value.advisory) && + typeof value.overallStatus === 'string' && + Array.isArray(value.products) && + isPlainObject(value.meta) + ); +} + +function corruptMessage(which: 'envelope' | 'result'): string { + const subject = which === 'envelope' ? 'evidence envelope' : 'evidence result'; + return `The ${subject} is truncated or not valid JSON. ${RESTORE_BUNDLE}`; +} + +function unreadableMessage(which: 'bundle' | 'envelope' | 'result'): string { + const subject = + which === 'bundle' ? 'evidence bundle' : which === 'envelope' ? 'evidence envelope' : 'evidence result'; + return `The ${subject} could not be read. ${RESTORE_BUNDLE}`; +} + +function envelopeShapeMessage(): string { + return `The evidence envelope is not a readable DSSE envelope. ${RESTORE_BUNDLE}`; +} + +function resultShapeMessage(): string { + return `The evidence result is not a readable evidence result. ${RESTORE_BUNDLE}`; +} + +function resultSchemaMessage(version: unknown): string { + const echoed = echoableEvidenceSchema(version); + const got = echoed ? `schema \`${echoed}\`` : 'a schema this version of vg cannot read'; + return `The evidence result uses ${got} (this version reads ${EVIDENCE_RESULT_SCHEMA}). ${RESTORE_BUNDLE}`; +} + const DEFAULT_KEY = 'attest-key.pem'; /** diff --git a/src/reporting/commands/evidence/index.ts b/src/reporting/commands/evidence/index.ts index 1c3b54a..369aa60 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'; @@ -391,22 +391,17 @@ const verifyCmd = new Command('verify') .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 loaded = loadEvidenceBundle(bundlePath); const publicKeyPem = opts.pub ? fs.readFileSync(path.resolve(opts.pub as string), 'utf8') : undefined; - const v = verifyEvidenceEnvelope(envelope, { publicKeyPem, result }); + 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)); + const tsrPath = path.join(path.dirname(loaded.envelopePath), 'timestamp.tsr'); + if (fs.existsSync(tsrPath) && loaded.result) { + const t = verifyTimestamp(fs.readFileSync(tsrPath), exposureSubjectDigest(loaded.result)); const tcolor = t.imprintMatches ? chalk.green : chalk.red; console.log(' ' + tcolor('TIMESTAMP') + ` ${t.reason}`); } From b1f4a63e54dbb2f7c529a060c335ed0e27d0429a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 10 Oct 2026 13:55:04 +0000 Subject: [PATCH 2/2] fix(evidence): report an unreadable bundle without echoing it vg evidence verify exits 1 when the envelope or result.json is truncated, not JSON, or not a document this version can read. The message says what failed and how to restore the bundle. It does not print a stack or the file contents. A readable bundle still reports verified, unverified, or failed. Signed-off-by: Cursor Agent Co-authored-by: vibgrate-team --- CHANGELOG.md | 12 +- DOCS.md | 2 +- README.md | 2 +- .../commands/evidence/bundle-load.test.ts | 280 ----------------- src/reporting/commands/evidence/bundle.ts | 274 +++++++++-------- src/reporting/commands/evidence/index.ts | 15 +- test/evidence-verify-load.test.ts | 286 ++++++++++++++++++ 7 files changed, 459 insertions(+), 412 deletions(-) delete mode 100644 src/reporting/commands/evidence/bundle-load.test.ts create mode 100644 test/evidence-verify-load.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 738634c..397100c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,12 +42,12 @@ backward compatible. ### Fixed -- **`vg evidence verify` names a truncated or unreadable bundle.** A bundle - that is truncated, not valid JSON, or not a schema this version can read - exits `1`. The message names the failure — the evidence envelope or the - evidence result — and says to restore the bundle or write a new one with - `vg evidence exposure --bundle `. It does not include the bundle - contents. A failure to write a bundle stays a separate error. (#287) +- **`vg evidence verify` reports a truncated or unreadable bundle as an error.** + A bundle whose envelope or `result.json` is cut off, is not JSON, or is not + an evidence document this version can read exits 1. The message says what + failed and how to restore the bundle. It does not print a stack or the + file's contents. A readable bundle still reports `verified`, `unverified`, + or `failed`. - **SBOM component order no longer follows scan or filesystem order.** `vg sbom export` and CycloneDX/SPDX graph export sort a component by its diff --git a/DOCS.md b/DOCS.md index c20065e..7e5d3ef 100644 --- a/DOCS.md +++ b/DOCS.md @@ -335,7 +335,7 @@ 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 checks a signature. A bundle that is truncated, not valid JSON, or not a schema this version can read is also an operational error and exits `1`. The message names the failure — the evidence envelope or the evidence result — and says to restore the original bundle or write a new one with `vg evidence exposure --bundle `. It does not include the bundle contents. That read error is separate from a failure to write a 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 ba8cf59..b4f7b87 100644 --- a/README.md +++ b/README.md @@ -507,7 +507,7 @@ 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 bundle that is truncated, not valid JSON, or not a schema this version of vg can read does not get a trust state. `vg evidence verify` exits 1 and names the failure. Restore the bundle, or write a new one with `vg evidence exposure --bundle `. The message does not include the bundle. +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. diff --git a/src/reporting/commands/evidence/bundle-load.test.ts b/src/reporting/commands/evidence/bundle-load.test.ts deleted file mode 100644 index 55806ab..0000000 --- a/src/reporting/commands/evidence/bundle-load.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { spawnSync } from 'node:child_process'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { DSSE_PAYLOAD_TYPE } from '../../../engine/attest.js'; -import { CliError, ExitCode } from '../../../util/exit.js'; -import { EvidenceBundleLoadError, loadEvidenceBundle } from './bundle.js'; - -/** - * `vg evidence verify` must fail closed on a truncated or schema-broken - * bundle: a stable operator error, a non-zero exit, and none of the file - * bytes or environment. That read error is not a serialize/write failure. - */ - -const FILE_SECRET = 'ghp_EvidenceFileMustNotLeak'; -const ENV_SECRET = 'npm_EvidenceEnvMustNotLeak'; -const RESTORE = 'Restore the original bundle, or write a new one with `vg evidence exposure --bundle `.'; -const WRITE_FAILURE = /cannot write|output could not be serialized|choose another --/; - -const dirs: string[] = []; - -afterEach(() => { - delete process.env.VG_EVIDENCE_SECRET; - for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); -}); - -function temp(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-evidence-load-')); - dirs.push(dir); - return dir; -} - -function envelope(over: Record = {}): Record { - return { - payloadType: DSSE_PAYLOAD_TYPE, - payload: Buffer.from('{}', 'utf8').toString('base64'), - signatures: [{ keyid: 'abc', sig: 'AA==' }], - ...over, - }; -} - -function result(over: Record = {}): Record { - return { - schemaVersion: 'evidence-1', - regime: 'cra', - advisory: { id: 'CVE-2026-0001', sourceProvenance: 'test', kevListed: false }, - overallStatus: 'not-affected', - products: [], - meta: { - evidenceId: 'ev-test', - dataPackVersion: 'none', - kernelVersion: 'evidence-1', - timestamp: { source: 'local-clock', value: '2026-01-01T00:00:00.000Z' }, - }, - ...over, - }; -} - -function writeEnvelope(dir: string, body: string): string { - const file = path.join(dir, 'evidence.intoto.jsonl'); - fs.writeFileSync(file, body); - return file; -} - -function assertLoadError(err: unknown, kind: 'corrupt' | 'schema', message: string): EvidenceBundleLoadError { - expect(err).toBeInstanceOf(EvidenceBundleLoadError); - const load = err as EvidenceBundleLoadError; - expect(load).toBeInstanceOf(CliError); - expect(load.name).toBe('EvidenceBundleLoadError'); - expect(load.kind).toBe(kind); - expect(load.code).toBe(ExitCode.ERROR); - expect(load.code).not.toBe(0); - expect(load.message).toBe(message); - expect(load.message).toContain(RESTORE); - expect(load.message).not.toContain(FILE_SECRET); - expect(load.message).not.toContain(ENV_SECRET); - expect(load.message).not.toContain('ghp_'); - expect(load.message).not.toContain('npm_'); - expect(load.message).not.toContain('Unexpected token'); - expect(load.message).not.toContain('Unexpected end'); - expect(load.message).not.toMatch(WRITE_FAILURE); - expect(load.message).not.toMatch(/\n\s*at /); - expect(load.stack ?? '').not.toContain(FILE_SECRET); - return load; -} - -function catchLoad(bundlePath: string): unknown { - process.env.VG_EVIDENCE_SECRET = ENV_SECRET; - try { - loadEvidenceBundle(bundlePath); - return undefined; - } catch (err) { - return err; - } -} - -describe('loadEvidenceBundle', () => { - it('loads a readable envelope and a pretty-printed result', () => { - const dir = temp(); - const env = envelope(); - writeEnvelope(dir, `${JSON.stringify(env)}\n`); - fs.writeFileSync(path.join(dir, 'result.json'), `${JSON.stringify(result(), null, 2)}\n`); - const loaded = loadEvidenceBundle(dir); - expect(loaded.envelope).toEqual(env); - expect(loaded.result).toMatchObject({ schemaVersion: 'evidence-1', meta: { evidenceId: 'ev-test' } }); - expect(loaded.envelopePath).toBe(path.join(dir, 'evidence.intoto.jsonl')); - }); - - it('rejects a truncated envelope without echoing file or env contents', () => { - const dir = temp(); - const file = writeEnvelope( - dir, - `{"payloadType":"${DSSE_PAYLOAD_TYPE}","note":"${FILE_SECRET}","payload":"`, - ); - const again = catchLoad(dir); - const first = catchLoad(file); - expect(assertLoadError( - first, - 'corrupt', - `The evidence envelope is truncated or not valid JSON. ${RESTORE}`, - ).message).toBe((again as EvidenceBundleLoadError).message); - }); - - it('rejects invalid JSON that is not a truncated prefix', () => { - const dir = temp(); - writeEnvelope(dir, `not-json ${FILE_SECRET}\n`); - assertLoadError( - catchLoad(dir), - 'corrupt', - `The evidence envelope is truncated or not valid JSON. ${RESTORE}`, - ); - }); - - it('rejects a truncated result.json beside a readable envelope', () => { - const dir = temp(); - writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); - fs.writeFileSync(path.join(dir, 'result.json'), `{"schemaVersion":"evidence-1","note":"${FILE_SECRET}"`); - assertLoadError( - catchLoad(dir), - 'corrupt', - `The evidence result is truncated or not valid JSON. ${RESTORE}`, - ); - }); - - it('rejects a schema this version cannot read without echoing the token', () => { - const dir = temp(); - writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); - fs.writeFileSync( - path.join(dir, 'result.json'), - JSON.stringify(result({ schemaVersion: FILE_SECRET, note: FILE_SECRET })), - ); - assertLoadError( - catchLoad(dir), - 'schema', - `The evidence result uses a schema this version of vg cannot read (this version reads evidence-1). ${RESTORE}`, - ); - }); - - it('names an evidence schema version that is safe to echo', () => { - const dir = temp(); - writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); - fs.writeFileSync(path.join(dir, 'result.json'), JSON.stringify(result({ schemaVersion: 'evidence-2' }))); - assertLoadError( - catchLoad(dir), - 'schema', - `The evidence result uses schema \`evidence-2\` (this version reads evidence-1). ${RESTORE}`, - ); - }); - - it('rejects JSON that is not a DSSE envelope', () => { - const dir = temp(); - writeEnvelope(dir, `${JSON.stringify({ schemaVersion: 'evidence-1', note: FILE_SECRET })}\n`); - assertLoadError( - catchLoad(dir), - 'schema', - `The evidence envelope is not a readable DSSE envelope. ${RESTORE}`, - ); - }); - - it('rejects an evidence-1 document that is missing the result shape', () => { - const dir = temp(); - writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); - fs.writeFileSync(path.join(dir, 'result.json'), `{"schemaVersion":"evidence-1","note":"${FILE_SECRET}"}`); - assertLoadError( - catchLoad(dir), - 'schema', - `The evidence result is not a readable evidence result. ${RESTORE}`, - ); - }); - - it('reports a missing bundle as not found, without a read error', () => { - const dir = temp(); - const missing = path.join(dir, 'missing-bundle'); - let caught: unknown; - try { - loadEvidenceBundle(missing); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(CliError); - expect(caught).not.toBeInstanceOf(EvidenceBundleLoadError); - const err = caught as CliError; - expect(err.code).toBe(ExitCode.NOT_FOUND); - expect(err.message).toBe(`no evidence.intoto.jsonl at ${missing}`); - expect(err.message).not.toContain('ENOENT'); - expect(err.message).not.toMatch(WRITE_FAILURE); - }); -}); - -describe('vg evidence verify CLI', () => { - const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); - const cli = path.join(pkgRoot, 'src/cli.ts'); - - function run(bundlePath: string): { status: number | null; signal: NodeJS.Signals | null; stderr: string; stdout: string; error?: Error } { - const res = spawnSync(process.execPath, ['--import', 'tsx', cli, 'evidence', 'verify', bundlePath], { - cwd: pkgRoot, - encoding: 'utf8', - timeout: 60_000, - env: { - ...process.env, - NO_COLOR: '1', - VIBGRATE_NO_KERNEL: '1', - VIBGRATE_DSN: '', - VG_EVIDENCE_SECRET: ENV_SECRET, - }, - }); - return { - status: res.status, - signal: res.signal, - stderr: res.stderr ?? '', - stdout: res.stdout ?? '', - error: res.error, - }; - } - - function assertCli(bundlePath: string, message: string): void { - const res = run(bundlePath); - expect(res.error).toBeUndefined(); - expect(res.signal).toBeNull(); - expect(res.status).toBe(ExitCode.ERROR); - expect(res.stderr).toContain(`error: ${message}`); - expect(res.stderr).not.toContain(FILE_SECRET); - expect(res.stderr).not.toContain(ENV_SECRET); - expect(res.stderr).not.toContain('Unexpected token'); - expect(res.stderr).not.toContain('Unexpected end'); - expect(res.stderr).not.toContain('SyntaxError'); - expect(res.stderr).not.toMatch(/\bat (?:JSON\.parse|readFile|loadEvidenceBundle)/); - expect(res.stderr).not.toMatch(WRITE_FAILURE); - expect(res.stdout).not.toContain(FILE_SECRET); - expect(res.stdout).not.toContain(ENV_SECRET); - expect(res.stdout).not.toMatch(/\b(VERIFIED|UNVERIFIED|FAILED)\b/); - } - - it('exits non-zero on a truncated bundle and does not print a stack', () => { - const dir = temp(); - writeEnvelope(dir, `{"note":"${FILE_SECRET}","payload":`); - assertCli(dir, `The evidence envelope is truncated or not valid JSON. ${RESTORE}`); - }, 60_000); - - it('exits non-zero on invalid JSON', () => { - const dir = temp(); - writeEnvelope(dir, `{not json, "token":"${FILE_SECRET}"}`); - assertCli(dir, `The evidence envelope is truncated or not valid JSON. ${RESTORE}`); - }, 60_000); - - it('exits non-zero on a schema this version cannot read', () => { - const dir = temp(); - writeEnvelope(dir, `${JSON.stringify(envelope())}\n`); - fs.writeFileSync( - path.join(dir, 'result.json'), - JSON.stringify(result({ schemaVersion: 'evidence-2', leak: FILE_SECRET })), - ); - assertCli( - dir, - `The evidence result uses schema \`evidence-2\` (this version reads evidence-1). ${RESTORE}`, - ); - }, 60_000); -}); diff --git a/src/reporting/commands/evidence/bundle.ts b/src/reporting/commands/evidence/bundle.ts index 2032577..cdbdacc 100644 --- a/src/reporting/commands/evidence/bundle.ts +++ b/src/reporting/commands/evidence/bundle.ts @@ -161,18 +161,18 @@ export function verifyEvidenceEnvelope( }; } -const EVIDENCE_RESULT_SCHEMA = 'evidence-1'; -const RESTORE_BUNDLE = - 'Restore the original bundle, or write a new one with `vg evidence exposure --bundle `.'; +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 for `vg evidence verify`. + * An evidence bundle on disk cannot be loaded. * - * This is a read failure: the file is truncated, not JSON, or not a schema - * this version can read. It is not a failure to serialize or write a bundle. - * The message names what failed and how to restore the bundle. It never - * includes file contents, environment values, or other secret material. - * `code` is {@link ExitCode.ERROR} so the command exits non-zero. + * 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; @@ -186,161 +186,193 @@ export class EvidenceBundleLoadError extends CliError { } export interface LoadedEvidenceBundle { - /** Absolute path of the DSSE envelope that was read. */ envelopePath: string; envelope: DsseEnvelope; - /** Present when a sibling result.json was read and accepted. */ 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; } /** - * Load the envelope (and result.json, when it sits beside the envelope) for - * `vg evidence verify`. - * - * `bundlePath` may be a bundle directory or the envelope file itself. A - * missing bundle is {@link CliError} with {@link ExitCode.NOT_FOUND}. A - * present file that cannot be read, is truncated or not JSON, or is not a - * schema this version can read throws {@link EvidenceBundleLoadError}. - * The same bytes always produce the same error. + * An attestation token we are willing to echo: a DSSE payload type or a + * Vibgrate predicate URL. Arbitrary strings stay out of the message. */ -export function loadEvidenceBundle(bundlePath: string): LoadedEvidenceBundle { - const abs = path.resolve(bundlePath); - let stat: fs.Stats; - try { - stat = fs.statSync(abs); - } catch (err) { - if (nodeCode(err) === 'ENOENT') { - throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND); - } - throw new EvidenceBundleLoadError(unreadableMessage('bundle'), 'corrupt'); - } - - const envelopePath = stat.isDirectory() ? path.join(abs, 'evidence.intoto.jsonl') : abs; - if (!fs.existsSync(envelopePath)) { - throw new CliError(`no evidence.intoto.jsonl at ${bundlePath}`, ExitCode.NOT_FOUND); - } +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; +} - const envelope = assertEnvelope(parseBundleJson(readUtf8(envelopePath, 'envelope'), 'envelope')); - const resultPath = path.join(path.dirname(envelopePath), 'result.json'); - const result = fs.existsSync(resultPath) - ? assertResult(parseBundleJson(readUtf8(resultPath, 'result'), 'result')) - : undefined; - return { envelopePath, envelope, result }; +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; } -function nodeCode(cause: unknown): string { - if (typeof cause === 'object' && cause !== null && 'code' in cause) { - const code = (cause as { code?: unknown }).code; +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 readUtf8(file: string, which: 'envelope' | 'result'): string { +function readBundleText(file: string): string { try { return fs.readFileSync(file, 'utf8'); - } catch { - throw new EvidenceBundleLoadError(unreadableMessage(which), 'corrupt'); + } catch (err) { + if (errnoCode(err) === 'ENOENT') { + throw new CliError(`no evidence.intoto.jsonl at ${file}`, ExitCode.NOT_FOUND); + } + throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt'); } } -/** Envelope files are JSONL: only the first record is the DSSE document. */ -function firstJsonLine(text: string): string { - const trimmed = text.trim(); - const newline = trimmed.indexOf('\n'); - const line = newline === -1 ? trimmed : trimmed.slice(0, newline); - return line.trim(); +function readBundleBytes(file: string): Buffer { + try { + return fs.readFileSync(file); + } catch { + throw new EvidenceBundleLoadError(unreadableMessage(), 'corrupt'); + } } -function parseBundleJson(text: string, which: 'envelope' | 'result'): unknown { - const body = which === 'envelope' ? firstJsonLine(text) : text.trim(); - if (!body) throw new EvidenceBundleLoadError(corruptMessage(which), '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(corruptMessage(which), 'corrupt'); + throw new EvidenceBundleLoadError(corruptJsonMessage(which), 'corrupt'); } } -function isPlainObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); +/** 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() ?? ''; } -/** A schema token we are willing to echo. Anything else stays out of the message. */ -function echoableEvidenceSchema(value: unknown): string | null { - if (typeof value !== 'string' || value.length > 16) return null; - return /^evidence-\d{1,4}$/.test(value) ? value : null; -} +const PREDICATE_STRINGS = ['evidenceId', 'regime', 'advisoryId', 'overallStatus', 'resultDigest'] as const; -function assertEnvelope(value: unknown): DsseEnvelope { - if ( - !isPlainObject(value) || - value.payloadType !== DSSE_PAYLOAD_TYPE || - typeof value.payload !== 'string' || - value.payload.length === 0 || - !isSignatureList(value.signatures) - ) { - throw new EvidenceBundleLoadError(envelopeShapeMessage(), 'schema'); +function assertEvidenceEnvelope(value: unknown): DsseEnvelope { + if (!isPlainObject(value) || typeof value.payload !== 'string' || !Array.isArray(value.signatures)) { + throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt'); } - return value as unknown as DsseEnvelope; -} - -function isSignatureList(value: unknown): boolean { - if (!Array.isArray(value)) return false; - for (const sig of value) { - if (!isPlainObject(sig)) return false; - if (typeof sig.keyid !== 'string' || typeof sig.sig !== 'string') return false; - if (sig.publicKey !== undefined && typeof sig.publicKey !== 'string') return false; + if (value.payloadType !== DSSE_PAYLOAD_TYPE) { + throw new EvidenceBundleLoadError(schemaMessage('envelope', value.payloadType), 'schema'); } - return true; -} - -function assertResult(value: unknown): ExposureResult { - if (!isPlainObject(value)) { - throw new EvidenceBundleLoadError(resultShapeMessage(), 'schema'); + let statement: unknown; + try { + statement = JSON.parse(Buffer.from(value.payload, 'base64').toString('utf8')); + } catch { + throw new EvidenceBundleLoadError(corruptJsonMessage('envelope'), 'corrupt'); } - if (value.schemaVersion !== EVIDENCE_RESULT_SCHEMA) { - throw new EvidenceBundleLoadError(resultSchemaMessage(value.schemaVersion), 'schema'); + if (!isPlainObject(statement)) { + throw new EvidenceBundleLoadError(shapeMessage('envelope'), 'corrupt'); } - if (!isReadableResult(value)) { - throw new EvidenceBundleLoadError(resultShapeMessage(), 'schema'); + if (statement._type !== IN_TOTO_STATEMENT_TYPE) { + throw new EvidenceBundleLoadError(schemaMessage('envelope', statement._type), 'schema'); } - return value as unknown as ExposureResult; + 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 isReadableResult(value: Record): boolean { - return ( - typeof value.regime === 'string' && - isPlainObject(value.advisory) && +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(value.meta) - ); -} - -function corruptMessage(which: 'envelope' | 'result'): string { - const subject = which === 'envelope' ? 'evidence envelope' : 'evidence result'; - return `The ${subject} is truncated or not valid JSON. ${RESTORE_BUNDLE}`; -} - -function unreadableMessage(which: 'bundle' | 'envelope' | 'result'): string { - const subject = - which === 'bundle' ? 'evidence bundle' : which === 'envelope' ? 'evidence envelope' : 'evidence result'; - return `The ${subject} could not be read. ${RESTORE_BUNDLE}`; -} - -function envelopeShapeMessage(): string { - return `The evidence envelope is not a readable DSSE envelope. ${RESTORE_BUNDLE}`; + isPlainObject(meta) && + typeof meta.evidenceId === 'string'; + if (!readable) throw new EvidenceBundleLoadError(shapeMessage('result'), 'corrupt'); + return value as unknown as ExposureResult; } -function resultShapeMessage(): string { - return `The evidence result is not a readable evidence result. ${RESTORE_BUNDLE}`; +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; } -function resultSchemaMessage(version: unknown): string { - const echoed = echoableEvidenceSchema(version); - const got = echoed ? `schema \`${echoed}\`` : 'a schema this version of vg cannot read'; - return `The evidence result uses ${got} (this version reads ${EVIDENCE_RESULT_SCHEMA}). ${RESTORE_BUNDLE}`; +/** + * 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'; @@ -464,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 369aa60..a247412 100644 --- a/src/reporting/commands/evidence/index.ts +++ b/src/reporting/commands/evidence/index.ts @@ -385,6 +385,14 @@ 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)') @@ -392,16 +400,15 @@ const verifyCmd = new Command('verify') .option('--pub ', 'Public key PEM to pin the signer (establish trust)') .action((bundlePath: string, opts) => { const loaded = loadEvidenceBundle(bundlePath); - const publicKeyPem = opts.pub ? fs.readFileSync(path.resolve(opts.pub as string), 'utf8') : undefined; + 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(loaded.envelopePath), 'timestamp.tsr'); - if (fs.existsSync(tsrPath) && loaded.result) { - const t = verifyTimestamp(fs.readFileSync(tsrPath), exposureSubjectDigest(loaded.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 0000000..759b595 --- /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); + }); +});