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
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ Git and PostgreSQL operations are real; Plane, Entire, GitHub, Buildkite, and
security observations in this sample are explicitly synthetic. This is not a
live-provider or security-scanner certification.

The demo receipt includes a failure matrix with expected and actual verdicts,
safe reasons, and lineage digests where available. It exercises missing, stale,
conflicting, tampered, secret, failed-validation, outage, and moved-base cases.
Source replay runs twice in a newly created local database, verifies the original
digest, and checks that source snapshots and Git refs remain unchanged. Cleanup
and local cost/time are recorded even when the demo fails.

To replace the sample security observation with real bounded checks, install
Gitleaks 8.30.1 and ast-grep 0.45.1 on `PATH`, then run:

Expand Down Expand Up @@ -93,8 +100,28 @@ isolated cluster:
node scripts/demo-provenance.mjs --verify-storage-tests true --out /tmp/tabellio-demo.json
```

`review` returns the full current candidate, distinct review and security verdicts,
actions for failed or blocked evidence, and matching GitHub status payloads. Known
Git and provider records receive source links; other records retain their exact
source identifiers and lineage digest. Load verified security finding locations
with `--security-input <receipt.json> --policy-digest <expected-digest>`.
`--report-url` supplies a credential-free report link for both status contexts.

`review-intent` prepares an immutable publication intent from the same scoped
lineage query. `publish-review` accepts `--intent-input` and `--approval-input`,
uses `GH_TOKEN`, rechecks the current candidate and GitHub origin,
and publishes separate `Tabellio / provenance review` and
`Tabellio / provenance security` contexts. The approval uses
`tabellio-provenance-status-approval/v0.1` with `id`, `intentDigest`, `approved: true`,
`approvedBy`, `approvedAt`, `expiresAt`, and `reason`; its lifetime is at most one
hour. Publication receipts report delivery separately from review verdicts.
Each approval is reserved in `refs/tabellio/provenance-statuses` before delivery;
repeated requests reuse the receipt, and uncertain attempts require inspection
before a new approval. The local demo exercises this flow through a fake GitHub
transport and compares the delivered states with the CLI result.

The `tabellio-provenance` CLI supports `capture`, `import`, `import-sources`, `replay`, `replay-sources`,
`show`, `review`, and `packet`. `import-sources` normalizes a bundle of Plane,
`show`, `review`, `review-intent`, `publish-review`, and `packet`. `import-sources` normalizes a bundle of Plane,
Entire, GitHub, and Buildkite snapshots and captures Git directly from `--repo`.
Readers preserve healthy sources while reporting authentication, permission,
missing-record, outage, and malformed-input failures as blocked. The demo imports
Expand All @@ -117,8 +144,8 @@ never an inherited application's `DATABASE_URL`.

The capture and retention boundary lives in `tabellio.data-boundary.json`.
Raw prompts, transcripts, provider bodies, and credentials are excluded. The
remaining rebuild work includes independent security evidence,
GitHub presentation, and the final release decision. No cloud provisioning,
remaining release work includes failure/recovery acceptance and the explicit
release decision. No cloud provisioning,
automatic publication, deployment, or learning is introduced.

The native engine runs through the installed `git` executable. It never constructs shell commands.
Expand Down
101 changes: 94 additions & 7 deletions scripts/demo-provenance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { sampleObservations } from "../examples/provenance/sample.mjs";
import { sampleSourceBundle } from "../examples/provenance/sources.mjs";
import { runSecurityReview, SECURITY_CHECKS } from "./lib/provenance-security.mjs";
import { SECURITY_POLICY_DIGEST } from "./lib/provenance-security-scanners.mjs";
import { publishProvenanceStatuses } from "./lib/provenance-review-publication.mjs";
import { GitHubStatusPublisher } from "./providers/github-status-publisher.mjs";
import { parseOptionPairs, writeJsonOutput } from "./lib/cli-options.mjs";

const execute = promisify(execFile);
Expand All @@ -27,6 +29,7 @@ const repo = join(root, "repo");
let running = false;
let initialized = false;
let receipt;
const failureMatrix = [];
const env = { ...process.env, LC_ALL: "C", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_AUTHOR_NAME: "Sample", GIT_AUTHOR_EMAIL: "sample@example.invalid", GIT_COMMITTER_NAME: "Sample", GIT_COMMITTER_EMAIL: "sample@example.invalid" };
for (const key of Object.keys(env)) if (key.startsWith("PG")) delete env[key];
const run = (command, args, cwd = root) => execute(command, args, { cwd, env, timeout: 60000, maxBuffer: 2 * 1024 * 1024 });
Expand All @@ -50,7 +53,9 @@ try {
const sourceTests = fileURLToPath(new URL("../tests/provenance-sources.test.mjs", import.meta.url));
const securityTests = fileURLToPath(new URL("../tests/provenance-security.test.mjs", import.meta.url));
const scannerTests = fileURLToPath(new URL("../tests/provenance-security-scanners.test.mjs", import.meta.url));
await execute(process.execPath, ["--test", testFile, lineageTests, sourceTests, securityTests, scannerTests], {
const resultTests = fileURLToPath(new URL("../tests/provenance-review-result.test.mjs", import.meta.url));
const publicationTests = fileURLToPath(new URL("../tests/provenance-review-publication.test.mjs", import.meta.url));
await execute(process.execPath, ["--test", testFile, lineageTests, sourceTests, securityTests, scannerTests, resultTests, publicationTests], {
cwd: root, env: { ...env, TABELLIO_REQUIRE_POSTGRES: "1", TABELLIO_TEST_PG_SOCKET: socketRoot, TABELLIO_TEST_PG_USER: "tabellio" },
timeout: 60000, maxBuffer: 2 * 1024 * 1024,
});
Expand All @@ -59,6 +64,7 @@ try {
await mkdir(repo);
const git = (...args) => run("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], repo);
await git("init", "-b", "main");
await git("remote", "add", "origin", "https://github.com/sample/repository.git");
await writeFile(join(repo, "app.mjs"), "export const greeting = 'Hello';\n");
await git("add", "app.mjs");
await git("commit", "-m", "Create sample application");
Expand Down Expand Up @@ -112,28 +118,42 @@ try {
const reviewArgs = [...query, "--repo", repo, "--now", now];
const initial = await invoke("review", ...reviewArgs);
if (initial.status !== "passed") throw new Error("Sample review did not pass.");
await exerciseFailureMatrix({ input, inputPath, databaseUrl, query, reviewArgs, invoke, invokeBlocked, failureMatrix });
await stop();
await start();
const afterRestart = await invoke("show", ...query);
if (afterRestart.digest !== imported.digest) throw new Error("Restart changed the lineage.");
const publicationQuery = [...query];
publicationQuery[publicationQuery.indexOf("--digest") + 1] = securityImport.digest;
const publicationArgs = [...publicationQuery, "--repo", repo, "--now", now];
const publicationLineage = await invoke("show", ...publicationQuery);
const publicationReview = await invoke("review", ...publicationArgs);
if (publicationReview.status !== "passed") throw new Error("Secured lineage review did not pass.");
const statusIntent = await invoke("review-intent", ...publicationArgs);
const statusPublication = await publishSampleStatuses({ repo, lineage: publicationLineage, intent: statusIntent, cli: publicationReview, now });
const store = new LocalProvenanceStore({ databaseUrl }); await store.migrate();
await run("createdb", ["--host", socketRoot, "--username", "tabellio", "--no-password", "tabellio_replay"]);
const replayDatabaseUrl = databaseUrl.replace("/tabellio?", "/tabellio_replay?");
const replayStore = new LocalProvenanceStore({ databaseUrl: replayDatabaseUrl }); await replayStore.migrate();
const sourceQuery = { digest: sourceImport.lineage.digest, projectKey: candidate.projectKey, repositoryId: candidate.repositoryId };
await store.removeLineage(sourceQuery);
if (await store.getLineage(sourceQuery) !== null) throw new Error("Source replay did not start from an empty derived record.");
const refsBeforeReplay = (await git("show-ref")).stdout;
if (await replayStore.getLineage(sourceQuery) !== null) throw new Error("Source replay did not start from an empty derived record.");
sourceBundle.snapshots = Object.fromEntries(Object.entries(sourceBundle.snapshots).reverse());
await writeFile(sourcePath, JSON.stringify(sourceBundle));
const replaySourceArgs = ["replay-sources", "--repo", repo, "--database-url", databaseUrl, "--input", sourcePath, "--now", now, "--expected-digest", sourceImport.lineage.digest];
const replaySourceArgs = ["replay-sources", "--repo", repo, "--database-url", replayDatabaseUrl, "--input", sourcePath, "--now", now, "--expected-digest", sourceImport.lineage.digest];
for (let replay = 0; replay < 2; replay += 1) {
const rebuilt = await invoke(...replaySourceArgs);
if (rebuilt.status !== "stored" || rebuilt.lineage.digest !== sourceImport.lineage.digest) throw new Error("Source replay changed the record.");
}
if (JSON.stringify(sourceBundle) !== await readFile(sourcePath, "utf8")) throw new Error("Replay modified original source snapshots.");
if ((await git("show-ref")).stdout !== refsBeforeReplay) throw new Error("Replay modified Git source refs.");
failureMatrix.push({ case: "clean-store-replay", expected: "passed", actual: "passed", lineageDigest: sourceQuery.digest, sourceSnapshotsUnchanged: true, gitRefsUnchanged: true });
sourceBundle.snapshots.github.reviews[0].state = "changes_requested";
await writeFile(sourcePath, JSON.stringify(sourceBundle));
const changedSource = await invokeBlocked(...replaySourceArgs);
if (changedSource.status !== "blocked" || changedSource.lineage.digest === sourceQuery.digest) throw new Error("Changed source replay was accepted.");
if (await store.getLineage({ ...sourceQuery, digest: changedSource.lineage.digest }) !== null) throw new Error("Rejected replay was persisted.");
if ((await store.getLineage(sourceQuery))?.digest !== sourceQuery.digest) throw new Error("Rejected replay changed the original record.");
if (await replayStore.getLineage({ ...sourceQuery, digest: changedSource.lineage.digest }) !== null) throw new Error("Rejected replay was persisted.");
if ((await replayStore.getLineage(sourceQuery))?.digest !== sourceQuery.digest) throw new Error("Rejected replay changed the original record.");
sourceBundle.snapshots.github.reviews[0].state = "approved";
await writeFile(sourcePath, JSON.stringify(sourceBundle));
const unavailableGitArgs = [...replaySourceArgs];
Expand All @@ -159,7 +179,15 @@ try {
moved = JSON.parse(error.stdout);
if (moved.status !== "blocked" || !moved.reasons.some((reason) => reason.state === "stale")) throw new Error("Moved base did not block readiness.");
}
receipt = { status: "passed", candidate, lineageDigest: imported.digest, checks: { cliImport: "passed", sourceImport: sourceImport.status, sourceReplay: "passed", changedSourceReplay: changedSource.status, missingSourceReplay: missingSource.status, gitOutage: unavailableGit.status, missingSecurity: sourcePacket.status, securityImport: securityImport.status, unavailableSecurityImport: blockedSecurityImport.status, review: initial.status, postgresServerRestart: "passed", deleteAndReplay: "passed", safePacket: packet.status, movedBase: moved.status }, sources: { git: "real temporary sample repository", plane: "synthetic fixture", entire: "synthetic fixture", github: "synthetic fixture", buildkite: "synthetic fixture", security: options.verifySecurityScanners ? "real bounded scanners over immutable Git content" : "synthetic fixture" }, cost: { usd: 0, modelCalls: 0, cloudCalls: 0 } };
receipt = { status: "passed", candidate, lineageDigest: imported.digest, checks: { cliImport: "passed", sourceImport: sourceImport.status, sourceReplay: "passed", changedSourceReplay: changedSource.status, missingSourceReplay: missingSource.status, gitOutage: unavailableGit.status, missingSecurity: sourcePacket.status, securityImport: securityImport.status, unavailableSecurityImport: blockedSecurityImport.status, review: initial.status, githubRepresentation: statusPublication.status, postgresServerRestart: "passed", deleteAndReplay: "passed", safePacket: packet.status, movedBase: moved.status }, sources: { git: "real temporary sample repository", plane: "synthetic fixture", entire: "synthetic fixture", github: "synthetic records and local fake status transport", buildkite: "synthetic fixture", security: options.verifySecurityScanners ? "real bounded scanners over immutable Git content" : "synthetic fixture" }, cost: { usd: 0, modelCalls: 0, cloudCalls: 0 } };
receipt.reviewedLineageDigest = publicationLineage.digest;
receipt.securityReviewDigest = securityReview.digest;
failureMatrix.push(
{ case: "changed-source-replay", expected: "blocked", actual: changedSource.status, acceptedLineageUnchanged: true, rejectedLineageNotStored: true },
{ case: "git-outage", expected: "blocked", actual: unavailableGit.status, healthySourcesPreserved: true },
{ case: "missing-source", expected: "blocked", actual: missingSource.status },
{ case: "moved-base", expected: "blocked", actual: moved.status, reasons: moved.reasons },
);
} catch (error) {
const postgresLog = await readFile(join(root, "postgres.log"), "utf8").catch(() => "");
const socketPathFailure = /Unix-domain socket path.*too long/i.test(postgresLog);
Expand Down Expand Up @@ -187,5 +215,64 @@ try {
process.exitCode = 1;
}
receipt.durationMs = Date.now() - startedAt;
receipt.failureMatrix = failureMatrix;
await writeJsonOutput(receipt, options.out);
}

async function exerciseFailureMatrix({ input, inputPath, databaseUrl, query, reviewArgs, invoke, invokeBlocked, failureMatrix }) {
const cases = [
["missing", (value) => value.observations.splice(0, 1), "blocked"],
["stale", (value) => { value.observations[0].observedAt = "2000-01-01T00:00:00Z"; }, "blocked"],
["conflicting", (value) => { value.observations[2].candidate = { ...value.candidate, headCommit: "c".repeat(40) }; }, "blocked"],
["failed-validation", (value) => { value.observations[5].status = "failed"; }, "failed"],
];
for (const [name, mutate, expected] of cases) {
const value = structuredClone(input);
mutate(value);
await writeFile(inputPath, JSON.stringify(value));
const imported = await invoke("import", "--database-url", databaseUrl, "--input", inputPath);
const args = [...reviewArgs];
args[args.indexOf("--digest") + 1] = imported.digest;
const result = await invokeBlocked("review", ...args);
failureMatrix.push({ case: name, expected, actual: result.status, lineageDigest: imported.digest, reasons: result.reasons });
if (result.status !== expected || !result.reasons.length) throw new Error("Failure matrix verdict mismatch.");
}
await writeFile(inputPath, JSON.stringify(input));
const original = await invoke("show", ...query);
const tampered = structuredClone(original);
tampered.observations[0].status = "failed";
const secret = structuredClone(input);
const privateMarker = "password=" + "sample-private-value";
secret.observations[0].sourceId = privateMarker;
for (const [name, value] of [["tampered", tampered], ["secret", secret]]) {
await writeFile(inputPath, JSON.stringify(value));
let result;
try {
await invoke("import", "--database-url", databaseUrl, "--input", inputPath);
throw new Error("Unsafe input accepted.");
} catch (error) {
if (error.code !== 1 || error.stdout || !error.stderr || error.stderr.includes(privateMarker)) throw new Error("Unsafe input was not rejected with safe diagnostics.");
result = JSON.parse(error.stderr);
}
failureMatrix.push({ case: name, expected: "blocked", actual: result.status, reason: result.reason });
if (result.status !== "blocked") throw new Error("Unsafe input did not block.");
}
await writeFile(inputPath, JSON.stringify(input));
}

async function publishSampleStatuses({ repo, lineage, intent, cli, now }) {
const requests = [];
const publisher = new GitHubStatusPublisher({ token: "synthetic-local-demo", fetchImpl: async (url, options) => {
const body = JSON.parse(options.body);
requests.push({ url: String(url), body });
return new Response(JSON.stringify({ ...body, id: requests.length, created_at: now }), { status: 201 });
} });
const approval = { schemaVersion: "tabellio-provenance-status-approval/v0.1", id: "sample-review-status", intentDigest: intent.integrity.digest, approved: true, approvedBy: "Synthetic demo", approvedAt: now, expiresAt: new Date(Date.parse(now) + 60000).toISOString(), reason: "Local fake GitHub transport only." };
const publication = await publishProvenanceStatuses({ repo, lineage, intent, approval, publisher, now });
if (publication.status !== "published" || requests.length !== 2) throw new Error("Sample status publication failed.");
for (const [index, request] of requests.entries()) {
const expected = cli.github[index];
if (!request.url.endsWith(`/statuses/${expected.commit}`) || request.body.state !== expected.state || request.body.context !== expected.context || request.body.description !== expected.description || (request.body.target_url ?? null) !== expected.targetUrl) throw new Error("CLI and GitHub status differ.");
}
return publication;
}
Loading
Loading