From c0a7462624419280b3e145a4c47c82d10022ed5f Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 12 Sep 2026 16:08:11 -0400 Subject: [PATCH 1/2] feat: rebuild provenance from pinned source snapshots Plane-Work-Item: TAB-22 Entire-Checkpoint: 95aae876ef91 --- README.md | 7 ++++- scripts/demo-provenance.mjs | 46 ++++++++++++++++++++++++++++----- scripts/tabellio-provenance.mjs | 17 +++++++++--- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ba806c1..c35b22e 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,18 @@ isolated cluster: node scripts/demo-provenance.mjs --verify-storage-tests true --out /tmp/tabellio-demo.json ``` -The `tabellio-provenance` CLI supports `capture`, `import`, `import-sources`, `replay`, +The `tabellio-provenance` CLI supports `capture`, `import`, `import-sources`, `replay`, `replay-sources`, `show`, `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 all five sources, then verifies missing independent security evidence blocks review. External snapshots remain explicitly synthetic in the sample. +`replay-sources` rebuilds from the original source bundle using `--repo`, `--input`, +the original capture time in `--now`, and `--expected-digest` from the import receipt. +It writes only when the rebuilt digest matches; changed or missing sources return +blocked evidence without storing a replacement. Reordering snapshots and repeating +the replay preserve the same record. Source snapshots are never modified. Database operations require an explicit local `--database-url`; review and packet commands also require `--repo` so readiness is checked against current Git state. `--now` supplies a deterministic evaluation diff --git a/scripts/demo-provenance.mjs b/scripts/demo-provenance.mjs index 59672ba..aa9295a 100755 --- a/scripts/demo-provenance.mjs +++ b/scripts/demo-provenance.mjs @@ -68,14 +68,21 @@ try { await writeFile(inputPath, JSON.stringify(input)); const cli = fileURLToPath(new URL("./tabellio-provenance.mjs", import.meta.url)); const invoke = async (...args) => JSON.parse((await run(process.execPath, [cli, ...args])).stdout); + const invokeBlocked = async (...args) => { + try { + await invoke(...args); + } catch (error) { + if (error.code !== 1 || !error.stdout) throw error; + return JSON.parse(error.stdout); + } + throw new Error("Expected blocked CLI exit."); + }; const sourcePath = join(root, "sources.json"); - await writeFile(sourcePath, JSON.stringify(await sampleSourceBundle(candidate, now))); + const sourceBundle = await sampleSourceBundle(candidate, now); + await writeFile(sourcePath, JSON.stringify(sourceBundle)); const sourceImport = await invoke("import-sources", "--repo", repo, "--database-url", databaseUrl, "--input", sourcePath, "--now", now); if (sourceImport.status !== "stored" || !sourceImport.sources.every((source) => source.status === "present")) throw new Error("Source fixture import failed."); - const sourcePacket = await invoke("packet", "--repo", repo, "--database-url", databaseUrl, "--digest", sourceImport.lineage.digest, "--project-key", "SAMPLE", "--repository-id", "sample/repository", "--now", now).catch((error) => { - if (error.code !== 1 || !error.stdout) throw error; - return JSON.parse(error.stdout); - }); + const sourcePacket = await invokeBlocked("packet", "--repo", repo, "--database-url", databaseUrl, "--digest", sourceImport.lineage.digest, "--project-key", "SAMPLE", "--repository-id", "sample/repository", "--now", now); if (sourcePacket.status !== "blocked" || sourcePacket.reasons.length !== 1 || sourcePacket.reasons[0].kind !== "security") throw new Error("Source fixture must block only on missing independent security review."); const imported = await invoke("import", "--database-url", databaseUrl, "--input", inputPath); const query = ["--database-url", databaseUrl, "--digest", imported.digest, "--project-key", "SAMPLE", "--repository-id", "sample/repository"]; @@ -87,6 +94,33 @@ try { const afterRestart = await invoke("show", ...query); if (afterRestart.digest !== imported.digest) throw new Error("Restart changed the lineage."); const store = new LocalProvenanceStore({ databaseUrl }); await store.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."); + 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]; + 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."); + 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."); + sourceBundle.snapshots.github.reviews[0].state = "approved"; + await writeFile(sourcePath, JSON.stringify(sourceBundle)); + const unavailableGitArgs = [...replaySourceArgs]; + unavailableGitArgs[2] = join(root, "missing-repository"); + const unavailableGit = await invokeBlocked(...unavailableGitArgs); + if (unavailableGit.status !== "blocked" || unavailableGit.sources.find((source) => source.source === "git")?.status !== "blocked" || !unavailableGit.sources.filter((source) => source.source !== "git").every((source) => source.status === "present")) throw new Error("Git outage discarded healthy source evidence."); + delete sourceBundle.snapshots.entire; + await writeFile(sourcePath, JSON.stringify(sourceBundle)); + const missingSource = await invokeBlocked(...replaySourceArgs); + if (missingSource.status !== "blocked" || missingSource.sources.find((source) => source.source === "entire")?.status !== "blocked") throw new Error("Missing source replay was accepted."); await store.removeLineage({ digest: imported.digest, projectKey: "SAMPLE", repositoryId: "sample/repository" }); const replayed = await invoke("replay", "--database-url", databaseUrl, "--input", inputPath); if (replayed.digest !== imported.digest) throw new Error("Replay changed the lineage."); @@ -102,7 +136,7 @@ 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, missingSecurity: sourcePacket.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: "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, 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: "synthetic fixture" }, cost: { usd: 0, modelCalls: 0, cloudCalls: 0 } }; } catch (error) { const postgresLog = await readFile(join(root, "postgres.log"), "utf8").catch(() => ""); const socketPathFailure = /Unix-domain socket path.*too long/i.test(postgresLog); diff --git a/scripts/tabellio-provenance.mjs b/scripts/tabellio-provenance.mjs index f51d588..79a8fc7 100755 --- a/scripts/tabellio-provenance.mjs +++ b/scripts/tabellio-provenance.mjs @@ -22,13 +22,14 @@ async function main() { const options = parseCommandOptions(process.argv.slice(2), { capture: ["repo", "projectKey", "repositoryId", "base", "head", "out"], "import-sources": ["input", "repo", "databaseUrl", "now", "out"], + "replay-sources": ["input", "repo", "databaseUrl", "now", "expectedDigest", "out"], import: ["input", "databaseUrl", "out"], replay: ["input", "databaseUrl", "out"], show: [...query, "out"], review: [...query, "repo", "base", "head", "now", "out"], packet: [...query, "repo", "base", "head", "now", "out"], }); - if (options.command === "import-sources") { + if (["import-sources", "replay-sources"].includes(options.command)) { await importSources(options); return; } @@ -67,11 +68,21 @@ async function main() { } async function importSources(options) { - requireOptions(options, ["input", "repo", "databaseUrl"], "import-sources"); + requireOptions(options, ["input", "repo", "databaseUrl"], options.command); + if (options.command === "replay-sources") { + requireOptions(options, ["now", "expectedDigest"], options.command); + if (!/^[a-f0-9]{64}$/.test(options.expectedDigest)) throw new Error("Expected lineage digest must be SHA-256."); + } const input = await readInput(options.input); - const snapshots = { ...input.snapshots, git: await captureGitSource({ repo: options.repo, candidate: input.candidate, capturedAt: options.now }) }; + const snapshots = { ...input.snapshots }; const readers = Object.fromEntries(Object.entries(snapshots).map(([source, snapshot]) => [source, async () => snapshot])); + readers.git = () => captureGitSource({ repo: options.repo, candidate: input.candidate, capturedAt: options.now }); const result = await collectProvenanceSources({ candidate: input.candidate, selection: input.selection, readers, now: options.now }); + if (options.expectedDigest && result.lineage.digest !== options.expectedDigest) { + await writeJsonOutput({ status: "blocked", reason: "Source replay differs from the original record. Reconcile changed or unavailable source evidence before accepting a new record.", expectedDigest: options.expectedDigest, ...result }, options.out); + process.exitCode = 1; + return; + } const store = new LocalProvenanceStore({ databaseUrl: options.databaseUrl }); await store.migrate(); await store.putLineage(result.lineage); From 520cb3966e2fd59a9d974d905a7868016c102a10 Mon Sep 17 00:00:00 2001 From: Hudson Aikins Date: Sat, 12 Sep 2026 19:27:07 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20explicit=20provenance=20verdicts=20?= =?UTF-8?q?and=20safe=20review=20(TAB-23=E2=80=9329)=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: preserve safe source failure reasons in review packets Plane-Work-Item: TAB-23 Entire-Checkpoint: 95aae876ef91 * feat: exact-candidate packets, security, and review (TAB-24–29) (#51) * fix: enforce complete review packet bounds and candidate acceptance Plane-Work-Item: TAB-25 Validates TAB-24 candidate movement and unrelated-history rejection. Entire-Checkpoint: 7098fc8f9b88 * test: validate review packets with the native schema checker Plane-Work-Item: TAB-25 Entire-Checkpoint: 7098fc8f9b88 * feat: exact security evidence, review, and recovery (TAB-26–29) (#52) * feat: bind separate security checks to immutable review candidates Plane-Work-Item: TAB-26 Entire-Checkpoint: 3a4c8131eec1 * ci: require pinned provenance security scanners Plane-Work-Item: TAB-26 Entire-Checkpoint: 3a4c8131eec1 * feat: exact review results, safe publication, and recovery (TAB-27–29) (#53) * feat: show actionable provenance review and GitHub statuses Plane-Work-Item: TAB-27 Entire-Checkpoint: 3a4c8131eec1 * test: demonstrate failure recovery and secured review (TAB-28/TAB-29) (#54) * test: demonstrate provenance failure and clean-store recovery (TAB-28) Entire-Checkpoint: 3a4c8131eec1 * fix: review the secured lineage in the release demo (TAB-29) (#55) * test: generate unsafe token fixture at runtime (TAB-29) Entire-Checkpoint: 3a4c8131eec1 * fix: demonstrate review of the secured provenance lineage (TAB-29) Entire-Checkpoint: 3a4c8131eec1 --------- Co-authored-by: hudsonaikins-crown --------- Co-authored-by: hudsonaikins-crown --------- Co-authored-by: hudsonaikins-crown --------- Co-authored-by: hudsonaikins-crown --------- Co-authored-by: hudsonaikins-crown --------- Co-authored-by: hudsonaikins-crown --- .buildkite/scripts/fallow.sh | 1 + .buildkite/scripts/product-validation.sh | 1 + .buildkite/scripts/security-tools.sh | 47 +++++ .buildkite/scripts/tests.sh | 1 + .github/workflows/product-validation.yml | 2 + .github/workflows/quality.yml | 4 + .tabellio/validators.json | 4 +- README.md | 68 ++++++- package.json | 3 +- schemas/provenance-review-packet.schema.json | 55 ++++++ .../provenance-security-review.schema.json | 179 ++++++++++++++++++ scripts/check-provenance-security.mjs | 19 ++ scripts/demo-provenance.mjs | 128 ++++++++++++- scripts/lib/provenance-ledger.mjs | 10 +- scripts/lib/provenance-review-publication.mjs | 88 +++++++++ scripts/lib/provenance-review-result.mjs | 87 +++++++++ scripts/lib/provenance-security-scanners.mjs | 139 ++++++++++++++ scripts/lib/provenance-security.mjs | 90 +++++++++ scripts/lib/provenance-source-failures.mjs | 17 ++ scripts/lib/provenance-sources.mjs | 7 +- scripts/tabellio-provenance.mjs | 60 +++++- tests/analytics-core.test.mjs | 2 +- tests/provenance-ledger.test.mjs | 71 ++++++- tests/provenance-review-publication.test.mjs | 113 +++++++++++ tests/provenance-review-result.test.mjs | 96 ++++++++++ tests/provenance-security-scanners.test.mjs | 88 +++++++++ tests/provenance-security.test.mjs | 129 +++++++++++++ tests/provenance-sources.test.mjs | 31 ++- 28 files changed, 1512 insertions(+), 28 deletions(-) create mode 100644 .buildkite/scripts/security-tools.sh create mode 100644 schemas/provenance-review-packet.schema.json create mode 100644 schemas/provenance-security-review.schema.json create mode 100644 scripts/check-provenance-security.mjs create mode 100644 scripts/lib/provenance-review-publication.mjs create mode 100644 scripts/lib/provenance-review-result.mjs create mode 100644 scripts/lib/provenance-security-scanners.mjs create mode 100644 scripts/lib/provenance-security.mjs create mode 100644 scripts/lib/provenance-source-failures.mjs create mode 100644 tests/provenance-review-publication.test.mjs create mode 100644 tests/provenance-review-result.test.mjs create mode 100644 tests/provenance-security-scanners.test.mjs create mode 100644 tests/provenance-security.test.mjs diff --git a/.buildkite/scripts/fallow.sh b/.buildkite/scripts/fallow.sh index 34ae582..eb5692f 100755 --- a/.buildkite/scripts/fallow.sh +++ b/.buildkite/scripts/fallow.sh @@ -11,6 +11,7 @@ fi base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-${TABELLIO_BASE_BRANCH:-main}}" git fetch --no-tags origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" +. .buildkite/scripts/security-tools.sh npm install --global fallow@2.89.0 c8@10.1.3 bash .buildkite/scripts/provenance-coverage.sh diff --git a/.buildkite/scripts/product-validation.sh b/.buildkite/scripts/product-validation.sh index f8e4cf1..242f8a0 100755 --- a/.buildkite/scripts/product-validation.sh +++ b/.buildkite/scripts/product-validation.sh @@ -15,6 +15,7 @@ if [[ "$pull_request" == "false" && "$build_context" != "preflight" && "$default fi . .buildkite/scripts/verify-git-toolchain.sh +. .buildkite/scripts/security-tools.sh candidate="${BUILDKITE_COMMIT:-HEAD}" base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-${TABELLIO_BASE_BRANCH:-main}}" diff --git a/.buildkite/scripts/security-tools.sh b/.buildkite/scripts/security-tools.sh new file mode 100644 index 0000000..f958a01 --- /dev/null +++ b/.buildkite/scripts/security-tools.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Source this script to retain PATH in Buildkite. GitHub receives the same paths +# through its runner environment files. No candidate package scripts are run. +if [[ -n "${TABELLIO_SECURITY_TOOLS_DIR:-}" ]]; then + security_tools_root="$TABELLIO_SECURITY_TOOLS_DIR" +else + security_tools_root="$(mktemp -d "${TMPDIR:-/tmp}/tabellio-security-tools.XXXXXX")" +fi +mkdir -p "$security_tools_root" +case "$(uname -s)-$(uname -m)" in + Darwin-arm64) + security_archive_name="gitleaks_8.30.1_darwin_arm64.tar.gz" + security_archive_digest="b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5" + ;; + Linux-x86_64) + security_archive_name="gitleaks_8.30.1_linux_x64.tar.gz" + security_archive_digest="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + ;; + *) printf '%s\n' 'Unsupported security tool platform.' >&2; exit 1 ;; +esac +security_archive_path="$security_tools_root/$security_archive_name" +if [[ ! -f "$security_archive_path" ]]; then + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 120 \ + "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/$security_archive_name" \ + --output "$security_archive_path" +fi +node --input-type=module - "$security_archive_path" "$security_archive_digest" <<'NODE' +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +const actual = createHash('sha256').update(readFileSync(process.argv[2])).digest('hex'); +if (actual !== process.argv[3]) throw new Error('Security scanner archive integrity mismatch.'); +NODE +tar -xzf "$security_archive_path" -C "$security_tools_root" gitleaks +npm install --prefix "$security_tools_root" --no-save --no-package-lock --ignore-scripts \ + --registry https://registry.npmjs.org @ast-grep/cli@0.45.1 +export PATH="$security_tools_root:$security_tools_root/node_modules/.bin:$PATH" +export TABELLIO_GITLEAKS="$security_tools_root/gitleaks" +export TABELLIO_REQUIRE_SECURITY_SCANNERS=1 +test "$(gitleaks version)" = "8.30.1" +test "$(ast-grep --version)" = "ast-grep 0.45.1" +if [[ -n "${GITHUB_PATH:-}" ]]; then + printf '%s\n' "$security_tools_root" "$security_tools_root/node_modules/.bin" >> "$GITHUB_PATH" + printf 'TABELLIO_GITLEAKS=%s\nTABELLIO_REQUIRE_SECURITY_SCANNERS=1\n' "$TABELLIO_GITLEAKS" >> "$GITHUB_ENV" +fi diff --git a/.buildkite/scripts/tests.sh b/.buildkite/scripts/tests.sh index 0bddf21..990be14 100644 --- a/.buildkite/scripts/tests.sh +++ b/.buildkite/scripts/tests.sh @@ -2,6 +2,7 @@ set -euo pipefail . .buildkite/scripts/verify-git-toolchain.sh +. .buildkite/scripts/security-tools.sh npm run check node scripts/write-tabellio-evidence-envelope.mjs --out tabellio-pr-evidence.json node scripts/check-tabellio-evidence-envelope.mjs --evidence tabellio-pr-evidence.json diff --git a/.github/workflows/product-validation.yml b/.github/workflows/product-validation.yml index d7aace2..480bb6c 100644 --- a/.github/workflows/product-validation.yml +++ b/.github/workflows/product-validation.yml @@ -28,6 +28,8 @@ jobs: test -x "$postgres_bin/initdb" test -x "$postgres_bin/pg_ctl" echo "$postgres_bin" >> "$GITHUB_PATH" + - name: Install pinned security scanners + run: bash .buildkite/scripts/security-tools.sh - name: Resolve squash-merge checkpoint revision id: checkpoint if: github.event_name == 'push' diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c4f672d..2963462 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -32,6 +32,8 @@ jobs: with: node-version: 20 package-manager-cache: false + - name: Install pinned security scanners + run: bash .buildkite/scripts/security-tools.sh - name: Run repository checks run: npm run check @@ -49,6 +51,8 @@ jobs: with: node-version: 20 package-manager-cache: false + - name: Install pinned security scanners + run: bash .buildkite/scripts/security-tools.sh - name: Install pinned Fallow run: npm install --global fallow@2.89.0 c8@10.1.3 - name: Measure provenance function and statement coverage diff --git a/.tabellio/validators.json b/.tabellio/validators.json index 77f1062..dca21c9 100644 --- a/.tabellio/validators.json +++ b/.tabellio/validators.json @@ -26,10 +26,10 @@ "summary": "Agent run and review lifecycle examples" }, "security": { - "commands": [["node", "scripts/check-tabellio-external-actions.mjs", "--evidence", "examples/tabellio-evidence/minimal-evidence.json"]], + "commands": [["node", "scripts/check-tabellio-external-actions.mjs", "--evidence", "examples/tabellio-evidence/minimal-evidence.json"], ["node", "scripts/check-provenance-security.mjs"]], "metrics": [{"name": "external_action_policy_pass", "unit": "boolean", "passValue": 1, "failValue": 0}], "cost": {"telemetry": "available", "usd": 0, "modelCalls": 0, "toolCalls": 0}, - "summary": "External-action and side-effect policy checks" + "summary": "External-action policy and real candidate-bound security scanner regressions" }, "analytics-static": { "commands": [["node", "--test", "tests/analytics-core.test.mjs"]], diff --git a/README.md b/README.md index c35b22e..3d47cad 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,45 @@ 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: + +```bash +node scripts/demo-provenance.mjs --verify-security-scanners true +``` + +On Apple Silicon macOS or x86-64 Linux, `. .buildkite/scripts/security-tools.sh` +installs the pinned tools into a temporary directory and exposes them on `PATH`. +Set `TABELLIO_SECURITY_TOOLS_DIR` to reuse a tool directory. The installer verifies +the Gitleaks archive checksum and does not run npm package install scripts. +Configured CI uses this setup before checks; the required security +validator fails when scanners are missing instead of skipping scanner fixtures. +Run the same required check locally with `npm run tabellio:provenance:security:check`. + +The scanner reads immutable Git blobs without running candidate code. It uses +Gitleaks defaults and explicit JavaScript/TypeScript rules for unverified JWT +decoding, unsigned JWT configuration, disabled TLS verification, and dynamic +`eval`. Candidate ignore files and suppression comments cannot grant a pass. +Insecure HTTP dependency references fail; other declared npm dependencies remain +blocked pending vulnerability evidence. These checks cover the listed rules; +they do not establish the absence of all authorization or dependency defects. + +`tabellio-provenance security --input --repo --now