Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ vg evidence export [--out <dir>] [--regime <id>]

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.

---
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
216 changes: 216 additions & 0 deletions src/reporting/commands/evidence/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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';

/**
Expand Down Expand Up @@ -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 <tsa-ca.pem>\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.`
Expand Down
26 changes: 14 additions & 12 deletions src/reporting/commands/evidence/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('<bundle>', 'Path to an evidence bundle directory or evidence.intoto.jsonl')
.option('--pub <file>', '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}`);
}
Expand Down
Loading
Loading