From deeda740ea32e2b7ac68195928c3fbfaaa376471 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Tue, 11 Aug 2026 12:39:37 -0500 Subject: [PATCH 01/14] refactor: share immutable commit-range materialization --- scripts/materialize-commit-range.sh | 119 ++++++++++++++++++++++++++++ scripts/scan-commit-range.sh | 95 ++++------------------ 2 files changed, 135 insertions(+), 79 deletions(-) create mode 100755 scripts/materialize-commit-range.sh diff --git a/scripts/materialize-commit-range.sh b/scripts/materialize-commit-range.sh new file mode 100755 index 0000000..24ff560 --- /dev/null +++ b/scripts/materialize-commit-range.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Materialize complete changed blobs and commit messages for immutable Git commits. +set -euo pipefail + +usage() { + cat >&2 <<'USAGE' +Usage: materialize-commit-range.sh (--range | --include [--exclude ...]) --output [--repo ] + +Options: + --repo Repository to inspect (default: current directory) + --output Empty directory to receive immutable snapshots + --include Commit to scan; repeatable with --exclude + --exclude Reachable history to exclude; repeatable with --include +USAGE + exit 2 +} + +repo=$PWD +output='' +range='' +includes=() +excludes=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --range) range=${2:-}; shift 2 ;; + --repo) repo=${2:-}; shift 2 ;; + --output) output=${2:-}; shift 2 ;; + --include) includes+=("${2:-}"); shift 2 ;; + --exclude) excludes+=("${2:-}"); shift 2 ;; + --help|-h) usage ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; usage ;; + esac +done + +if [[ -z "$output" ]] || { [[ -n "$range" ]] && [[ ${#includes[@]} -gt 0 ]]; } || { [[ -z "$range" ]] && [[ ${#includes[@]} -eq 0 ]]; }; then + usage +fi +[[ ! -e "$output" ]] || { printf 'Output directory already exists: %s\n' "$output" >&2; exit 2; } +git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + printf 'Not a Git work tree: %s\n' "$repo" >&2 + exit 2 +} +if [[ -n "$range" ]]; then + revisions=("$range") +else + revisions=("${includes[@]}") + if [[ ${#excludes[@]} -gt 0 ]]; then + revisions+=(--not "${excludes[@]}") + fi +fi +git -C "$repo" rev-list --quiet "${revisions[@]}" >/dev/null 2>&1 || { + printf 'Invalid or unavailable revisions.\n' >&2 + exit 2 +} + +is_safe_relative_path() { + local path=$1 + [[ -n "$path" && "$path" != /* && "/$path/" != *'/./'* && "/$path/" != *'/../'* && "$path" != *'//' ]] +} + +mkdir -p "$output/commits" +commits_file="$output/commits.txt" +git -C "$repo" rev-list --reverse "${revisions[@]}" >"$commits_file" || { + printf 'Failed to enumerate commits for materialization.\n' >&2 + rm -rf "$output" + exit 2 +} + +while IFS= read -r commit; do + snapshot="$output/commits/$commit" + scan_root="$snapshot/scan" + content="$scan_root/content" + metadata="$scan_root/metadata" + mkdir -p "$content" "$metadata" + paths_file="$snapshot/paths" + git -C "$repo" diff-tree --root --no-commit-id --name-only --diff-filter=d -r -m -z "$commit" >"$paths_file" || { + printf 'Failed to enumerate changed paths in commit %.12s.\n' "$commit" >&2 + exit 2 + } + while IFS= read -r -d '' path; do + is_safe_relative_path "$path" || { + printf 'Unsafe Git path in commit %.12s; refusing to materialize.\n' "$commit" >&2 + exit 2 + } + entry_file="$snapshot/tree-entry" + git -C "$repo" ls-tree -z "$commit" -- ":(literal)$path" >"$entry_file" || { + printf 'Failed to inspect changed path in commit %.12s.\n' "$commit" >&2 + exit 2 + } + IFS= read -r -d '' entry <"$entry_file" || { + printf 'Missing tree entry in commit %.12s.\n' "$commit" >&2 + exit 2 + } + metadata_entry=${entry%%$'\t'*} + read -r mode object_type object_id <<<"$metadata_entry" + if [[ "$mode" == 160000 && "$object_type" == commit ]]; then + continue + fi + [[ "$object_type" == blob ]] || { + printf 'Refusing to skip non-blob path in commit %.12s.\n' "$commit" >&2 + exit 2 + } + git -C "$repo" cat-file -e "$object_id^{blob}" 2>/dev/null || { + printf 'Missing selected blob in commit %.12s.\n' "$commit" >&2 + exit 2 + } + file="$content/$path" + mkdir -p -- "$(dirname "$file")" + git -C "$repo" cat-file blob "$object_id" >"$file" || { + printf 'Failed to materialize changed blob in commit %.12s.\n' "$commit" >&2 + exit 2 + } + done <"$paths_file" + git -C "$repo" log -1 --format=%B "$commit" >"$metadata/commit-message.txt" || { + printf 'Failed to materialize commit message for %.12s.\n' "$commit" >&2 + exit 2 + } +done <"$commits_file" diff --git a/scripts/scan-commit-range.sh b/scripts/scan-commit-range.sh index 489e882..9486ecd 100755 --- a/scripts/scan-commit-range.sh +++ b/scripts/scan-commit-range.sh @@ -1,7 +1,5 @@ #!/usr/bin/env bash -# Scan changed blobs at every commit in RANGE using complete file snapshots. -# This intentionally does not use `gitleaks git`: that command scans patches, -# whose added lines omit unchanged declaration context. +# Scan immutable changed-blob snapshots at every commit in RANGE. set -euo pipefail usage() { @@ -20,6 +18,7 @@ USAGE repo=$PWD config="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.gitleaks.toml" +materializer="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/materialize-commit-range.sh" gitleaks=${GITLEAKS_BIN:-gitleaks} range='' includes=() @@ -66,84 +65,22 @@ command -v "$gitleaks" >/dev/null 2>&1 || { workdir=$(mktemp -d) trap 'rm -rf "$workdir"' EXIT found=0 +materializer_args=(--repo "$repo" --output "$workdir/materialized") +if [[ -n "$range" ]]; then + materializer_args+=(--range "$range") +else + for include in "${includes[@]}"; do + materializer_args+=(--include "$include") + done + for exclude in "${excludes[@]}"; do + materializer_args+=(--exclude "$exclude") + done +fi +"$materializer" "${materializer_args[@]}" -is_safe_relative_path() { - local path=$1 - [[ -n "$path" && "$path" != /* && "/$path/" != *'/./'* && "/$path/" != *'/../'* && "$path" != *'//' ]] -} - -commits_file="$workdir/commits" -git -C "$repo" rev-list --reverse "${revisions[@]}" >"$commits_file" || { - printf 'Failed to enumerate commits for scanning.\n' >&2 - exit 2 -} - -# For each introduced commit, materialize only blobs changed by that commit. -# The complete blob, rather than its patch hunk, preserves nearby declaration -# context; the commit loop catches content later removed in the same push. while IFS= read -r commit; do - snapshot="$workdir/$commit" + snapshot="$workdir/materialized/commits/$commit" scan_root="$snapshot/scan" - content="$scan_root/content" - mkdir -p "$content" - files=0 - paths_file="$snapshot/paths" - git -C "$repo" diff-tree --root --no-commit-id --name-only --diff-filter=d -r -m -z "$commit" >"$paths_file" || { - printf 'Failed to enumerate changed paths in commit %.12s.\n' "$commit" >&2 - exit 2 - } - while IFS= read -r -d '' path; do - is_safe_relative_path "$path" || { - printf 'Unsafe Git path in commit %.12s; refusing to scan.\n' "$commit" >&2 - exit 2 - } - entry_file="$snapshot/tree-entry" - # A Git filename can begin with pathspec magic such as `:(literal)`. - # Force literal interpretation so this exact changed blob is inspected. - git -C "$repo" ls-tree -z "$commit" -- ":(literal)$path" >"$entry_file" || { - printf 'Failed to inspect path %s in commit %.12s.\n' "$path" "$commit" >&2 - exit 2 - } - IFS= read -r -d '' entry <"$entry_file" || { - printf 'Missing tree entry for path %s in commit %.12s.\n' "$path" "$commit" >&2 - exit 2 - } - metadata=${entry%%$'\t'*} - read -r mode object_type object_id <<<"$metadata" - # A gitlink is a commit reference, not superproject file content. Its - # target repository is outside this repository's scanning boundary. - if [[ "$mode" == 160000 && "$object_type" == commit ]]; then - continue - fi - [[ "$object_type" == blob ]] || { - printf 'Refusing to skip non-blob path %s (type %s) in commit %.12s.\n' "$path" "$object_type" "$commit" >&2 - exit 2 - } - git -C "$repo" cat-file -e "$object_id^{blob}" 2>/dev/null || { - printf 'Missing blob for path %s in commit %.12s.\n' "$path" "$commit" >&2 - exit 2 - } - file="$content/$path" - mkdir -p -- "$(dirname "$file")" - git -C "$repo" cat-file blob "$object_id" >"$file" || { - printf 'Failed to materialize path %s in commit %.12s.\n' "$path" "$commit" >&2 - exit 2 - } - files=$((files + 1)) - done <"$paths_file" - - # Keep generated metadata outside `content/`: a repository may legitimately - # contain a file named `.git-commit-message`. - metadata="$scan_root/metadata" - mkdir -p "$metadata" - message_file="$metadata/commit-message.txt" - git -C "$repo" log -1 --format=%B "$commit" >"$message_file" || { - printf 'Failed to materialize commit message for %.12s.\n' "$commit" >&2 - exit 2 - } - files=$((files + 1)) - - [[ $files -gt 0 ]] || continue log="$workdir/gitleaks-$commit.log" set +e (cd "$scan_root" && "$gitleaks" dir --config "$config" --redact=100 --no-banner --no-color \ @@ -163,7 +100,7 @@ while IFS= read -r commit; do exit 2 ;; esac -done <"$commits_file" +done <"$workdir/materialized/commits.txt" if [[ $found -ne 0 ]]; then printf 'Secret scan failed. Remove and rotate the value; removing it in a later commit is insufficient.\n' >&2 From 9d548c17caef306e6302ccd3a9824fba59bf3ae3 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Tue, 11 Aug 2026 13:14:05 -0500 Subject: [PATCH 02/14] feat: add advisory EVM key liveness checks --- .github/workflows/evm-key-scan.yml | 49 +++++ .github/workflows/policy-tests.yml | 12 +- .gitignore | 1 + README.md | 28 ++- scripts/evm-key-liveness/LICENSE | 139 ++++++++++++ scripts/evm-key-liveness/NOTICE | 8 + scripts/evm-key-liveness/package-lock.json | 18 ++ scripts/evm-key-liveness/package.json | 11 + scripts/evm-key-liveness/scan.mjs | 244 +++++++++++++++++++++ tests/evm-key-liveness.test.mjs | 206 +++++++++++++++++ 10 files changed, 709 insertions(+), 7 deletions(-) create mode 100644 scripts/evm-key-liveness/LICENSE create mode 100644 scripts/evm-key-liveness/NOTICE create mode 100644 scripts/evm-key-liveness/package-lock.json create mode 100644 scripts/evm-key-liveness/package.json create mode 100644 scripts/evm-key-liveness/scan.mjs create mode 100644 tests/evm-key-liveness.test.mjs diff --git a/.github/workflows/evm-key-scan.yml b/.github/workflows/evm-key-scan.yml index 6e62874..7ca36c6 100644 --- a/.github/workflows/evm-key-scan.yml +++ b/.github/workflows/evm-key-scan.yml @@ -41,3 +41,52 @@ jobs: --range "$BASE..$HEAD" \ --config "$GITHUB_WORKSPACE/policy/.gitleaks.toml" \ --gitleaks "$RUNNER_TEMP/gitleaks/gitleaks" + + semantic-liveness: + name: semantic liveness (advisory) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out the pull request + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + path: source + fetch-depth: 0 + persist-credentials: false + + - name: Check out the trusted scanner + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: policy + fetch-depth: 1 + persist-credentials: false + + - name: Set up trusted Node runtime + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 + with: + node-version: 22 + + - name: Install verified Gitleaks + run: policy/scripts/install-gitleaks.sh "$RUNNER_TEMP/gitleaks" + + - name: Install pinned liveness dependencies + run: npm ci --ignore-scripts --prefix policy/scripts/evm-key-liveness + + - name: Check advisory EVM key liveness + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + # Public endpoints only. The central policy checkout, never the + # caller checkout or package lifecycle, reads this environment. + EVM_KEY_LIVENESS_RPC_URLS: vana=https://rpc.vana.org,moksha=https://rpc.moksha.vana.org + run: | + policy/scripts/materialize-commit-range.sh \ + --repo "$GITHUB_WORKSPACE/source" \ + --range "$BASE..$HEAD" \ + --output "$RUNNER_TEMP/evm-key-snapshots" + node policy/scripts/evm-key-liveness/scan.mjs \ + --snapshots "$RUNNER_TEMP/evm-key-snapshots" \ + --config "$GITHUB_WORKSPACE/policy/.gitleaks.toml" \ + --gitleaks "$RUNNER_TEMP/gitleaks/gitleaks" diff --git a/.github/workflows/policy-tests.yml b/.github/workflows/policy-tests.yml index 1aa37cf..3a41803 100644 --- a/.github/workflows/policy-tests.yml +++ b/.github/workflows/policy-tests.yml @@ -21,7 +21,17 @@ jobs: - name: Install verified Gitleaks run: scripts/install-gitleaks.sh "$RUNNER_TEMP/gitleaks" + - name: Set up trusted Node runtime + uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 + with: + node-version: 22 + + - name: Install pinned liveness dependencies + run: npm ci --ignore-scripts --prefix scripts/evm-key-liveness + - name: Run scanner regression tests env: GITLEAKS_BIN: ${{ runner.temp }}/gitleaks/gitleaks - run: tests/run.sh + run: | + tests/run.sh + node --test tests/evm-key-liveness.test.mjs diff --git a/.gitignore b/.gitignore index 004d181..db07b88 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .tools/ +node_modules/ diff --git a/README.md b/README.md index 7214a6d..90f5a37 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # vana-com shared GitHub configuration -This repository contains the single trusted implementation of Vana's raw EVM -private-key check. It is deliberately a thin, pinned wrapper around Gitleaks, -not a second secret scanner. +This repository contains Vana's trusted EVM private-key policy. Gitleaks is the +required primary scanner. A separate advisory job enriches only Gitleaks +findings with EVM address derivation and public-chain liveness checks. ## What the CI check does @@ -17,7 +17,16 @@ The rule finds 64-hex-character EVM private-key candidates when they are within two lines of a secret-shaped declaration or stored in a secret-named file such as `private-key`. It intentionally does not scan arbitrary 32-byte hashes. Findings are redacted; the workflow prints a commit ID, never the candidate -value. +value. The advisory job deduplicates the primary candidates across the whole +range before it queries RPC endpoints. It reports active-address findings and +incomplete endpoint checks as warnings; an internal scanner or inventory error +still fails that job. + +The advisory scanner runs only code from the immutable policy checkout. It +materializes caller Git objects as data and does not run caller scripts, install +caller dependencies, or use a caller package lifecycle. Its implementation is +Apache-2.0 because it adapts Vana smart-contracts PR 69 by Maciej; see +[`scripts/evm-key-liveness/NOTICE`](scripts/evm-key-liveness/NOTICE). This release does **not** detect BIP-39 mnemonics. A reliable mnemonic rule must validate the BIP-39 checksum against its word list to avoid flagging normal @@ -103,14 +112,21 @@ still be uploaded, cloned, cached, or indexed. ```bash scripts/install-gitleaks.sh .tools/gitleaks GITLEAKS_BIN=$PWD/.tools/gitleaks/gitleaks tests/run.sh +npm ci --ignore-scripts --prefix scripts/evm-key-liveness +GITLEAKS_BIN=$PWD/.tools/gitleaks/gitleaks node --test tests/evm-key-liveness.test.mjs ``` The test harness covers inline keys, clean hashes, add-then-remove history, split-line declarations, path-and-value exceptions, commit messages, merge resolutions, secret-named files, upstream-vendored-example exception bounds, and fail-closed argument and tool failures. It -creates its own throwaway Git repository. +creates its own throwaway Git repository. The liveness tests use mocked RPC +responses for scalar validation, address derivation, active and inactive +accounts, incomplete checks, redaction, and range materialization. ## License -[MIT](LICENSE) +The repository root is [MIT licensed](LICENSE). The isolated +[`scripts/evm-key-liveness`](scripts/evm-key-liveness) adaptation is +[Apache-2.0 licensed](scripts/evm-key-liveness/LICENSE); its +[NOTICE](scripts/evm-key-liveness/NOTICE) preserves the source attribution. diff --git a/scripts/evm-key-liveness/LICENSE b/scripts/evm-key-liveness/LICENSE new file mode 100644 index 0000000..5751df3 --- /dev/null +++ b/scripts/evm-key-liveness/LICENSE @@ -0,0 +1,139 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work. + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. + + "Contribution" shall mean any work of authorship, including the + original version of the Work and any modifications or additions to + that Work or Derivative Works thereof, that is intentionally submitted + to Licensor for inclusion in the Work by the copyright owner or by an + individual or Legal Entity authorized to submit on behalf of the + copyright owner. + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable by + such Contributor that are necessarily infringed by their Contribution(s) + alone or by combination of their Contribution(s) with the Work to which + such Contribution(s) was submitted. If You institute patent litigation + against any entity alleging that the Work or a Contribution incorporated + within the Work constitutes direct or contributory patent infringement, + then any patent licenses granted to You under this License for that Work + shall terminate as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the Work or + Derivative Works thereof in any medium, with or without modifications, + and in Source or Object form, provided that You meet the following: + + (a) You must give any other recipients of the Work or Derivative Works + a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that + You distribute, all copyright, patent, trademark, and attribution + notices from the Source form of the Work, excluding notices that do + not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained within + such NOTICE file, excluding those notices that do not pertain to any + part of the Derivative Works, in at least one of the following + places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if + provided along with the Derivative Works; or, within a display + generated by the Derivative Works, if and wherever such third-party + notices normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work by + You to the Licensor shall be under the terms and conditions of this + License, without any additional terms or conditions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or agreed to + in writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT + WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, + including, without limitation, any warranties or conditions of TITLE, + NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + You are solely responsible for determining the appropriateness of using + or redistributing the Work and assume any risks associated with Your + exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, whether + in tort (including negligence), contract, or otherwise, unless required + by applicable law or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the Work. + + 9. Accepting Warranty or Additional Liability. While redistributing the + Work or Derivative Works thereof, You may choose to offer, and charge a + fee for, acceptance of support, warranty, indemnity, or other liability + obligations and/or rights consistent with this License. In accepting + such obligations, You may act only on Your own behalf and on Your sole + responsibility, not on behalf of any other Contributor, and only if You + agree to indemnify, defend, and hold each Contributor harmless for any + liability incurred by, or claims asserted against, such Contributor by + reason of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/scripts/evm-key-liveness/NOTICE b/scripts/evm-key-liveness/NOTICE new file mode 100644 index 0000000..4ca9d1d --- /dev/null +++ b/scripts/evm-key-liveness/NOTICE @@ -0,0 +1,8 @@ +EVM key liveness advisory scanner + +Portions adapted from vana-com/vana-smart-contracts pull request #69, authored +by Maciej Witowski, commit edbd95b66e94f82a734b21322ca26f972c904a60. + +The source repository's root LICENSE at that commit is Apache-2.0. This +directory is licensed under Apache-2.0. The surrounding vana-com/.github +repository remains MIT licensed. diff --git a/scripts/evm-key-liveness/package-lock.json b/scripts/evm-key-liveness/package-lock.json new file mode 100644 index 0000000..79aed14 --- /dev/null +++ b/scripts/evm-key-liveness/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "@vana/evm-key-liveness", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { "name": "@vana/evm-key-liveness", "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "ethers": "6.17.0" } }, + "node_modules/@adraffy/ens-normalize": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", "license": "MIT", "dependencies": { "@noble/hashes": "1.3.2" } }, + "node_modules/@noble/hashes": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", "license": "MIT" }, + "node_modules/@types/node": { "version": "22.7.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, + "node_modules/aes-js": { "version": "4.0.0-beta.5", "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", "license": "MIT" }, + "node_modules/ethers": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", "license": "MIT", "dependencies": { "@adraffy/ens-normalize": "1.11.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.2", "@types/node": "22.7.5", "aes-js": "4.0.0-beta.5", "tslib": "2.7.0", "ws": "8.21.0" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/tslib": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "license": "0BSD" }, + "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { "optional": true }, "utf-8-validate": { "optional": true } } } + } +} diff --git a/scripts/evm-key-liveness/package.json b/scripts/evm-key-liveness/package.json new file mode 100644 index 0000000..6a752fc --- /dev/null +++ b/scripts/evm-key-liveness/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vana/evm-key-liveness", + "private": true, + "version": "1.0.0", + "description": "Apache-2.0 advisory EVM liveness enrichment for the central Gitleaks policy", + "license": "Apache-2.0", + "type": "module", + "dependencies": { + "ethers": "6.17.0" + } +} diff --git a/scripts/evm-key-liveness/scan.mjs b/scripts/evm-key-liveness/scan.mjs new file mode 100644 index 0000000..d984fca --- /dev/null +++ b/scripts/evm-key-liveness/scan.mjs @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: Apache-2.0 +// Adapted from vana-com/vana-smart-contracts PR #69 (Maciej Witowski); see NOTICE. +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { computeAddress, SigningKey } from "ethers"; + +export const SECP256K1_N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; + +export function normalizeScalar(candidate) { + if (typeof candidate !== "string") return null; + const hex = candidate.replace(/^0x/i, "").toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(hex)) return null; + const scalar = BigInt(`0x${hex}`); + return scalar > 0n && scalar < SECP256K1_N ? `0x${hex}` : null; +} + +export function deriveAddress(key) { + const scalar = normalizeScalar(key); + if (!scalar) throw new Error("invalid secp256k1 private-key scalar"); + return computeAddress(new SigningKey(scalar).publicKey).toLowerCase(); +} + +function redactScalarLikeText(value) { + return value.replace(/(?:0x)?[0-9a-f]{64}/gi, "[REDACTED]"); +} + +export function sanitizeError(error) { + const raw = error instanceof Error ? error.message : String(error); + return raw + .replace(/https?:\/\/[^\s"'<>)\]},]+/gi, (url) => { + try { return `[${new URL(url).hostname}]`; } catch { return "[rpc]"; } + }) + .replace(/[\x00-\x1f\x7f]/g, " ") + .replace(/(?:0x)?[0-9a-f]{64}/gi, "[REDACTED]") + .slice(0, 200); +} + +function safeName(value) { + return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80) || "unnamed-rpc"; +} + +export function parseRpcUrls(raw) { + if (!raw) return []; + return raw.split(",").filter(Boolean).map((entry) => { + const trimmed = entry.trim(); + const split = trimmed.indexOf("="); + const named = split > 0 && !trimmed.slice(0, split).includes("://"); + const url = named ? trimmed.slice(split + 1).trim() : trimmed; + let hostname; + try { hostname = new URL(url).hostname; } catch { throw new Error("invalid liveness RPC URL"); } + if (!/^https?:$/.test(new URL(url).protocol)) throw new Error("liveness RPC URL must use HTTP(S)"); + return { name: safeName(named ? trimmed.slice(0, split).trim() : hostname), url }; + }); +} + +function candidateFromReport(finding, commit) { + const key = normalizeScalar(finding.Secret); + if (!key) return null; + const file = typeof finding.File === "string" ? finding.File.replace(/^(content|metadata)\//, "") : "unknown"; + const line = Number.isInteger(finding.StartLine) ? finding.StartLine : 0; + return { key, commit, file, line }; +} + +export function collectCandidatesFromReports(reports) { + const byKey = new Map(); + byKey.truncated = false; + for (const { commit, findings } of reports) { + for (const finding of findings) { + const candidate = candidateFromReport(finding, commit); + if (!candidate) continue; + let locations = byKey.get(candidate.key); + if (!locations) { + if (byKey.size >= 128) { byKey.truncated = true; continue; } + locations = []; + byKey.set(candidate.key, locations); + } + if (locations.some((location) => location.commit === candidate.commit && location.file === candidate.file && location.line === candidate.line)) continue; + if (locations.length >= 8) { byKey.truncated = true; continue; } + locations.push(candidate); + } + } + return byKey; +} + +function runGitleaksInventory({ snapshots, gitleaks, config }) { + const reports = []; + const resolvedConfig = resolve(config); + for (const commit of readFileSync(join(snapshots, "commits.txt"), "utf8").trim().split("\n").filter(Boolean)) { + const scanRoot = join(snapshots, "commits", commit, "scan"); + const reportDir = mkdtempSync(join(tmpdir(), "vana-evm-key-report-")); + const reportPath = join(reportDir, "report.json"); + try { + execFileSync(gitleaks, ["dir", "--config", resolvedConfig, "--report-format", "json", "--report-path", reportPath, "--exit-code", "0", "--no-banner", "--no-color", "--ignore-gitleaks-allow", "--log-level", "error", "."], { cwd: scanRoot, stdio: "pipe" }); + reports.push({ commit, findings: JSON.parse(readFileSync(reportPath, "utf8")) }); + } catch { + throw new Error(`Gitleaks candidate inventory failed for commit ${commit.slice(0, 12)}`); + } finally { + rmSync(reportDir, { recursive: true, force: true }); + } + } + return collectCandidatesFromReports(reports); +} + +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function rpcCall(chain, method, params, fetchImpl, timeoutMs) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(chain.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`RPC returned HTTP ${response.status}`); + const payload = await response.json(); + if (payload.error || typeof payload.result !== "string") throw new Error("RPC returned an invalid response"); + return payload.result; + } finally { + clearTimeout(timeout); + } +} + +async function withRetry(operation, attempts, baseDelayMs) { + let error; + for (let attempt = 0; attempt < attempts; attempt++) { + try { return await operation(); } catch (caught) { + error = caught; + if (attempt + 1 < attempts) await sleep(baseDelayMs * 2 ** attempt); + } + } + throw error; +} + +async function mapPool(items, limit, worker) { + const results = Array(items.length); + let cursor = 0; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await worker(items[index]); + } + })); + return results; +} + +export async function findUsedAddresses(addresses, chains, options = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const attempts = options.attempts ?? 3; + const baseDelayMs = options.baseDelayMs ?? 100; + const concurrency = options.concurrency ?? 3; + const timeoutMs = options.timeoutMs ?? 5000; + const used = new Map(); + const status = []; + for (const chain of chains) { + try { + await withRetry(() => rpcCall(chain, "eth_blockNumber", [], fetchImpl, timeoutMs), attempts, baseDelayMs); + } catch (error) { + status.push({ name: chain.name, ok: false, error: sanitizeError(error) }); + continue; + } + let failure; + await mapPool(addresses, concurrency, async (address) => { + try { + const [nonce, balance] = await withRetry(() => Promise.all([ + rpcCall(chain, "eth_getTransactionCount", [address, "latest"], fetchImpl, timeoutMs), + rpcCall(chain, "eth_getBalance", [address, "latest"], fetchImpl, timeoutMs), + ]), attempts, baseDelayMs); + if (BigInt(nonce) > 0n || BigInt(balance) > 0n) used.set(address, [...(used.get(address) ?? []), chain.name]); + } catch (error) { + failure ??= sanitizeError(error); + } + }); + status.push(failure ? { name: chain.name, ok: false, error: failure } : { name: chain.name, ok: true }); + } + return { used, status }; +} + +export function reportAdvisory(candidates, used, status) { + const lines = []; + for (const [key, locations] of candidates) { + const address = deriveAddress(key); + const chains = used.get(address); + if (!chains) continue; + for (const location of locations) lines.push(`warning: active EVM key candidate at ${safeLocation(location.commit).slice(0, 12)}:${safeLocation(location.file)}:${location.line} derives to ${address} (${chains.map(safeName).join(", ")})`); + } + for (const chain of status.filter((entry) => !entry.ok)) lines.push(`warning: incomplete liveness check for ${safeName(String(chain.name))}: ${sanitizeError(chain.error)}`); + return lines; +} + +function safeLocation(value) { + return redactScalarLikeText(value).replace(/[\x00-\x1f\x7f]/g, "_").replace(/::/g, "__").slice(0, 300); +} + +function emitWarning(message) { + const safeMessage = message.replace(/%/g, "%25").replace(/[\r\n]/g, " "); + console.log(`::warning::${safeMessage}`); +} + +export function parseCli(argv) { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!new Set(["--snapshots", "--gitleaks", "--config"]).has(name) || !value || value.startsWith("--")) { + throw new Error("usage: scan.mjs --snapshots --gitleaks --config "); + } + if (values.has(name)) throw new Error("usage: scan.mjs --snapshots --gitleaks --config "); + values.set(name, value); + } + if (values.size !== 3) throw new Error("usage: scan.mjs --snapshots --gitleaks --config "); + return { + snapshots: values.get("--snapshots"), + gitleaks: values.get("--gitleaks"), + config: values.get("--config"), + }; +} + +export async function main(argv = process.argv.slice(2), environment = process.env) { + const { snapshots, gitleaks, config } = parseCli(argv); + const candidates = runGitleaksInventory({ snapshots, gitleaks, config }); + const addresses = [...candidates.keys()].map(deriveAddress); + const chains = parseRpcUrls(environment.EVM_KEY_LIVENESS_RPC_URLS); + if (chains.length === 0) { + emitWarning("liveness scan incomplete: EVM_KEY_LIVENESS_RPC_URLS is unset"); + console.log(`advisory: checked ${candidates.size} unique Gitleaks candidate(s) against 0 RPC endpoint(s)`); + return 0; + } + const { used, status } = await findUsedAddresses(addresses, chains); + for (const line of reportAdvisory(candidates, used, status)) emitWarning(line); + if (candidates.truncated) emitWarning("candidate enrichment was truncated at 128 unique candidates or 8 locations per candidate"); + console.log(`advisory: checked ${candidates.size} unique Gitleaks candidate(s) against ${chains.length} RPC endpoint(s)`); + return 0; +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) { + main().then((status) => process.exit(status)).catch((error) => { + console.error(`EVM liveness scanner error: ${sanitizeError(error)}`); + process.exit(2); + }); +} diff --git a/tests/evm-key-liveness.test.mjs b/tests/evm-key-liveness.test.mjs new file mode 100644 index 0000000..b28e44f --- /dev/null +++ b/tests/evm-key-liveness.test.mjs @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + collectCandidatesFromReports, + deriveAddress, + findUsedAddresses, + normalizeScalar, + parseCli, + reportAdvisory, + sanitizeError, +} from "../scripts/evm-key-liveness/scan.mjs"; + +const key = ["0xd1e5b1a0f6c8e3a94b7f2c5d8e0a3f6b", "9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e4f"].join(""); +const address = "0x0ceb6d5e139c6f79ab76d69a0d81d4ade23f0f3b"; + +function response(result) { + return { ok: true, json: async () => ({ jsonrpc: "2.0", id: 1, result }) }; +} + +test("accepts only valid secp256k1 scalar candidates", () => { + assert.equal(normalizeScalar(key.toUpperCase()), key); + assert.equal(normalizeScalar("0x" + "0".repeat(64)), null); + assert.equal(normalizeScalar("0x" + "f".repeat(64)), null); + assert.equal(normalizeScalar("not a scalar"), null); +}); + +test("derives the Ethereum address controlled by a scalar", () => { + assert.equal(deriveAddress(key), address); +}); + +test("uses only Gitleaks report candidates and deduplicates the full range", () => { + const secondKey = `0x${"0".repeat(63)}1`; + const candidates = collectCandidatesFromReports([ + { commit: "a".repeat(40), findings: [{ Secret: key, File: "content/one.ts", StartLine: 3 }] }, + { commit: "b".repeat(40), findings: [{ Secret: key.slice(2), File: "metadata/commit-message.txt", StartLine: 1 }] }, + { commit: "b".repeat(40), findings: [{ Secret: secondKey, File: "metadata/commit-message.txt", StartLine: 1 }] }, + { commit: "c".repeat(40), findings: [{ Secret: "0x" + "0".repeat(64), File: "content/hash.ts", StartLine: 1 }] }, + ]); + assert.equal(candidates.size, 2); + assert.equal(candidates.get(key).length, 2); + assert.equal(candidates.get(secondKey).length, 1); +}); + +test("caps candidate and location inventory", () => { + const commit = "a".repeat(40); + const findings = Array.from({ length: 129 }, (_, index) => ({ + Secret: `0x${(index + 1).toString(16).padStart(64, "0")}`, + File: "content/keys.ts", + StartLine: index + 1, + })); + const candidates = collectCandidatesFromReports([{ commit, findings }]); + assert.equal(candidates.size, 128); + assert.equal(candidates.truncated, true); + + const repeated = collectCandidatesFromReports([{ + commit, + findings: Array.from({ length: 9 }, (_, index) => ({ Secret: key, File: "content/keys.ts", StartLine: index + 1 })), + }]); + assert.equal(repeated.get(key).length, 8); + assert.equal(repeated.truncated, true); +}); + +test("marks an active RPC address without querying a candidate twice", async () => { + const calls = []; + const fetchImpl = async (_url, request) => { + const { method } = JSON.parse(request.body); + calls.push(method); + if (method === "eth_blockNumber") return response("0x10"); + if (method === "eth_getTransactionCount") return response("0x0"); + return response("0x1"); + }; + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid/token" }], { fetchImpl, attempts: 1, baseDelayMs: 0 }); + assert.deepEqual(result.used.get(address), ["mock"]); + assert.deepEqual(result.status, [{ name: "mock", ok: true }]); + assert.deepEqual(calls, ["eth_blockNumber", "eth_getTransactionCount", "eth_getBalance"]); +}); + +test("marks an inactive RPC address as checked", async () => { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + fetchImpl: async (_url, request) => response(JSON.parse(request.body).method === "eth_blockNumber" ? "0x10" : "0x0"), + attempts: 1, + baseDelayMs: 0, + }); + assert.equal(result.used.has(address), false); + assert.deepEqual(result.status, [{ name: "mock", ok: true }]); +}); + +test("retries transient RPC errors", async () => { + let attempts = 0; + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + fetchImpl: async (_url, request) => { + const { method } = JSON.parse(request.body); + if (method === "eth_blockNumber" && attempts++ === 0) throw new Error("transient failure"); + return response(method === "eth_blockNumber" ? "0x10" : "0x0"); + }, + attempts: 2, + baseDelayMs: 0, + }); + assert.equal(attempts, 2); + assert.deepEqual(result.status, [{ name: "mock", ok: true }]); +}); + +test("records timed-out RPC calls as incomplete", async () => { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + fetchImpl: async (_url, request) => ({ + ok: true, + json: () => new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => reject(new Error("request timed out")), { once: true }); + }), + }), + attempts: 1, + baseDelayMs: 0, + timeoutMs: 5, + }); + assert.deepEqual(result.status, [{ name: "mock", ok: false, error: "request timed out" }]); +}); + +test("records an incomplete RPC check and redacts its URL", async () => { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid/secret" }], { + fetchImpl: async () => { throw new Error("request to https://rpc.example.invalid/secret failed"); }, + attempts: 1, + baseDelayMs: 0, + }); + assert.equal(result.status[0].ok, false); + assert.equal(result.status[0].error.includes("/secret"), false); + assert.equal(result.status[0].error.includes("rpc.example.invalid"), true); +}); + +test("advisory output warns for findings and incompleteness without exposing keys", () => { + const candidates = new Map([[key, [{ commit: "a".repeat(40), file: `source\u001b[2J-${key}`, line: 2 }]]]); + const lines = reportAdvisory(candidates, new Map([[address, ["mock"]]]), [{ name: "mock", ok: false, error: "timeout" }]); + assert.equal(lines.length, 2); + assert.match(lines[0], /warning: active EVM key candidate/); + assert.equal(lines.join("\n").includes(key), false); + assert.equal(lines.join("\n").includes(key.slice(2, 10)), false); + assert.equal(lines.join("\n").includes("\u001b"), false); + assert.match(lines[1], /incomplete liveness check/); + assert.equal(sanitizeError(new Error("https://rpc.example.invalid/private-token")).includes("private-token"), false); + assert.equal(sanitizeError(new Error(`bad RPC result ${key}`)).includes(key.slice(2, 10)), false); +}); + +test("rejects malformed CLI arguments and normalizes accepted names", () => { + assert.deepEqual(parseCli(["--snapshots", "snapshots", "--gitleaks", "gitleaks", "--config", "config"]), { + snapshots: "snapshots", + gitleaks: "gitleaks", + config: "config", + }); + for (const argv of [ + [], + ["--snapshots", "snapshots", "--gitleaks", "gitleaks"], + ["--snapshots", "snapshots", "--gitleaks", "gitleaks", "--config", "config", "--config", "other"], + ["--snapshots", "snapshots", "--unknown", "value", "--config", "config"], + ["--snapshots", "--gitleaks", "gitleaks", "--config", "config"], + ]) assert.throws(() => parseCli(argv), /usage: scan\.mjs/); + + const result = spawnSync("node", ["scripts/evm-key-liveness/scan.mjs", "--snapshots", "snapshots", "--gitleaks", "gitleaks", "--config", "config", "--extra", "value"], { + cwd: process.cwd(), + encoding: "utf8", + }); + assert.equal(result.status, 2); + assert.match(result.stderr, /EVM liveness scanner error: usage: scan\.mjs/); +}); + +test("materializes every commit's full changed blob and commit message", () => { + const root = mkdtempSync(join(tmpdir(), "vana-evm-materializer-test-")); + const repo = join(root, "repo"); + const snapshots = join(root, "snapshots"); + try { + execFileSync("git", ["init", "-q", "-b", "main", repo]); + execFileSync("git", ["-C", repo, "config", "user.name", "test"]); + execFileSync("git", ["-C", repo, "config", "user.email", "test@example.invalid"]); + writeFileSync(join(repo, "key.ts"), "const privateKey =\n"); + execFileSync("git", ["-C", repo, "add", "key.ts"]); + execFileSync("git", ["-C", repo, "commit", "-q", "-m", "base"]); + const base = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + writeFileSync(join(repo, "key.ts"), `const privateKey =\n \"${key}\";\n`); + execFileSync("git", ["-C", repo, "add", "key.ts"]); + execFileSync("git", ["-C", repo, "commit", "-q", "-m", "add key"]); + const added = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + writeFileSync(join(repo, "key.ts"), "export const removed = true;\n"); + execFileSync("git", ["-C", repo, "add", "key.ts"]); + execFileSync("git", ["-C", repo, "commit", "-q", "-m", `remove key ${key}`]); + const removed = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + execFileSync(join(process.cwd(), "scripts/materialize-commit-range.sh"), ["--repo", repo, "--range", `${base}..${removed}`, "--output", snapshots]); + assert.equal(readFileSync(join(snapshots, "commits", added, "scan/content/key.ts"), "utf8").includes("const privateKey ="), true); + assert.equal(readFileSync(join(snapshots, "commits", added, "scan/content/key.ts"), "utf8").includes(key), true); + assert.equal(readFileSync(join(snapshots, "commits", removed, "scan/metadata/commit-message.txt"), "utf8").includes(key), true); + const gitleaks = process.env.GITLEAKS_BIN ?? join(process.cwd(), ".tools/gitleaks/gitleaks"); + const output = execFileSync("node", ["scripts/evm-key-liveness/scan.mjs", "--snapshots", snapshots, "--config", ".gitleaks.toml", "--gitleaks", gitleaks], { + cwd: process.cwd(), + env: { ...process.env, EVM_KEY_LIVENESS_RPC_URLS: "" }, + encoding: "utf8", + }); + assert.match(output, /::warning::liveness scan incomplete/); + assert.match(output, /checked 1 unique Gitleaks candidate/); + assert.equal(output.includes(key), false); + assert.equal(output.includes(key.slice(2, 10)), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 92504f0d8043c36ccde58fe0debc1e39b735c468 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Tue, 11 Aug 2026 13:29:25 -0500 Subject: [PATCH 03/14] fix: harden advisory RPC liveness checks --- .github/workflows/policy-tests.yml | 2 +- README.md | 17 +- scripts/evm-key-liveness/LICENSE | 192 ++++++++++++------ scripts/evm-key-liveness/scan.mjs | 37 ++-- .../evm-key-liveness/scan.test.mjs | 79 +++++-- 5 files changed, 227 insertions(+), 100 deletions(-) rename tests/evm-key-liveness.test.mjs => scripts/evm-key-liveness/scan.test.mjs (76%) diff --git a/.github/workflows/policy-tests.yml b/.github/workflows/policy-tests.yml index 3a41803..fdeb836 100644 --- a/.github/workflows/policy-tests.yml +++ b/.github/workflows/policy-tests.yml @@ -34,4 +34,4 @@ jobs: GITLEAKS_BIN: ${{ runner.temp }}/gitleaks/gitleaks run: | tests/run.sh - node --test tests/evm-key-liveness.test.mjs + node --test scripts/evm-key-liveness/scan.test.mjs diff --git a/README.md b/README.md index 90f5a37..fbaf625 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # vana-com shared GitHub configuration This repository contains Vana's trusted EVM private-key policy. Gitleaks is the -required primary scanner. A separate advisory job enriches only Gitleaks +authoritative primary scanner. A separate advisory job enriches only Gitleaks findings with EVM address derivation and public-chain liveness checks. ## What the CI check does @@ -51,10 +51,11 @@ jobs: ``` Replace `` with the 40-character commit ID of a reviewed -release. After the first successful run, make its observed scan job a required -status check. This pin is immutable, so all repositories run the reviewed -central implementation. Keep the small caller workflow code-owned, so a pull -request cannot change the central pin without the security owner's review. +release. This pin is immutable, so all repositories run the reviewed central +implementation. Keep the small caller workflow code-owned, so a pull request +cannot change the central pin without the security owner's review. The Gitleaks +scan is the authoritative job; whether it is required for merge remains a +separate, approved branch-protection decision in each caller repository. The workflow pins Gitleaks `v8.30.1` and verifies the downloaded archive's SHA-256 before executing it. Action references use immutable commit IDs. It @@ -113,14 +114,14 @@ still be uploaded, cloned, cached, or indexed. scripts/install-gitleaks.sh .tools/gitleaks GITLEAKS_BIN=$PWD/.tools/gitleaks/gitleaks tests/run.sh npm ci --ignore-scripts --prefix scripts/evm-key-liveness -GITLEAKS_BIN=$PWD/.tools/gitleaks/gitleaks node --test tests/evm-key-liveness.test.mjs +GITLEAKS_BIN=$PWD/.tools/gitleaks/gitleaks node --test scripts/evm-key-liveness/scan.test.mjs ``` The test harness covers inline keys, clean hashes, add-then-remove history, split-line declarations, path-and-value exceptions, commit messages, merge resolutions, secret-named files, upstream-vendored-example exception bounds, -and fail-closed argument and tool failures. It -creates its own throwaway Git repository. The liveness tests use mocked RPC +and fail-closed argument and tool failures. It creates its own throwaway Git +repository. The liveness tests use mocked RPC responses for scalar validation, address derivation, active and inactive accounts, incomplete checks, redaction, and range materialization. diff --git a/scripts/evm-key-liveness/LICENSE b/scripts/evm-key-liveness/LICENSE index 5751df3..261eeb9 100644 --- a/scripts/evm-key-liveness/LICENSE +++ b/scripts/evm-key-liveness/LICENSE @@ -34,19 +34,30 @@ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work. + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. - - "Contribution" shall mean any work of authorship, including the - original version of the Work and any modifications or additions to - that Work or Derivative Works thereof, that is intentionally submitted - to Licensor for inclusion in the Work by the copyright owner or by an - individual or Legal Entity authorized to submit on behalf of the - copyright owner. + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and @@ -59,81 +70,132 @@ publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - 3. Grant of Patent License. Subject to the terms and conditions of this - License, each Contributor hereby grants to You a perpetual, + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable by - such Contributor that are necessarily infringed by their Contribution(s) - alone or by combination of their Contribution(s) with the Work to which - such Contribution(s) was submitted. If You institute patent litigation - against any entity alleging that the Work or a Contribution incorporated - within the Work constitutes direct or contributory patent infringement, - then any patent licenses granted to You under this License for that Work - shall terminate as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the Work or - Derivative Works thereof in any medium, with or without modifications, - and in Source or Object form, provided that You meet the following: - - (a) You must give any other recipients of the Work or Derivative Works - a copy of this License; and + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and - (c) You must retain, in the Source form of any Derivative Works that - You distribute, all copyright, patent, trademark, and attribution - notices from the Source form of the Work, excluding notices that do - not pertain to any part of the Derivative Works; and + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained within - such NOTICE file, excluding those notices that do not pertain to any - part of the Derivative Works, in at least one of the following - places: within a NOTICE text file distributed as part of the - Derivative Works; within the Source form or documentation, if - provided along with the Derivative Works; or, within a display - generated by the Derivative Works, if and wherever such third-party - notices normally appear. The contents of the NOTICE file are for - informational purposes only and do not modify the License. + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work by - You to the Licensor shall be under the terms and conditions of this - License, without any additional terms or conditions. + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - 7. Disclaimer of Warranty. Unless required by applicable law or agreed to - in writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT - WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, - including, without limitation, any warranties or conditions of TITLE, - NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - You are solely responsible for determining the appropriateness of using - or redistributing the Work and assume any risks associated with Your - exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, whether - in tort (including negligence), contract, or otherwise, unless required - by applicable law or agreed to in writing, shall any Contributor be + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the Work. - - 9. Accepting Warranty or Additional Liability. While redistributing the - Work or Derivative Works thereof, You may choose to offer, and charge a - fee for, acceptance of support, warranty, indemnity, or other liability - obligations and/or rights consistent with this License. In accepting - such obligations, You may act only on Your own behalf and on Your sole - responsibility, not on behalf of any other Contributor, and only if You - agree to indemnify, defend, and hold each Contributor harmless for any - liability incurred by, or claims asserted against, such Contributor by - reason of your accepting any such warranty or additional liability. + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/scripts/evm-key-liveness/scan.mjs b/scripts/evm-key-liveness/scan.mjs index d984fca..b8fa91f 100644 --- a/scripts/evm-key-liveness/scan.mjs +++ b/scripts/evm-key-liveness/scan.mjs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Adapted from vana-com/vana-smart-contracts PR #69 (Maciej Witowski); see NOTICE. import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; @@ -105,19 +106,21 @@ function runGitleaksInventory({ snapshots, gitleaks, config }) { const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); -async function rpcCall(chain, method, params, fetchImpl, timeoutMs) { +async function rpcCall(chain, method, params, fetchImpl, timeoutMs, requestId) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchImpl(chain.url, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }), signal: controller.signal, }); if (!response.ok) throw new Error(`RPC returned HTTP ${response.status}`); const payload = await response.json(); - if (payload.error || typeof payload.result !== "string") throw new Error("RPC returned an invalid response"); + if (payload?.jsonrpc !== "2.0" || payload.id !== requestId || Object.hasOwn(payload, "error") || typeof payload.result !== "string") { + throw new Error("RPC returned an invalid response"); + } return payload.result; } finally { clearTimeout(timeout); @@ -153,26 +156,28 @@ export async function findUsedAddresses(addresses, chains, options = {}) { const baseDelayMs = options.baseDelayMs ?? 100; const concurrency = options.concurrency ?? 3; const timeoutMs = options.timeoutMs ?? 5000; + let nextRequestId = 1; + const callRpc = (chain, method, params) => rpcCall(chain, method, params, fetchImpl, timeoutMs, nextRequestId++); const used = new Map(); const status = []; for (const chain of chains) { try { - await withRetry(() => rpcCall(chain, "eth_blockNumber", [], fetchImpl, timeoutMs), attempts, baseDelayMs); + await withRetry(() => callRpc(chain, "eth_blockNumber", []), attempts, baseDelayMs); } catch (error) { status.push({ name: chain.name, ok: false, error: sanitizeError(error) }); continue; } let failure; await mapPool(addresses, concurrency, async (address) => { - try { - const [nonce, balance] = await withRetry(() => Promise.all([ - rpcCall(chain, "eth_getTransactionCount", [address, "latest"], fetchImpl, timeoutMs), - rpcCall(chain, "eth_getBalance", [address, "latest"], fetchImpl, timeoutMs), - ]), attempts, baseDelayMs); - if (BigInt(nonce) > 0n || BigInt(balance) > 0n) used.set(address, [...(used.get(address) ?? []), chain.name]); - } catch (error) { - failure ??= sanitizeError(error); + const [nonce, balance] = await Promise.allSettled([ + withRetry(async () => BigInt(await callRpc(chain, "eth_getTransactionCount", [address, "latest"])), attempts, baseDelayMs), + withRetry(async () => BigInt(await callRpc(chain, "eth_getBalance", [address, "latest"])), attempts, baseDelayMs), + ]); + if ((nonce.status === "fulfilled" && nonce.value > 0n) || (balance.status === "fulfilled" && balance.value > 0n)) { + used.set(address, [...(used.get(address) ?? []), chain.name]); } + const rejected = [nonce, balance].find((result) => result.status === "rejected"); + if (rejected?.status === "rejected") failure ??= sanitizeError(rejected.reason); }); status.push(failure ? { name: chain.name, ok: false, error: failure } : { name: chain.name, ok: true }); } @@ -185,9 +190,9 @@ export function reportAdvisory(candidates, used, status) { const address = deriveAddress(key); const chains = used.get(address); if (!chains) continue; - for (const location of locations) lines.push(`warning: active EVM key candidate at ${safeLocation(location.commit).slice(0, 12)}:${safeLocation(location.file)}:${location.line} derives to ${address} (${chains.map(safeName).join(", ")})`); + for (const location of locations) lines.push(`active EVM key candidate at ${safeLocation(location.commit).slice(0, 12)}:path-sha256=${pathDigest(location.file)}:${location.line} derives to ${address} (${chains.map(safeName).join(", ")})`); } - for (const chain of status.filter((entry) => !entry.ok)) lines.push(`warning: incomplete liveness check for ${safeName(String(chain.name))}: ${sanitizeError(chain.error)}`); + for (const chain of status.filter((entry) => !entry.ok)) lines.push(`incomplete liveness check for ${safeName(String(chain.name))}: ${sanitizeError(chain.error)}`); return lines; } @@ -195,6 +200,10 @@ function safeLocation(value) { return redactScalarLikeText(value).replace(/[\x00-\x1f\x7f]/g, "_").replace(/::/g, "__").slice(0, 300); } +function pathDigest(value) { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + function emitWarning(message) { const safeMessage = message.replace(/%/g, "%25").replace(/[\r\n]/g, " "); console.log(`::warning::${safeMessage}`); diff --git a/tests/evm-key-liveness.test.mjs b/scripts/evm-key-liveness/scan.test.mjs similarity index 76% rename from tests/evm-key-liveness.test.mjs rename to scripts/evm-key-liveness/scan.test.mjs index b28e44f..753bb6d 100644 --- a/tests/evm-key-liveness.test.mjs +++ b/scripts/evm-key-liveness/scan.test.mjs @@ -13,13 +13,13 @@ import { parseCli, reportAdvisory, sanitizeError, -} from "../scripts/evm-key-liveness/scan.mjs"; +} from "./scan.mjs"; const key = ["0xd1e5b1a0f6c8e3a94b7f2c5d8e0a3f6b", "9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e4f"].join(""); const address = "0x0ceb6d5e139c6f79ab76d69a0d81d4ade23f0f3b"; -function response(result) { - return { ok: true, json: async () => ({ jsonrpc: "2.0", id: 1, result }) }; +function response(result, id) { + return { ok: true, json: async () => ({ jsonrpc: "2.0", id, result }) }; } test("accepts only valid secp256k1 scalar candidates", () => { @@ -68,11 +68,11 @@ test("caps candidate and location inventory", () => { test("marks an active RPC address without querying a candidate twice", async () => { const calls = []; const fetchImpl = async (_url, request) => { - const { method } = JSON.parse(request.body); + const { method, id } = JSON.parse(request.body); calls.push(method); - if (method === "eth_blockNumber") return response("0x10"); - if (method === "eth_getTransactionCount") return response("0x0"); - return response("0x1"); + if (method === "eth_blockNumber") return response("0x10", id); + if (method === "eth_getTransactionCount") return response("0x0", id); + return response("0x1", id); }; const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid/token" }], { fetchImpl, attempts: 1, baseDelayMs: 0 }); assert.deepEqual(result.used.get(address), ["mock"]); @@ -82,7 +82,10 @@ test("marks an active RPC address without querying a candidate twice", async () test("marks an inactive RPC address as checked", async () => { const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { - fetchImpl: async (_url, request) => response(JSON.parse(request.body).method === "eth_blockNumber" ? "0x10" : "0x0"), + fetchImpl: async (_url, request) => { + const { method, id } = JSON.parse(request.body); + return response(method === "eth_blockNumber" ? "0x10" : "0x0", id); + }, attempts: 1, baseDelayMs: 0, }); @@ -94,9 +97,9 @@ test("retries transient RPC errors", async () => { let attempts = 0; const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { - const { method } = JSON.parse(request.body); + const { method, id } = JSON.parse(request.body); if (method === "eth_blockNumber" && attempts++ === 0) throw new Error("transient failure"); - return response(method === "eth_blockNumber" ? "0x10" : "0x0"); + return response(method === "eth_blockNumber" ? "0x10" : "0x0", id); }, attempts: 2, baseDelayMs: 0, @@ -105,6 +108,53 @@ test("retries transient RPC errors", async () => { assert.deepEqual(result.status, [{ name: "mock", ok: true }]); }); +test("rejects malformed JSON-RPC envelopes", async () => { + for (const payload of [ + { jsonrpc: "1.0", id: 1, result: "0x10" }, + { jsonrpc: "2.0", id: 2, result: "0x10" }, + { jsonrpc: "2.0", id: 1, result: null }, + { jsonrpc: "2.0", id: 1, error: { code: -32000, message: "mock failure" } }, + ]) { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + fetchImpl: async () => ({ ok: true, json: async () => payload }), + attempts: 1, + baseDelayMs: 0, + }); + assert.deepEqual(result.status, [{ name: "mock", ok: false, error: "RPC returned an invalid response" }]); + } +}); + +test("retains a positive nonce when the balance request fails", async () => { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + fetchImpl: async (_url, request) => { + const { method, id } = JSON.parse(request.body); + if (method === "eth_blockNumber") return response("0x10", id); + if (method === "eth_getTransactionCount") return response("0x1", id); + throw new Error("balance request failed"); + }, + attempts: 1, + baseDelayMs: 0, + }); + assert.deepEqual(result.used.get(address), ["mock"]); + assert.deepEqual(result.status, [{ name: "mock", ok: false, error: "balance request failed" }]); +}); + +test("retains a positive balance when the nonce result is malformed", async () => { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + fetchImpl: async (_url, request) => { + const { method, id } = JSON.parse(request.body); + if (method === "eth_blockNumber") return response("0x10", id); + if (method === "eth_getTransactionCount") return response("not-a-quantity", id); + return response("0x1", id); + }, + attempts: 1, + baseDelayMs: 0, + }); + assert.deepEqual(result.used.get(address), ["mock"]); + assert.equal(result.status[0].ok, false); + assert.match(result.status[0].error, /Cannot convert/); +}); + test("records timed-out RPC calls as incomplete", async () => { const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => ({ @@ -132,12 +182,17 @@ test("records an incomplete RPC check and redacts its URL", async () => { }); test("advisory output warns for findings and incompleteness without exposing keys", () => { - const candidates = new Map([[key, [{ commit: "a".repeat(40), file: `source\u001b[2J-${key}`, line: 2 }]]]); + const firstHalf = key.slice(2, 34); + const secondHalf = key.slice(34); + const candidates = new Map([[key, [{ commit: "a".repeat(40), file: `source\u001b[2J-full-${key}/halves-${firstHalf}-${secondHalf}`, line: 2 }]]]); const lines = reportAdvisory(candidates, new Map([[address, ["mock"]]]), [{ name: "mock", ok: false, error: "timeout" }]); assert.equal(lines.length, 2); - assert.match(lines[0], /warning: active EVM key candidate/); + assert.match(lines[0], /active EVM key candidate/); + assert.match(lines[0], /path-sha256=[0-9a-f]{16}/); assert.equal(lines.join("\n").includes(key), false); assert.equal(lines.join("\n").includes(key.slice(2, 10)), false); + assert.equal(lines.join("\n").includes(firstHalf), false); + assert.equal(lines.join("\n").includes(secondHalf), false); assert.equal(lines.join("\n").includes("\u001b"), false); assert.match(lines[1], /incomplete liveness check/); assert.equal(sanitizeError(new Error("https://rpc.example.invalid/private-token")).includes("private-token"), false); From 4a8f38ef4478d12a1719ab4868075c07ed050181 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Tue, 11 Aug 2026 13:35:57 -0500 Subject: [PATCH 04/14] fix: validate EVM RPC quantities --- scripts/evm-key-liveness/scan.mjs | 13 ++++++-- scripts/evm-key-liveness/scan.test.mjs | 46 ++++++++++++++++++-------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/scripts/evm-key-liveness/scan.mjs b/scripts/evm-key-liveness/scan.mjs index b8fa91f..af0ec8f 100644 --- a/scripts/evm-key-liveness/scan.mjs +++ b/scripts/evm-key-liveness/scan.mjs @@ -127,6 +127,13 @@ async function rpcCall(chain, method, params, fetchImpl, timeoutMs, requestId) { } } +export function parseEthereumQuantity(value) { + if (typeof value !== "string" || !/^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/.test(value)) { + throw new Error("RPC returned an invalid quantity"); + } + return BigInt(value); +} + async function withRetry(operation, attempts, baseDelayMs) { let error; for (let attempt = 0; attempt < attempts; attempt++) { @@ -162,7 +169,7 @@ export async function findUsedAddresses(addresses, chains, options = {}) { const status = []; for (const chain of chains) { try { - await withRetry(() => callRpc(chain, "eth_blockNumber", []), attempts, baseDelayMs); + await withRetry(async () => parseEthereumQuantity(await callRpc(chain, "eth_blockNumber", [])), attempts, baseDelayMs); } catch (error) { status.push({ name: chain.name, ok: false, error: sanitizeError(error) }); continue; @@ -170,8 +177,8 @@ export async function findUsedAddresses(addresses, chains, options = {}) { let failure; await mapPool(addresses, concurrency, async (address) => { const [nonce, balance] = await Promise.allSettled([ - withRetry(async () => BigInt(await callRpc(chain, "eth_getTransactionCount", [address, "latest"])), attempts, baseDelayMs), - withRetry(async () => BigInt(await callRpc(chain, "eth_getBalance", [address, "latest"])), attempts, baseDelayMs), + withRetry(async () => parseEthereumQuantity(await callRpc(chain, "eth_getTransactionCount", [address, "latest"])), attempts, baseDelayMs), + withRetry(async () => parseEthereumQuantity(await callRpc(chain, "eth_getBalance", [address, "latest"])), attempts, baseDelayMs), ]); if ((nonce.status === "fulfilled" && nonce.value > 0n) || (balance.status === "fulfilled" && balance.value > 0n)) { used.set(address, [...(used.get(address) ?? []), chain.name]); diff --git a/scripts/evm-key-liveness/scan.test.mjs b/scripts/evm-key-liveness/scan.test.mjs index 753bb6d..4f0e02b 100644 --- a/scripts/evm-key-liveness/scan.test.mjs +++ b/scripts/evm-key-liveness/scan.test.mjs @@ -10,6 +10,7 @@ import { deriveAddress, findUsedAddresses, normalizeScalar, + parseEthereumQuantity, parseCli, reportAdvisory, sanitizeError, @@ -17,6 +18,7 @@ import { const key = ["0xd1e5b1a0f6c8e3a94b7f2c5d8e0a3f6b", "9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e4f"].join(""); const address = "0x0ceb6d5e139c6f79ab76d69a0d81d4ade23f0f3b"; +const addresses = Array.of(address); function response(result, id) { return { ok: true, json: async () => ({ jsonrpc: "2.0", id, result }) }; @@ -33,6 +35,15 @@ test("derives the Ethereum address controlled by a scalar", () => { assert.equal(deriveAddress(key), address); }); +test("accepts only canonical Ethereum JSON-RPC quantities", () => { + assert.equal(parseEthereumQuantity("0x0"), 0n); + assert.equal(parseEthereumQuantity("0x1"), 1n); + assert.equal(parseEthereumQuantity("0xA"), 10n); + for (const value of ["not-a-quantity", 1, -1, "0x", "0x00", "0x01", "0X1", null]) { + assert.throws(() => parseEthereumQuantity(value), /RPC returned an invalid quantity/); + } +}); + test("uses only Gitleaks report candidates and deduplicates the full range", () => { const secondKey = `0x${"0".repeat(63)}1`; const candidates = collectCandidatesFromReports([ @@ -74,14 +85,14 @@ test("marks an active RPC address without querying a candidate twice", async () if (method === "eth_getTransactionCount") return response("0x0", id); return response("0x1", id); }; - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid/token" }], { fetchImpl, attempts: 1, baseDelayMs: 0 }); + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid/token" }], { fetchImpl, attempts: 1, baseDelayMs: 0 }); assert.deepEqual(result.used.get(address), ["mock"]); assert.deepEqual(result.status, [{ name: "mock", ok: true }]); assert.deepEqual(calls, ["eth_blockNumber", "eth_getTransactionCount", "eth_getBalance"]); }); test("marks an inactive RPC address as checked", async () => { - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); return response(method === "eth_blockNumber" ? "0x10" : "0x0", id); @@ -95,7 +106,7 @@ test("marks an inactive RPC address as checked", async () => { test("retries transient RPC errors", async () => { let attempts = 0; - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); if (method === "eth_blockNumber" && attempts++ === 0) throw new Error("transient failure"); @@ -115,7 +126,7 @@ test("rejects malformed JSON-RPC envelopes", async () => { { jsonrpc: "2.0", id: 1, result: null }, { jsonrpc: "2.0", id: 1, error: { code: -32000, message: "mock failure" } }, ]) { - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async () => ({ ok: true, json: async () => payload }), attempts: 1, baseDelayMs: 0, @@ -124,27 +135,36 @@ test("rejects malformed JSON-RPC envelopes", async () => { } }); -test("retains a positive nonce when the balance request fails", async () => { - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { +test("rejects a noncanonical block-number quantity", async () => { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + fetchImpl: async (_url, request) => response("0x01", JSON.parse(request.body).id), + attempts: 1, + baseDelayMs: 0, + }); + assert.deepEqual(result.status, [{ name: "mock", ok: false, error: "RPC returned an invalid quantity" }]); +}); + +test("retains a positive nonce when the balance quantity is malformed", async () => { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); if (method === "eth_blockNumber") return response("0x10", id); if (method === "eth_getTransactionCount") return response("0x1", id); - throw new Error("balance request failed"); + return response("0x01", id); }, attempts: 1, baseDelayMs: 0, }); assert.deepEqual(result.used.get(address), ["mock"]); - assert.deepEqual(result.status, [{ name: "mock", ok: false, error: "balance request failed" }]); + assert.deepEqual(result.status, [{ name: "mock", ok: false, error: "RPC returned an invalid quantity" }]); }); test("retains a positive balance when the nonce result is malformed", async () => { - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); if (method === "eth_blockNumber") return response("0x10", id); - if (method === "eth_getTransactionCount") return response("not-a-quantity", id); + if (method === "eth_getTransactionCount") return response("0x01", id); return response("0x1", id); }, attempts: 1, @@ -152,11 +172,11 @@ test("retains a positive balance when the nonce result is malformed", async () = }); assert.deepEqual(result.used.get(address), ["mock"]); assert.equal(result.status[0].ok, false); - assert.match(result.status[0].error, /Cannot convert/); + assert.equal(result.status[0].error, "RPC returned an invalid quantity"); }); test("records timed-out RPC calls as incomplete", async () => { - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => ({ ok: true, json: () => new Promise((_resolve, reject) => { @@ -171,7 +191,7 @@ test("records timed-out RPC calls as incomplete", async () => { }); test("records an incomplete RPC check and redacts its URL", async () => { - const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid/secret" }], { + const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid/secret" }], { fetchImpl: async () => { throw new Error("request to https://rpc.example.invalid/secret failed"); }, attempts: 1, baseDelayMs: 0, From 4df1cd184b9b7ef12532d222a43f386f54a6d151 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Tue, 11 Aug 2026 13:37:21 -0500 Subject: [PATCH 05/14] test: keep liveness fixtures idiomatic --- scripts/evm-key-liveness/scan.test.mjs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/scripts/evm-key-liveness/scan.test.mjs b/scripts/evm-key-liveness/scan.test.mjs index 4f0e02b..b09a09d 100644 --- a/scripts/evm-key-liveness/scan.test.mjs +++ b/scripts/evm-key-liveness/scan.test.mjs @@ -18,7 +18,6 @@ import { const key = ["0xd1e5b1a0f6c8e3a94b7f2c5d8e0a3f6b", "9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e4f"].join(""); const address = "0x0ceb6d5e139c6f79ab76d69a0d81d4ade23f0f3b"; -const addresses = Array.of(address); function response(result, id) { return { ok: true, json: async () => ({ jsonrpc: "2.0", id, result }) }; @@ -85,14 +84,14 @@ test("marks an active RPC address without querying a candidate twice", async () if (method === "eth_getTransactionCount") return response("0x0", id); return response("0x1", id); }; - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid/token" }], { fetchImpl, attempts: 1, baseDelayMs: 0 }); + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid/token" }], { fetchImpl, attempts: 1, baseDelayMs: 0 }); assert.deepEqual(result.used.get(address), ["mock"]); assert.deepEqual(result.status, [{ name: "mock", ok: true }]); assert.deepEqual(calls, ["eth_blockNumber", "eth_getTransactionCount", "eth_getBalance"]); }); test("marks an inactive RPC address as checked", async () => { - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); return response(method === "eth_blockNumber" ? "0x10" : "0x0", id); @@ -106,7 +105,7 @@ test("marks an inactive RPC address as checked", async () => { test("retries transient RPC errors", async () => { let attempts = 0; - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); if (method === "eth_blockNumber" && attempts++ === 0) throw new Error("transient failure"); @@ -126,7 +125,7 @@ test("rejects malformed JSON-RPC envelopes", async () => { { jsonrpc: "2.0", id: 1, result: null }, { jsonrpc: "2.0", id: 1, error: { code: -32000, message: "mock failure" } }, ]) { - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async () => ({ ok: true, json: async () => payload }), attempts: 1, baseDelayMs: 0, @@ -136,7 +135,7 @@ test("rejects malformed JSON-RPC envelopes", async () => { }); test("rejects a noncanonical block-number quantity", async () => { - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => response("0x01", JSON.parse(request.body).id), attempts: 1, baseDelayMs: 0, @@ -145,7 +144,7 @@ test("rejects a noncanonical block-number quantity", async () => { }); test("retains a positive nonce when the balance quantity is malformed", async () => { - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); if (method === "eth_blockNumber") return response("0x10", id); @@ -160,7 +159,7 @@ test("retains a positive nonce when the balance quantity is malformed", async () }); test("retains a positive balance when the nonce result is malformed", async () => { - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => { const { method, id } = JSON.parse(request.body); if (method === "eth_blockNumber") return response("0x10", id); @@ -176,7 +175,7 @@ test("retains a positive balance when the nonce result is malformed", async () = }); test("records timed-out RPC calls as incomplete", async () => { - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid" }], { fetchImpl: async (_url, request) => ({ ok: true, json: () => new Promise((_resolve, reject) => { @@ -191,7 +190,7 @@ test("records timed-out RPC calls as incomplete", async () => { }); test("records an incomplete RPC check and redacts its URL", async () => { - const result = await findUsedAddresses(addresses, [{ name: "mock", url: "https://rpc.example.invalid/secret" }], { + const result = await findUsedAddresses([address], [{ name: "mock", url: "https://rpc.example.invalid/secret" }], { fetchImpl: async () => { throw new Error("request to https://rpc.example.invalid/secret failed"); }, attempts: 1, baseDelayMs: 0, From 552439a7fce4baa9cfc5a77365a84c5e6d0666ad Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:25:38 -0500 Subject: [PATCH 06/14] Add pinned pre-push hook installer --- README.md | 31 +++++++---- hooks/pre-push | 31 +++++++++++ scripts/install-gitleaks.sh | 5 ++ scripts/install-pre-push.sh | 106 +++++++++++++++++++++++++++++++++--- tests/run.sh | 28 ++++++++-- 5 files changed, 177 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fbaf625..d97d879 100644 --- a/README.md +++ b/README.md @@ -69,21 +69,30 @@ Git hooks are not policy: they can be missing, stale, or bypassed with The hook is still useful because it stops an accidental public leak before it leaves the workstation. -Clone this repository at the same reviewed release commit somewhere durable, -then: +Clone this repository at the same reviewed release commit somewhere durable. +From that checkout, run: ```bash -scripts/install-pre-push.sh --shared-dir /path/to/vana-dotgithub-build --repo /path/to/public-repo +scripts/install-pre-push.sh --repo /path/to/public-repo --ref ``` -The installer refuses to overwrite an existing hook. Use a hook manager or -merge the launcher deliberately when another pre-push hook already exists. It -records the selected shared checkout in the installed launcher, so no shell -environment setup is required. -The hook runs offline after its first verified Gitleaks download and sends no -source or candidate values over the network. For a new remote branch, it scans -only commits not reachable from locally fetched `refs/remotes/` tips; -run `git fetch ` first if those refs may be stale. +The installer requires the selected checkout to come from `vana-com/.github`, +match the exact release SHA, and have no local changes. It downloads the pinned +Gitleaks binary during install, stores a checksum receipt, and records the +selected policy checkout in a managed launcher. No shell environment setup is +required. Re-running the command updates the managed launcher for the same +repository. Use `status` to inspect it and `uninstall` to remove it. + +The installer refuses to overwrite or remove an unmanaged hook. Use a hook +manager or merge the launcher deliberately when another pre-push hook already +exists. + +The hook verifies the policy checkout SHA, verifies that the checkout has no +local changes, and verifies the Gitleaks binary checksum before each push. It +runs offline after installation and sends no source or candidate values over the +network. For a new remote branch, it scans only commits not reachable from +locally fetched `refs/remotes/` tips; run `git fetch ` first if +those refs may be stale. ## False positives and remediation diff --git a/hooks/pre-push b/hooks/pre-push index eb588be..fb811b1 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -8,6 +8,11 @@ if [[ -z "$shared_dir" ]]; then printf 'VANA_SECRET_SCAN_HOME is unset; skipping optional EVM key scan.\n' >&2 exit 0 fi +expected_sha=${VANA_SECRET_SCAN_EXPECTED_SHA:-} +if [[ -z "$expected_sha" || ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then + printf 'VANA_SECRET_SCAN_EXPECTED_SHA is unset or invalid; refusing to scan with unpinned policy.\n' >&2 + exit 2 +fi scanner="$shared_dir/scripts/scan-commit-range.sh" installer="$shared_dir/scripts/install-gitleaks.sh" @@ -15,11 +20,37 @@ installer="$shared_dir/scripts/install-gitleaks.sh" printf 'VANA_SECRET_SCAN_HOME is not a valid shared scanner checkout.\n' >&2 exit 2 } +actual_sha=$(git -C "$shared_dir" rev-parse HEAD) +if [[ "$actual_sha" != "$expected_sha" ]]; then + printf 'Vana scanner checkout is at %s, expected %s.\n' "$actual_sha" "$expected_sha" >&2 + exit 2 +fi +origin_url=$(git -C "$shared_dir" config --get remote.origin.url || true) +case "$origin_url" in + git@github.com:vana-com/.github.git|https://github.com/vana-com/.github.git) ;; + *) printf 'Vana scanner checkout origin is not vana-com/.github: %s\n' "${origin_url:-}" >&2; exit 2 ;; +esac +git -C "$shared_dir" diff --quiet -- . && git -C "$shared_dir" diff --cached --quiet -- . || { + printf 'Vana scanner checkout has local changes: %s\n' "$shared_dir" >&2 + exit 2 +} tool_dir="$shared_dir/.tools/gitleaks" if [[ ! -x "$tool_dir/gitleaks" ]]; then "$installer" "$tool_dir" fi +if [[ ! -f "$tool_dir/gitleaks.sha256" ]]; then + printf 'Gitleaks checksum receipt missing; reinstalling pinned Gitleaks.\n' >&2 + "$installer" "$tool_dir" +fi +if command -v sha256sum >/dev/null 2>&1; then + sha256sum --check --status "$tool_dir/gitleaks.sha256" +else + shasum -a 256 --check --status "$tool_dir/gitleaks.sha256" +fi || { + printf 'Gitleaks binary checksum verification failed.\n' >&2 + exit 2 +} remote_name=${1:-origin} while read -r local_ref local_sha remote_ref remote_sha; do diff --git a/scripts/install-gitleaks.sh b/scripts/install-gitleaks.sh index 6167ae9..034fb6f 100755 --- a/scripts/install-gitleaks.sh +++ b/scripts/install-gitleaks.sh @@ -50,4 +50,9 @@ else fi tar -xzf "$archive" -C "$workdir" gitleaks install -m 0755 "$workdir/gitleaks" "$destination/gitleaks" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$destination/gitleaks" >"$destination/gitleaks.sha256" +else + shasum -a 256 "$destination/gitleaks" >"$destination/gitleaks.sha256" +fi "$destination/gitleaks" version >&2 diff --git a/scripts/install-pre-push.sh b/scripts/install-pre-push.sh index f283424..76945af 100755 --- a/scripts/install-pre-push.sh +++ b/scripts/install-pre-push.sh @@ -3,36 +3,126 @@ set -euo pipefail usage() { - printf 'Usage: %s --shared-dir [--repo ]\n' "${0##*/}" >&2 + cat >&2 < Git repository to manage (default: current directory) + --shared-dir Trusted vana-com/.github checkout (default: this checkout) + --ref Required 40-character policy commit + +The installer writes only a small managed launcher. It refuses to overwrite an +unmanaged pre-push hook. +USAGE exit 2 } +command_name=install repo=$PWD -shared_dir='' +shared_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +expected_sha='' +if [[ $# -gt 0 && "$1" != --* ]]; then + command_name=$1 + shift +fi while [[ $# -gt 0 ]]; do case "$1" in --repo) repo=${2:-}; shift 2 ;; --shared-dir) shared_dir=${2:-}; shift 2 ;; + --ref|--expected-sha) expected_sha=${2:-}; shift 2 ;; --help|-h) usage ;; *) printf 'Unknown argument: %s\n' "$1" >&2; usage ;; esac done -[[ -n "$shared_dir" ]] || usage +case "$command_name" in + install|status|uninstall) ;; + *) printf 'Unknown command: %s\n' "$command_name" >&2; usage ;; +esac git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || { printf 'Not a Git work tree: %s\n' "$repo" >&2; exit 2; } repo=$(cd "$repo" && pwd -P) [[ -x "$shared_dir/hooks/pre-push" ]] || { printf 'No hook at: %s/hooks/pre-push\n' "$shared_dir" >&2; exit 2; } shared_dir=$(cd "$shared_dir" && pwd -P) +if [[ -z "$expected_sha" ]]; then + expected_sha=$(git -C "$shared_dir" rev-parse HEAD) +fi +[[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] || { + printf 'Expected policy SHA must be a 40-character lowercase commit ID.\n' >&2 + exit 2 +} +actual_sha=$(git -C "$shared_dir" rev-parse HEAD) +[[ "$actual_sha" == "$expected_sha" ]] || { + printf 'Policy checkout is at %s, expected %s.\n' "$actual_sha" "$expected_sha" >&2 + exit 2 +} +origin_url=$(git -C "$shared_dir" config --get remote.origin.url || true) +case "$origin_url" in + git@github.com:vana-com/.github.git|https://github.com/vana-com/.github.git) ;; + *) printf 'Policy checkout origin is not vana-com/.github: %s\n' "${origin_url:-}" >&2; exit 2 ;; +esac +git -C "$shared_dir" diff --quiet -- . || { + printf 'Policy checkout has unstaged changes: %s\n' "$shared_dir" >&2 + exit 2 +} +git -C "$shared_dir" diff --cached --quiet -- . || { + printf 'Policy checkout has staged changes: %s\n' "$shared_dir" >&2 + exit 2 +} hooks_dir=$(git -C "$repo" rev-parse --git-path hooks) [[ "$hooks_dir" = /* ]] || hooks_dir="$repo/$hooks_dir" hook="$hooks_dir/pre-push" -if [[ -e "$hook" ]]; then +marker='VANA_MANAGED_EVM_KEYSCAN_PRE_PUSH=1' +is_managed=0 +if [[ -f "$hook" ]] && grep -qF "$marker" "$hook"; then + is_managed=1 +fi +desired_hook=$(mktemp) +trap 'rm -f "$desired_hook" "${tmp_hook:-}"' EXIT +cat >"$desired_hook" <&2 + exit 2 + fi + printf 'No Vana EVM keyscan pre-push hook is installed for %s\n' "$repo" + exit 0 + ;; +esac + +if [[ -e "$hook" && $is_managed -ne 1 ]]; then printf 'Refusing to overwrite existing hook: %s\nMerge it manually or use your hook manager.\n' "$hook" >&2 exit 2 fi +"$shared_dir/scripts/install-gitleaks.sh" "$shared_dir/.tools/gitleaks" >/dev/null mkdir -p "$hooks_dir" -printf '#!/usr/bin/env bash\nexec env VANA_SECRET_SCAN_HOME=%q %q "$@"\n' \ - "$shared_dir" "$shared_dir/hooks/pre-push" >"$hook" -chmod 0755 "$hook" -printf 'Installed optional pre-push guard at %s\n' "$hook" +tmp_hook=$(mktemp "$hooks_dir/pre-push.vana.XXXXXX") +cp "$desired_hook" "$tmp_hook" +chmod 0755 "$tmp_hook" +mv "$tmp_hook" "$hook" +printf 'Installed Vana EVM keyscan pre-push hook at %s\n' "$hook" diff --git a/tests/run.sh b/tests/run.sh index 601d065..ea35b67 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -5,6 +5,7 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) scanner="$root/scripts/scan-commit-range.sh" config="$root/.gitleaks.toml" gitleaks=${GITLEAKS_BIN:-gitleaks} +policy_sha=$(git -C "$root" rev-parse HEAD) command -v "$gitleaks" >/dev/null 2>&1 || { printf 'Set GITLEAKS_BIN to a Gitleaks executable.\n' >&2 @@ -18,6 +19,26 @@ git init -q -b main "$repo" git -C "$repo" config user.name test git -C "$repo" config user.email test@example.invalid +hook_repo="$test_root/hook-repo" +git init -q -b main "$hook_repo" +"$root/scripts/install-pre-push.sh" --repo "$hook_repo" --ref "$policy_sha" +"$root/scripts/install-pre-push.sh" status --repo "$hook_repo" --ref "$policy_sha" +hook_path=$(git -C "$hook_repo" rev-parse --git-path hooks/pre-push) +[[ "$hook_path" = /* ]] || hook_path="$hook_repo/$hook_path" +grep -qF 'VANA_MANAGED_EVM_KEYSCAN_PRE_PUSH=1' "$hook_path" +"$root/scripts/install-pre-push.sh" --repo "$hook_repo" --ref "$policy_sha" +"$root/scripts/install-pre-push.sh" uninstall --repo "$hook_repo" --ref "$policy_sha" +if "$root/scripts/install-pre-push.sh" status --repo "$hook_repo" --ref "$policy_sha"; then + printf 'expected status to fail after uninstall\n' >&2 + exit 1 +fi +printf '#!/usr/bin/env bash\nexit 0\n' >"$hook_path" +chmod 0755 "$hook_path" +if "$root/scripts/install-pre-push.sh" --repo "$hook_repo" --ref "$policy_sha"; then + printf 'expected installer to refuse unmanaged hook\n' >&2 + exit 1 +fi + key='4f3c8b1a9e6d2c7f0b5e1d8a6c3f9b2e''7d4a1c8f5b0e6d3a9c2f7b4e1d8a6c3f' scan() { "$scanner" --repo "$repo" --range "$1" --config "$config" --gitleaks "$gitleaks"; } expect_scan_status() { @@ -455,7 +476,7 @@ fi # The optional hook should inherit the trusted checkout selected by its # installer and must not print the candidate value when it blocks a push. -"$root/scripts/install-pre-push.sh" --shared-dir "$root" --repo "$repo" >/dev/null +"$root/scripts/install-pre-push.sh" --shared-dir "$root" --repo "$repo" --ref "$policy_sha" >/dev/null hook_output="$test_root/hook-output" if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$allowed" 0 | .git/hooks/pre-push origin example.invalid) >"$hook_output" 2>&1; then @@ -475,10 +496,7 @@ if ! (cd "$repo" && printf 'refs/heads/new %s refs/heads/new %040d\n' "$new_bran printf 'expected new branch to exclude existing remote history\n' >&2 exit 1 fi -if "$root/scripts/install-pre-push.sh" --shared-dir "$root" --repo "$repo" >/dev/null 2>&1; then - printf 'expected installer to refuse an existing hook\n' >&2 - exit 1 -fi +"$root/scripts/install-pre-push.sh" --shared-dir "$root" --repo "$repo" --ref "$policy_sha" >/dev/null git -C "$repo" commit --allow-empty -q -m "privateKey=$key" message_commit=$(git -C "$repo" rev-parse HEAD) From 8ce5390e6b1d1934b02796c47e7ddcea63831381 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:28:50 -0500 Subject: [PATCH 07/14] Harden pre-push hook bootstrap --- README.md | 20 +++++----- hooks/pre-push | 10 ++--- scripts/install-pre-push.sh | 31 ++++++++++---- tests/run.sh | 80 +++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d97d879..eba434a 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,12 @@ scripts/install-pre-push.sh --repo /path/to/public-repo --ref ` tips; run `git fetch ` first if -those refs may be stale. +does not download tools during `git push`; if the tool or checksum receipt is +missing, it fails closed and asks the developer to re-run the installer. It +sends no source or candidate values over the network. For a new remote branch, +it scans only commits not reachable from locally fetched `refs/remotes/` +tips; run `git fetch ` first if those refs may be stale. ## False positives and remediation diff --git a/hooks/pre-push b/hooks/pre-push index fb811b1..cd2f81f 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -15,8 +15,7 @@ if [[ -z "$expected_sha" || ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then fi scanner="$shared_dir/scripts/scan-commit-range.sh" -installer="$shared_dir/scripts/install-gitleaks.sh" -[[ -x "$scanner" && -x "$installer" ]] || { +[[ -x "$scanner" ]] || { printf 'VANA_SECRET_SCAN_HOME is not a valid shared scanner checkout.\n' >&2 exit 2 } @@ -37,11 +36,12 @@ git -C "$shared_dir" diff --quiet -- . && git -C "$shared_dir" diff --cached --q tool_dir="$shared_dir/.tools/gitleaks" if [[ ! -x "$tool_dir/gitleaks" ]]; then - "$installer" "$tool_dir" + printf 'Gitleaks is not installed for the Vana pre-push hook. Re-run scripts/install-pre-push.sh from the pinned policy checkout.\n' >&2 + exit 2 fi if [[ ! -f "$tool_dir/gitleaks.sha256" ]]; then - printf 'Gitleaks checksum receipt missing; reinstalling pinned Gitleaks.\n' >&2 - "$installer" "$tool_dir" + printf 'Gitleaks checksum receipt is missing. Re-run scripts/install-pre-push.sh from the pinned policy checkout.\n' >&2 + exit 2 fi if command -v sha256sum >/dev/null 2>&1; then sha256sum --check --status "$tool_dir/gitleaks.sha256" diff --git a/scripts/install-pre-push.sh b/scripts/install-pre-push.sh index 76945af..3c40d6d 100755 --- a/scripts/install-pre-push.sh +++ b/scripts/install-pre-push.sh @@ -72,9 +72,9 @@ hooks_dir=$(git -C "$repo" rev-parse --git-path hooks) [[ "$hooks_dir" = /* ]] || hooks_dir="$repo/$hooks_dir" hook="$hooks_dir/pre-push" marker='VANA_MANAGED_EVM_KEYSCAN_PRE_PUSH=1' -is_managed=0 -if [[ -f "$hook" ]] && grep -qF "$marker" "$hook"; then - is_managed=1 +if [[ -L "$hook" ]]; then + printf 'Refusing to manage symlink hook: %s\n' "$hook" >&2 + exit 2 fi desired_hook=$(mktemp) trap 'rm -f "$desired_hook" "${tmp_hook:-}"' EXIT @@ -85,10 +85,25 @@ readonly VANA_SECRET_SCAN_HOME=$(printf '%q' "$shared_dir") readonly VANA_SECRET_SCAN_EXPECTED_SHA=$(printf '%q' "$expected_sha") exec env VANA_SECRET_SCAN_HOME="\$VANA_SECRET_SCAN_HOME" VANA_SECRET_SCAN_EXPECTED_SHA="\$VANA_SECRET_SCAN_EXPECTED_SHA" $(printf '%q' "$shared_dir/hooks/pre-push") "\$@" EOF +is_exact_managed=0 +if [[ -f "$hook" ]] && cmp -s "$desired_hook" "$hook"; then + is_exact_managed=1 +fi + +verify_installed_gitleaks() { + local binary="$shared_dir/.tools/gitleaks/gitleaks" + local receipt="$shared_dir/.tools/gitleaks/gitleaks.sha256" + [[ -x "$binary" && -f "$receipt" ]] || return 1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum --check --status "$receipt" + else + shasum -a 256 --check --status "$receipt" + fi +} case "$command_name" in status) - if [[ $is_managed -eq 1 ]]; then + if [[ $is_exact_managed -eq 1 ]]; then printf 'Vana EVM keyscan pre-push hook is installed at %s\n' "$hook" elif [[ -e "$hook" ]]; then printf 'A non-Vana pre-push hook exists at %s\n' "$hook" @@ -100,7 +115,7 @@ case "$command_name" in exit 0 ;; uninstall) - if [[ $is_managed -eq 1 ]] && cmp -s "$desired_hook" "$hook"; then + if [[ $is_exact_managed -eq 1 ]]; then rm "$hook" printf 'Removed Vana EVM keyscan pre-push hook at %s\n' "$hook" exit 0 @@ -114,12 +129,14 @@ case "$command_name" in ;; esac -if [[ -e "$hook" && $is_managed -ne 1 ]]; then +if [[ -e "$hook" && $is_exact_managed -ne 1 ]]; then printf 'Refusing to overwrite existing hook: %s\nMerge it manually or use your hook manager.\n' "$hook" >&2 exit 2 fi -"$shared_dir/scripts/install-gitleaks.sh" "$shared_dir/.tools/gitleaks" >/dev/null +if ! verify_installed_gitleaks; then + "$shared_dir/scripts/install-gitleaks.sh" "$shared_dir/.tools/gitleaks" >/dev/null +fi mkdir -p "$hooks_dir" tmp_hook=$(mktemp "$hooks_dir/pre-push.vana.XXXXXX") cp "$desired_hook" "$tmp_hook" diff --git a/tests/run.sh b/tests/run.sh index ea35b67..78c5f8f 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -38,6 +38,63 @@ if "$root/scripts/install-pre-push.sh" --repo "$hook_repo" --ref "$policy_sha"; printf 'expected installer to refuse unmanaged hook\n' >&2 exit 1 fi +printf '#!/usr/bin/env bash\n# VANA_MANAGED_EVM_KEYSCAN_PRE_PUSH=1\nexit 0\n' >"$hook_path" +chmod 0755 "$hook_path" +if "$root/scripts/install-pre-push.sh" --repo "$hook_repo" --ref "$policy_sha"; then + printf 'expected installer to refuse marker-spoofed hook\n' >&2 + exit 1 +fi +rm "$hook_path" +ln -s /dev/null "$hook_path" +if "$root/scripts/install-pre-push.sh" --repo "$hook_repo" --ref "$policy_sha"; then + printf 'expected installer to refuse symlink hook\n' >&2 + exit 1 +fi + +custom_hook_repo="$test_root/custom-hook-repo" +git init -q -b main "$custom_hook_repo" +git -C "$custom_hook_repo" config core.hooksPath custom-hooks +"$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref "$policy_sha" +if [[ ! -f "$custom_hook_repo/custom-hooks/pre-push" ]]; then + printf 'expected installer to honor custom core.hooksPath\n' >&2 + exit 1 +fi + +if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref 0000000000000000000000000000000000000000; then + printf 'expected installer to refuse wrong policy SHA\n' >&2 + exit 1 +fi +bad_origin="$test_root/bad-origin" +git init -q -b main "$bad_origin" +mkdir -p "$bad_origin/hooks" +cp "$root/hooks/pre-push" "$bad_origin/hooks/pre-push" +chmod 0755 "$bad_origin/hooks/pre-push" +git -C "$bad_origin" config user.name test +git -C "$bad_origin" config user.email test@example.invalid +git -C "$bad_origin" remote add origin https://example.invalid/not-vana.git +git -C "$bad_origin" add hooks/pre-push +git -C "$bad_origin" commit -q -m policy +bad_origin_sha=$(git -C "$bad_origin" rev-parse HEAD) +if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --shared-dir "$bad_origin" --ref "$bad_origin_sha"; then + printf 'expected installer to refuse wrong policy origin\n' >&2 + exit 1 +fi +dirty_policy="$test_root/dirty-policy" +git init -q -b main "$dirty_policy" +mkdir -p "$dirty_policy/hooks" +cp "$root/hooks/pre-push" "$dirty_policy/hooks/pre-push" +chmod 0755 "$dirty_policy/hooks/pre-push" +git -C "$dirty_policy" config user.name test +git -C "$dirty_policy" config user.email test@example.invalid +git -C "$dirty_policy" remote add origin https://github.com/vana-com/.github.git +git -C "$dirty_policy" add hooks/pre-push +git -C "$dirty_policy" commit -q -m policy +dirty_sha=$(git -C "$dirty_policy" rev-parse HEAD) +printf '\n# dirty\n' >>"$dirty_policy/hooks/pre-push" +if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --shared-dir "$dirty_policy" --ref "$dirty_sha"; then + printf 'expected installer to refuse dirty policy checkout\n' >&2 + exit 1 +fi key='4f3c8b1a9e6d2c7f0b5e1d8a6c3f9b2e''7d4a1c8f5b0e6d3a9c2f7b4e1d8a6c3f' scan() { "$scanner" --repo "$repo" --range "$1" --config "$config" --gitleaks "$gitleaks"; } @@ -488,6 +545,29 @@ if grep -Fq "$key" "$hook_output"; then exit 1 fi +gitleaks_bin="$root/.tools/gitleaks/gitleaks" +gitleaks_backup="$test_root/gitleaks.backup" +gitleaks_receipt="$root/.tools/gitleaks/gitleaks.sha256" +gitleaks_receipt_backup="$test_root/gitleaks.sha256.backup" +cp "$gitleaks_bin" "$gitleaks_backup" +cp "$gitleaks_receipt" "$gitleaks_receipt_backup" +mv "$gitleaks_bin" "$gitleaks_bin.missing" +if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | + .git/hooks/pre-push origin example.invalid) >/dev/null 2>&1; then + printf 'expected pre-push hook to fail closed when Gitleaks is missing\n' >&2 + exit 1 +fi +mv "$gitleaks_bin.missing" "$gitleaks_bin" +printf '#!/usr/bin/env bash\nexit 0\n' >"$gitleaks_bin" +chmod 0755 "$gitleaks_bin" +if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | + .git/hooks/pre-push origin example.invalid) >/dev/null 2>&1; then + printf 'expected pre-push hook to fail closed when Gitleaks is tampered\n' >&2 + exit 1 +fi +cp "$gitleaks_backup" "$gitleaks_bin" +cp "$gitleaks_receipt_backup" "$gitleaks_receipt" + # A new branch based on already published history scans only its new commits. new_branch=$(commit_file new-branch.ts 'export const release = true;' new-branch) git -C "$repo" update-ref refs/remotes/origin/main "$upstream_mutation" From 5c07469adc29023c55bc3dd96fd37e7bd7362633 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:34:30 -0500 Subject: [PATCH 08/14] Finalize pre-push bootstrap trust checks --- README.md | 17 +++--- hooks/pre-push | 17 +----- scripts/install-gitleaks.sh | 8 +-- scripts/install-pre-push.sh | 118 +++++++++++++++++++++++------------- scripts/verify-gitleaks.sh | 33 ++++++++++ tests/run.sh | 76 ++++++++++++++++++----- 6 files changed, 184 insertions(+), 85 deletions(-) create mode 100755 scripts/verify-gitleaks.sh diff --git a/README.md b/README.md index eba434a..f2dfff4 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ The installer requires the selected checkout to come from `vana-com/.github`, match the exact release SHA, and have no local changes. It installs the pinned Gitleaks binary when the local copy is missing or fails checksum verification, then records the selected policy checkout in a managed launcher. No shell -environment setup is required. Re-running the command updates the managed -launcher for the same repository. Use `status` to inspect it and `uninstall` to -remove it. +environment setup is required. Use `status` to inspect the launcher and +`uninstall` to remove it. To move a repository to a newer reviewed release, +uninstall with the old release SHA, then install with the new release SHA. The installer refuses to overwrite or remove an unmanaged hook. Use a hook manager or merge the launcher deliberately when another pre-push hook already @@ -90,11 +90,12 @@ exists. The hook verifies the policy checkout SHA, verifies that the checkout has no local changes, and verifies the Gitleaks binary checksum before each push. It -does not download tools during `git push`; if the tool or checksum receipt is -missing, it fails closed and asks the developer to re-run the installer. It -sends no source or candidate values over the network. For a new remote branch, -it scans only commits not reachable from locally fetched `refs/remotes/` -tips; run `git fetch ` first if those refs may be stale. +does not download tools during `git push`; if the tool is missing or has the +wrong checksum, it fails closed and asks the developer to re-run the installer. +It sends no source or candidate values over the network. For a new remote +branch, it scans only commits not reachable from locally fetched +`refs/remotes/` tips; run `git fetch ` first if those refs may +be stale. ## False positives and remediation diff --git a/hooks/pre-push b/hooks/pre-push index cd2f81f..60d524e 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -15,7 +15,8 @@ if [[ -z "$expected_sha" || ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then fi scanner="$shared_dir/scripts/scan-commit-range.sh" -[[ -x "$scanner" ]] || { +verifier="$shared_dir/scripts/verify-gitleaks.sh" +[[ -x "$scanner" && -x "$verifier" ]] || { printf 'VANA_SECRET_SCAN_HOME is not a valid shared scanner checkout.\n' >&2 exit 2 } @@ -35,22 +36,10 @@ git -C "$shared_dir" diff --quiet -- . && git -C "$shared_dir" diff --cached --q } tool_dir="$shared_dir/.tools/gitleaks" -if [[ ! -x "$tool_dir/gitleaks" ]]; then +if ! "$verifier" "$tool_dir" >/dev/null 2>&1; then printf 'Gitleaks is not installed for the Vana pre-push hook. Re-run scripts/install-pre-push.sh from the pinned policy checkout.\n' >&2 exit 2 fi -if [[ ! -f "$tool_dir/gitleaks.sha256" ]]; then - printf 'Gitleaks checksum receipt is missing. Re-run scripts/install-pre-push.sh from the pinned policy checkout.\n' >&2 - exit 2 -fi -if command -v sha256sum >/dev/null 2>&1; then - sha256sum --check --status "$tool_dir/gitleaks.sha256" -else - shasum -a 256 --check --status "$tool_dir/gitleaks.sha256" -fi || { - printf 'Gitleaks binary checksum verification failed.\n' >&2 - exit 2 -} remote_name=${1:-origin} while read -r local_ref local_sha remote_ref remote_sha; do diff --git a/scripts/install-gitleaks.sh b/scripts/install-gitleaks.sh index 034fb6f..61e836c 100755 --- a/scripts/install-gitleaks.sh +++ b/scripts/install-gitleaks.sh @@ -36,6 +36,9 @@ case "$(uname -s)-$(uname -m)" in ;; esac +[[ ! -L "$destination" ]] || { printf 'Refusing symlink destination: %s\n' "$destination" >&2; exit 2; } +[[ ! -L "$destination/gitleaks" ]] || { printf 'Refusing symlink Gitleaks binary: %s\n' "$destination/gitleaks" >&2; exit 2; } +[[ ! -L "$destination/gitleaks.sha256" ]] || { printf 'Refusing symlink Gitleaks receipt: %s\n' "$destination/gitleaks.sha256" >&2; exit 2; } mkdir -p "$destination" workdir=$(mktemp -d) trap 'rm -rf "$workdir"' EXIT @@ -50,9 +53,4 @@ else fi tar -xzf "$archive" -C "$workdir" gitleaks install -m 0755 "$workdir/gitleaks" "$destination/gitleaks" -if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$destination/gitleaks" >"$destination/gitleaks.sha256" -else - shasum -a 256 "$destination/gitleaks" >"$destination/gitleaks.sha256" -fi "$destination/gitleaks" version >&2 diff --git a/scripts/install-pre-push.sh b/scripts/install-pre-push.sh index 3c40d6d..cbdc652 100755 --- a/scripts/install-pre-push.sh +++ b/scripts/install-pre-push.sh @@ -4,15 +4,16 @@ set -euo pipefail usage() { cat >&2 < Git repository to manage (default: current directory) --shared-dir Trusted vana-com/.github checkout (default: this checkout) --ref Required 40-character policy commit -The installer writes only a small managed launcher. It refuses to overwrite an -unmanaged pre-push hook. +The install command writes only a small managed launcher. The prepare command +validates the policy checkout and installs the pinned Gitleaks binary without +touching repository hooks. USAGE exit 2 } @@ -29,26 +30,29 @@ while [[ $# -gt 0 ]]; do case "$1" in --repo) repo=${2:-}; shift 2 ;; --shared-dir) shared_dir=${2:-}; shift 2 ;; - --ref|--expected-sha) expected_sha=${2:-}; shift 2 ;; + --ref) expected_sha=${2:-}; shift 2 ;; --help|-h) usage ;; *) printf 'Unknown argument: %s\n' "$1" >&2; usage ;; esac done case "$command_name" in - install|status|uninstall) ;; + install|prepare|status|uninstall) ;; *) printf 'Unknown command: %s\n' "$command_name" >&2; usage ;; esac -git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || { printf 'Not a Git work tree: %s\n' "$repo" >&2; exit 2; } -repo=$(cd "$repo" && pwd -P) -[[ -x "$shared_dir/hooks/pre-push" ]] || { printf 'No hook at: %s/hooks/pre-push\n' "$shared_dir" >&2; exit 2; } -shared_dir=$(cd "$shared_dir" && pwd -P) -if [[ -z "$expected_sha" ]]; then - expected_sha=$(git -C "$shared_dir" rev-parse HEAD) -fi +[[ -n "$expected_sha" ]] || { printf 'Missing required --ref .\n' >&2; exit 2; } [[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] || { - printf 'Expected policy SHA must be a 40-character lowercase commit ID.\n' >&2 + printf 'Policy ref must be a 40-character lowercase commit ID.\n' >&2 exit 2 } + +git -C "$shared_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + printf 'Policy checkout is not a Git work tree: %s\n' "$shared_dir" >&2 + exit 2 +} +[[ -x "$shared_dir/hooks/pre-push" ]] || { printf 'No hook at: %s/hooks/pre-push\n' "$shared_dir" >&2; exit 2; } +[[ -x "$shared_dir/scripts/install-gitleaks.sh" ]] || { printf 'No installer at: %s/scripts/install-gitleaks.sh\n' "$shared_dir" >&2; exit 2; } +[[ -x "$shared_dir/scripts/verify-gitleaks.sh" ]] || { printf 'No verifier at: %s/scripts/verify-gitleaks.sh\n' "$shared_dir" >&2; exit 2; } +shared_dir=$(cd "$shared_dir" && pwd -P) actual_sha=$(git -C "$shared_dir" rev-parse HEAD) [[ "$actual_sha" == "$expected_sha" ]] || { printf 'Policy checkout is at %s, expected %s.\n' "$actual_sha" "$expected_sha" >&2 @@ -59,23 +63,63 @@ case "$origin_url" in git@github.com:vana-com/.github.git|https://github.com/vana-com/.github.git) ;; *) printf 'Policy checkout origin is not vana-com/.github: %s\n' "${origin_url:-}" >&2; exit 2 ;; esac -git -C "$shared_dir" diff --quiet -- . || { - printf 'Policy checkout has unstaged changes: %s\n' "$shared_dir" >&2 - exit 2 -} -git -C "$shared_dir" diff --cached --quiet -- . || { - printf 'Policy checkout has staged changes: %s\n' "$shared_dir" >&2 +if [[ -n "$(git -C "$shared_dir" status --porcelain --untracked-files=all -- ':!/.tools')" ]]; then + printf 'Policy checkout has local changes: %s\n' "$shared_dir" >&2 exit 2 +fi + +tool_dir="$shared_dir/.tools/gitleaks" +prepare_gitleaks() { + if ! "$shared_dir/scripts/verify-gitleaks.sh" "$tool_dir" >/dev/null 2>&1; then + "$shared_dir/scripts/install-gitleaks.sh" "$tool_dir" >/dev/null + "$shared_dir/scripts/verify-gitleaks.sh" "$tool_dir" >/dev/null + fi } +if [[ "$command_name" == prepare ]]; then + prepare_gitleaks + printf 'Prepared Vana EVM keyscan policy checkout at %s\n' "$shared_dir" + exit 0 +fi + +git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || { printf 'Not a Git work tree: %s\n' "$repo" >&2; exit 2; } +repo=$(cd "$repo" && pwd -P) +configured_hooks_path=$(git -C "$repo" config --get core.hooksPath || true) +if [[ -n "$configured_hooks_path" ]]; then + printf 'Refusing to manage repositories with core.hooksPath set: %s\n' "$configured_hooks_path" >&2 + exit 2 +fi hooks_dir=$(git -C "$repo" rev-parse --git-path hooks) -[[ "$hooks_dir" = /* ]] || hooks_dir="$repo/$hooks_dir" +[[ "$hooks_dir" != /* ]] || { printf 'Refusing absolute hooks directory: %s\n' "$hooks_dir" >&2; exit 2; } +[[ "$hooks_dir" == .git/hooks ]] || { printf 'Refusing unexpected hooks directory: %s\n' "$hooks_dir" >&2; exit 2; } +git_dir=$(git -C "$repo" rev-parse --git-dir) +[[ "$git_dir" != /* ]] || { printf 'Refusing absolute Git directory: %s\n' "$git_dir" >&2; exit 2; } +git_dir_real=$(cd "$repo/$(dirname "$git_dir")" && pwd -P)/$(basename "$git_dir") +repo_real=$(cd "$repo" && pwd -P) +case "$git_dir_real" in + "$repo_real/.git") ;; + *) printf 'Refusing Git directory outside repository: %s\n' "$git_dir_real" >&2; exit 2 ;; +esac +if [[ -e "$repo/.git" && -L "$repo/.git" ]]; then + printf 'Refusing symlink Git directory: %s\n' "$repo/.git" >&2 + exit 2 +fi +hooks_dir="$repo/.git/hooks" +if [[ -e "$hooks_dir" && ! -d "$hooks_dir" ]]; then + printf 'Refusing non-directory hooks path: %s\n' "$hooks_dir" >&2 + exit 2 +fi +if [[ -L "$hooks_dir" ]]; then + printf 'Refusing symlink hooks directory: %s\n' "$hooks_dir" >&2 + exit 2 +fi hook="$hooks_dir/pre-push" -marker='VANA_MANAGED_EVM_KEYSCAN_PRE_PUSH=1' if [[ -L "$hook" ]]; then - printf 'Refusing to manage symlink hook: %s\n' "$hook" >&2 + printf 'Refusing symlink hook: %s\n' "$hook" >&2 exit 2 fi + +marker='VANA_MANAGED_EVM_KEYSCAN_PRE_PUSH=1' desired_hook=$(mktemp) trap 'rm -f "$desired_hook" "${tmp_hook:-}"' EXIT cat >"$desired_hook" </dev/null 2>&1; then - sha256sum --check --status "$receipt" - else - shasum -a 256 --check --status "$receipt" - fi -} - case "$command_name" in status) - if [[ $is_exact_managed -eq 1 ]]; then + "$shared_dir/scripts/verify-gitleaks.sh" "$tool_dir" >/dev/null + if [[ $is_exact_managed -eq 1 && -x "$hook" ]]; then printf 'Vana EVM keyscan pre-push hook is installed at %s\n' "$hook" - elif [[ -e "$hook" ]]; then - printf 'A non-Vana pre-push hook exists at %s\n' "$hook" - exit 1 + exit 0 + fi + if [[ -e "$hook" ]]; then + printf 'A non-Vana pre-push hook exists at %s\n' "$hook" >&2 else - printf 'Vana EVM keyscan pre-push hook is not installed for %s\n' "$repo" - exit 1 + printf 'Vana EVM keyscan pre-push hook is not installed for %s\n' "$repo" >&2 fi - exit 0 + exit 1 ;; uninstall) if [[ $is_exact_managed -eq 1 ]]; then @@ -134,9 +168,7 @@ if [[ -e "$hook" && $is_exact_managed -ne 1 ]]; then exit 2 fi -if ! verify_installed_gitleaks; then - "$shared_dir/scripts/install-gitleaks.sh" "$shared_dir/.tools/gitleaks" >/dev/null -fi +prepare_gitleaks mkdir -p "$hooks_dir" tmp_hook=$(mktemp "$hooks_dir/pre-push.vana.XXXXXX") cp "$desired_hook" "$tmp_hook" diff --git a/scripts/verify-gitleaks.sh b/scripts/verify-gitleaks.sh new file mode 100755 index 0000000..7faa52e --- /dev/null +++ b/scripts/verify-gitleaks.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Verify the exact Gitleaks binary used by this policy. +set -euo pipefail + +usage() { + printf 'Usage: %s \n' "${0##*/}" >&2 + exit 2 +} + +[[ $# -eq 1 ]] || usage +tool_dir=$1 +[[ ! -L "$tool_dir" ]] || { printf 'Refusing symlink tool directory: %s\n' "$tool_dir" >&2; exit 2; } +binary="$tool_dir/gitleaks" +[[ ! -L "$binary" ]] || { printf 'Refusing symlink Gitleaks binary: %s\n' "$binary" >&2; exit 2; } +[[ -x "$binary" ]] || { printf 'Gitleaks executable not found: %s\n' "$binary" >&2; exit 2; } + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) expected='88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509' ;; + Linux-aarch64|Linux-arm64) expected='00e91bbe655bd7c47753e8cfe61cb76ea1a5d7e7702fe161ee40102b46b3823b' ;; + Darwin-arm64) expected='ba52fb1bfabbcde42f032afad3d6e0b19dff8ed105229a16e7caa338bbc0e84f' ;; + Darwin-x86_64) expected='cee01fea7173f1b779dff188e1c26ecbcb4027d394acc573b23aaf0be260e291' ;; + *) printf 'Unsupported platform: %s-%s\n' "$(uname -s)" "$(uname -m)" >&2; exit 2 ;; +esac + +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$binary" | awk '{print $1}') +else + actual=$(shasum -a 256 "$binary" | awk '{print $1}') +fi +[[ "$actual" == "$expected" ]] || { + printf 'Gitleaks binary checksum verification failed.\n' >&2 + exit 2 +} diff --git a/tests/run.sh b/tests/run.sh index 78c5f8f..0b62393 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -21,6 +21,10 @@ git -C "$repo" config user.email test@example.invalid hook_repo="$test_root/hook-repo" git init -q -b main "$hook_repo" +if "$root/scripts/install-pre-push.sh" --repo "$hook_repo"; then + printf 'expected installer to require --ref\n' >&2 + exit 1 +fi "$root/scripts/install-pre-push.sh" --repo "$hook_repo" --ref "$policy_sha" "$root/scripts/install-pre-push.sh" status --repo "$hook_repo" --ref "$policy_sha" hook_path=$(git -C "$hook_repo" rev-parse --git-path hooks/pre-push) @@ -54,11 +58,22 @@ fi custom_hook_repo="$test_root/custom-hook-repo" git init -q -b main "$custom_hook_repo" git -C "$custom_hook_repo" config core.hooksPath custom-hooks -"$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref "$policy_sha" -if [[ ! -f "$custom_hook_repo/custom-hooks/pre-push" ]]; then - printf 'expected installer to honor custom core.hooksPath\n' >&2 +if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref "$policy_sha"; then + printf 'expected installer to refuse configured core.hooksPath\n' >&2 exit 1 fi +if [[ -e "$custom_hook_repo/custom-hooks/pre-push" ]]; then + printf 'prepare-only repo should not receive a pre-push hook\n' >&2 + exit 1 +fi +"$root/scripts/install-pre-push.sh" prepare --repo "$custom_hook_repo" --ref "$policy_sha" +if [[ -e "$custom_hook_repo/custom-hooks/pre-push" ]]; then + printf 'prepare should not touch repository hooks\n' >&2 + exit 1 +fi +not_a_repo="$test_root/not-a-repo" +mkdir "$not_a_repo" +"$root/scripts/install-pre-push.sh" prepare --repo "$not_a_repo" --ref "$policy_sha" if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref 0000000000000000000000000000000000000000; then printf 'expected installer to refuse wrong policy SHA\n' >&2 @@ -95,6 +110,39 @@ if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --shared-dir " printf 'expected installer to refuse dirty policy checkout\n' >&2 exit 1 fi +git -C "$dirty_policy" checkout -- hooks/pre-push +printf 'untracked\n' >"$dirty_policy/untracked.txt" +if "$root/scripts/install-pre-push.sh" prepare --shared-dir "$dirty_policy" --ref "$dirty_sha"; then + printf 'expected installer to refuse untracked policy checkout files\n' >&2 + exit 1 +fi +symlink_tool_dir="$test_root/symlink-tool-dir" +ln -s "$test_root/outside-tools" "$symlink_tool_dir" +if "$root/scripts/install-gitleaks.sh" "$symlink_tool_dir"; then + printf 'expected Gitleaks installer to refuse symlink destination\n' >&2 + exit 1 +fi +mkdir -p "$test_root/tool-with-symlink" +ln -s "$test_root/external-gitleaks" "$test_root/tool-with-symlink/gitleaks" +if "$root/scripts/install-gitleaks.sh" "$test_root/tool-with-symlink"; then + printf 'expected Gitleaks installer to refuse symlink binary\n' >&2 + exit 1 +fi +mkdir -p "$test_root/tool-with-receipt-symlink" +ln -s "$test_root/external-receipt" "$test_root/tool-with-receipt-symlink/gitleaks.sha256" +if "$root/scripts/install-gitleaks.sh" "$test_root/tool-with-receipt-symlink"; then + printf 'expected Gitleaks installer to refuse symlink receipt\n' >&2 + exit 1 +fi +tool_symlink_policy="$test_root/tool-symlink-policy" +git clone -q "$root" "$tool_symlink_policy" +git -C "$tool_symlink_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$tool_symlink_policy" checkout -q "$policy_sha" +ln -s "$test_root/external-tools" "$tool_symlink_policy/.tools" +if "$tool_symlink_policy/scripts/install-pre-push.sh" prepare --shared-dir "$tool_symlink_policy" --ref "$policy_sha"; then + printf 'expected prepare to refuse symlink .tools directory\n' >&2 + exit 1 +fi key='4f3c8b1a9e6d2c7f0b5e1d8a6c3f9b2e''7d4a1c8f5b0e6d3a9c2f7b4e1d8a6c3f' scan() { "$scanner" --repo "$repo" --range "$1" --config "$config" --gitleaks "$gitleaks"; } @@ -545,28 +593,26 @@ if grep -Fq "$key" "$hook_output"; then exit 1 fi -gitleaks_bin="$root/.tools/gitleaks/gitleaks" -gitleaks_backup="$test_root/gitleaks.backup" -gitleaks_receipt="$root/.tools/gitleaks/gitleaks.sha256" -gitleaks_receipt_backup="$test_root/gitleaks.sha256.backup" -cp "$gitleaks_bin" "$gitleaks_backup" -cp "$gitleaks_receipt" "$gitleaks_receipt_backup" -mv "$gitleaks_bin" "$gitleaks_bin.missing" +hook_policy="$test_root/hook-policy" +git clone -q "$root" "$hook_policy" +git -C "$hook_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$hook_policy" checkout -q "$policy_sha" +"$hook_policy/scripts/install-pre-push.sh" --repo "$repo" --shared-dir "$hook_policy" --ref "$policy_sha" >/dev/null +mv "$hook_policy/.tools/gitleaks/gitleaks" "$hook_policy/.tools/gitleaks/gitleaks.missing" if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | .git/hooks/pre-push origin example.invalid) >/dev/null 2>&1; then printf 'expected pre-push hook to fail closed when Gitleaks is missing\n' >&2 exit 1 fi -mv "$gitleaks_bin.missing" "$gitleaks_bin" -printf '#!/usr/bin/env bash\nexit 0\n' >"$gitleaks_bin" -chmod 0755 "$gitleaks_bin" +mv "$hook_policy/.tools/gitleaks/gitleaks.missing" "$hook_policy/.tools/gitleaks/gitleaks" +printf '#!/usr/bin/env bash\nexit 0\n' >"$hook_policy/.tools/gitleaks/gitleaks" +chmod 0755 "$hook_policy/.tools/gitleaks/gitleaks" if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | .git/hooks/pre-push origin example.invalid) >/dev/null 2>&1; then printf 'expected pre-push hook to fail closed when Gitleaks is tampered\n' >&2 exit 1 fi -cp "$gitleaks_backup" "$gitleaks_bin" -cp "$gitleaks_receipt_backup" "$gitleaks_receipt" +"$root/scripts/install-pre-push.sh" --shared-dir "$root" --repo "$repo" --ref "$policy_sha" >/dev/null # A new branch based on already published history scans only its new commits. new_branch=$(commit_file new-branch.ts 'export const release = true;' new-branch) From 1c07dbd337825a906eb98f0fccde0b9218f6c050 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:37:03 -0500 Subject: [PATCH 09/14] Fix pre-push bootstrap regression test --- tests/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/run.sh b/tests/run.sh index 0b62393..27cab29 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -612,6 +612,7 @@ if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 printf 'expected pre-push hook to fail closed when Gitleaks is tampered\n' >&2 exit 1 fi +"$hook_policy/scripts/install-pre-push.sh" uninstall --repo "$repo" --shared-dir "$hook_policy" --ref "$policy_sha" >/dev/null "$root/scripts/install-pre-push.sh" --shared-dir "$root" --repo "$repo" --ref "$policy_sha" >/dev/null # A new branch based on already published history scans only its new commits. From 0c9493a8e436e7caf982808fae3ddee5ae2946bf Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:39:49 -0500 Subject: [PATCH 10/14] Close tool-directory symlink escape --- scripts/install-gitleaks.sh | 10 ++++++++++ scripts/verify-gitleaks.sh | 10 ++++++++++ tests/run.sh | 25 +++++++------------------ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/scripts/install-gitleaks.sh b/scripts/install-gitleaks.sh index 61e836c..eb67723 100755 --- a/scripts/install-gitleaks.sh +++ b/scripts/install-gitleaks.sh @@ -36,6 +36,16 @@ case "$(uname -s)-$(uname -m)" in ;; esac +script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +parent=$(dirname "$destination") +parent_real=$(cd "$parent" 2>/dev/null && pwd -P) || { + printf 'Tool directory parent does not exist: %s\n' "$parent" >&2 + exit 2 +} +case "$parent_real/" in + "$script_root/"*) ;; + *) printf 'Refusing tool directory outside policy checkout: %s\n' "$destination" >&2; exit 2 ;; +esac [[ ! -L "$destination" ]] || { printf 'Refusing symlink destination: %s\n' "$destination" >&2; exit 2; } [[ ! -L "$destination/gitleaks" ]] || { printf 'Refusing symlink Gitleaks binary: %s\n' "$destination/gitleaks" >&2; exit 2; } [[ ! -L "$destination/gitleaks.sha256" ]] || { printf 'Refusing symlink Gitleaks receipt: %s\n' "$destination/gitleaks.sha256" >&2; exit 2; } diff --git a/scripts/verify-gitleaks.sh b/scripts/verify-gitleaks.sh index 7faa52e..7ee2918 100755 --- a/scripts/verify-gitleaks.sh +++ b/scripts/verify-gitleaks.sh @@ -9,6 +9,16 @@ usage() { [[ $# -eq 1 ]] || usage tool_dir=$1 +script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +parent=$(dirname "$tool_dir") +parent_real=$(cd "$parent" 2>/dev/null && pwd -P) || { + printf 'Tool directory parent does not exist: %s\n' "$parent" >&2 + exit 2 +} +case "$parent_real/" in + "$script_root/"*) ;; + *) printf 'Refusing tool directory outside policy checkout: %s\n' "$tool_dir" >&2; exit 2 ;; +esac [[ ! -L "$tool_dir" ]] || { printf 'Refusing symlink tool directory: %s\n' "$tool_dir" >&2; exit 2; } binary="$tool_dir/gitleaks" [[ ! -L "$binary" ]] || { printf 'Refusing symlink Gitleaks binary: %s\n' "$binary" >&2; exit 2; } diff --git a/tests/run.sh b/tests/run.sh index 27cab29..21a193a 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -80,30 +80,18 @@ if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref 00000000 exit 1 fi bad_origin="$test_root/bad-origin" -git init -q -b main "$bad_origin" -mkdir -p "$bad_origin/hooks" -cp "$root/hooks/pre-push" "$bad_origin/hooks/pre-push" -chmod 0755 "$bad_origin/hooks/pre-push" -git -C "$bad_origin" config user.name test -git -C "$bad_origin" config user.email test@example.invalid -git -C "$bad_origin" remote add origin https://example.invalid/not-vana.git -git -C "$bad_origin" add hooks/pre-push -git -C "$bad_origin" commit -q -m policy +git clone -q "$root" "$bad_origin" +git -C "$bad_origin" remote set-url origin https://example.invalid/not-vana.git +git -C "$bad_origin" checkout -q "$policy_sha" bad_origin_sha=$(git -C "$bad_origin" rev-parse HEAD) if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --shared-dir "$bad_origin" --ref "$bad_origin_sha"; then printf 'expected installer to refuse wrong policy origin\n' >&2 exit 1 fi dirty_policy="$test_root/dirty-policy" -git init -q -b main "$dirty_policy" -mkdir -p "$dirty_policy/hooks" -cp "$root/hooks/pre-push" "$dirty_policy/hooks/pre-push" -chmod 0755 "$dirty_policy/hooks/pre-push" -git -C "$dirty_policy" config user.name test -git -C "$dirty_policy" config user.email test@example.invalid -git -C "$dirty_policy" remote add origin https://github.com/vana-com/.github.git -git -C "$dirty_policy" add hooks/pre-push -git -C "$dirty_policy" commit -q -m policy +git clone -q "$root" "$dirty_policy" +git -C "$dirty_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$dirty_policy" checkout -q "$policy_sha" dirty_sha=$(git -C "$dirty_policy" rev-parse HEAD) printf '\n# dirty\n' >>"$dirty_policy/hooks/pre-push" if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --shared-dir "$dirty_policy" --ref "$dirty_sha"; then @@ -138,6 +126,7 @@ tool_symlink_policy="$test_root/tool-symlink-policy" git clone -q "$root" "$tool_symlink_policy" git -C "$tool_symlink_policy" remote set-url origin https://github.com/vana-com/.github.git git -C "$tool_symlink_policy" checkout -q "$policy_sha" +mkdir -p "$test_root/external-tools" ln -s "$test_root/external-tools" "$tool_symlink_policy/.tools" if "$tool_symlink_policy/scripts/install-pre-push.sh" prepare --shared-dir "$tool_symlink_policy" --ref "$policy_sha"; then printf 'expected prepare to refuse symlink .tools directory\n' >&2 From b3ace6f59152a0be8e6e9c0b5d1a0d49882d0138 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:40:36 -0500 Subject: [PATCH 11/14] Allow fresh policy tool preparation --- scripts/install-gitleaks.sh | 18 +++++++++++------- tests/run.sh | 9 +++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/scripts/install-gitleaks.sh b/scripts/install-gitleaks.sh index eb67723..1d8360c 100755 --- a/scripts/install-gitleaks.sh +++ b/scripts/install-gitleaks.sh @@ -37,15 +37,19 @@ case "$(uname -s)-$(uname -m)" in esac script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) -parent=$(dirname "$destination") -parent_real=$(cd "$parent" 2>/dev/null && pwd -P) || { - printf 'Tool directory parent does not exist: %s\n' "$parent" >&2 +expected_destination="$script_root/.tools/gitleaks" +[[ "$destination" == "$expected_destination" ]] || { + printf 'Refusing unexpected tool directory: %s\n' "$destination" >&2 + exit 2 +} +tools_root="$script_root/.tools" +[[ ! -L "$tools_root" ]] || { printf 'Refusing symlink tools directory: %s\n' "$tools_root" >&2; exit 2; } +mkdir -p "$tools_root" +tools_root_real=$(cd "$tools_root" && pwd -P) +[[ "$tools_root_real" == "$tools_root" ]] || { + printf 'Refusing tools directory outside policy checkout: %s\n' "$tools_root" >&2 exit 2 } -case "$parent_real/" in - "$script_root/"*) ;; - *) printf 'Refusing tool directory outside policy checkout: %s\n' "$destination" >&2; exit 2 ;; -esac [[ ! -L "$destination" ]] || { printf 'Refusing symlink destination: %s\n' "$destination" >&2; exit 2; } [[ ! -L "$destination/gitleaks" ]] || { printf 'Refusing symlink Gitleaks binary: %s\n' "$destination/gitleaks" >&2; exit 2; } [[ ! -L "$destination/gitleaks.sha256" ]] || { printf 'Refusing symlink Gitleaks receipt: %s\n' "$destination/gitleaks.sha256" >&2; exit 2; } diff --git a/tests/run.sh b/tests/run.sh index 21a193a..739a165 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -74,6 +74,15 @@ fi not_a_repo="$test_root/not-a-repo" mkdir "$not_a_repo" "$root/scripts/install-pre-push.sh" prepare --repo "$not_a_repo" --ref "$policy_sha" +fresh_policy="$test_root/fresh-policy" +git clone -q "$root" "$fresh_policy" +git -C "$fresh_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$fresh_policy" checkout -q "$policy_sha" +"$fresh_policy/scripts/install-pre-push.sh" prepare --shared-dir "$fresh_policy" --ref "$policy_sha" +if [[ ! -x "$fresh_policy/.tools/gitleaks/gitleaks" ]]; then + printf 'expected prepare to install Gitleaks in a fresh policy clone\n' >&2 + exit 1 +fi if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref 0000000000000000000000000000000000000000; then printf 'expected installer to refuse wrong policy SHA\n' >&2 From 0a9362f60ca596f6a22d3bc4764e6f31c4b09798 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:44:45 -0500 Subject: [PATCH 12/14] Tighten pre-push bootstrap tests --- tests/run.sh | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/tests/run.sh b/tests/run.sh index 739a165..c3d3041 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -113,24 +113,16 @@ if "$root/scripts/install-pre-push.sh" prepare --shared-dir "$dirty_policy" --re printf 'expected installer to refuse untracked policy checkout files\n' >&2 exit 1 fi -symlink_tool_dir="$test_root/symlink-tool-dir" -ln -s "$test_root/outside-tools" "$symlink_tool_dir" -if "$root/scripts/install-gitleaks.sh" "$symlink_tool_dir"; then - printf 'expected Gitleaks installer to refuse symlink destination\n' >&2 - exit 1 -fi -mkdir -p "$test_root/tool-with-symlink" -ln -s "$test_root/external-gitleaks" "$test_root/tool-with-symlink/gitleaks" -if "$root/scripts/install-gitleaks.sh" "$test_root/tool-with-symlink"; then +binary_symlink_policy="$test_root/binary-symlink-policy" +git clone -q "$root" "$binary_symlink_policy" +git -C "$binary_symlink_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$binary_symlink_policy" checkout -q "$policy_sha" +mkdir -p "$binary_symlink_policy/.tools/gitleaks" +ln -s "$test_root/external-gitleaks" "$binary_symlink_policy/.tools/gitleaks/gitleaks" +if "$binary_symlink_policy/scripts/install-gitleaks.sh" "$binary_symlink_policy/.tools/gitleaks"; then printf 'expected Gitleaks installer to refuse symlink binary\n' >&2 exit 1 fi -mkdir -p "$test_root/tool-with-receipt-symlink" -ln -s "$test_root/external-receipt" "$test_root/tool-with-receipt-symlink/gitleaks.sha256" -if "$root/scripts/install-gitleaks.sh" "$test_root/tool-with-receipt-symlink"; then - printf 'expected Gitleaks installer to refuse symlink receipt\n' >&2 - exit 1 -fi tool_symlink_policy="$test_root/tool-symlink-policy" git clone -q "$root" "$tool_symlink_policy" git -C "$tool_symlink_policy" remote set-url origin https://github.com/vana-com/.github.git @@ -141,6 +133,10 @@ if "$tool_symlink_policy/scripts/install-pre-push.sh" prepare --shared-dir "$too printf 'expected prepare to refuse symlink .tools directory\n' >&2 exit 1 fi +if [[ -e "$test_root/external-tools/gitleaks/gitleaks" ]]; then + printf 'prepare wrote through symlink .tools directory\n' >&2 + exit 1 +fi key='4f3c8b1a9e6d2c7f0b5e1d8a6c3f9b2e''7d4a1c8f5b0e6d3a9c2f7b4e1d8a6c3f' scan() { "$scanner" --repo "$repo" --range "$1" --config "$config" --gitleaks "$gitleaks"; } @@ -595,6 +591,7 @@ hook_policy="$test_root/hook-policy" git clone -q "$root" "$hook_policy" git -C "$hook_policy" remote set-url origin https://github.com/vana-com/.github.git git -C "$hook_policy" checkout -q "$policy_sha" +"$root/scripts/install-pre-push.sh" uninstall --shared-dir "$root" --repo "$repo" --ref "$policy_sha" >/dev/null "$hook_policy/scripts/install-pre-push.sh" --repo "$repo" --shared-dir "$hook_policy" --ref "$policy_sha" >/dev/null mv "$hook_policy/.tools/gitleaks/gitleaks" "$hook_policy/.tools/gitleaks/gitleaks.missing" if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | From e16c9c1ec5001b2672d8463d3dd8f027bbcf8a35 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:48:45 -0500 Subject: [PATCH 13/14] Support safe Gitleaks install destinations --- scripts/install-gitleaks.sh | 32 +++++++++++++++++--------------- tests/run.sh | 11 ++++++++++- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/scripts/install-gitleaks.sh b/scripts/install-gitleaks.sh index 1d8360c..04a2017 100755 --- a/scripts/install-gitleaks.sh +++ b/scripts/install-gitleaks.sh @@ -11,7 +11,8 @@ usage() { } [[ $# -le 1 ]] || usage -destination=${1:-"$PWD/.tools/gitleaks"} +script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +destination=${1:-"$script_root/.tools/gitleaks"} case "$(uname -s)-$(uname -m)" in Linux-x86_64) @@ -36,21 +37,22 @@ case "$(uname -s)-$(uname -m)" in ;; esac -script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) -expected_destination="$script_root/.tools/gitleaks" -[[ "$destination" == "$expected_destination" ]] || { - printf 'Refusing unexpected tool directory: %s\n' "$destination" >&2 - exit 2 -} -tools_root="$script_root/.tools" -[[ ! -L "$tools_root" ]] || { printf 'Refusing symlink tools directory: %s\n' "$tools_root" >&2; exit 2; } -mkdir -p "$tools_root" -tools_root_real=$(cd "$tools_root" && pwd -P) -[[ "$tools_root_real" == "$tools_root" ]] || { - printf 'Refusing tools directory outside policy checkout: %s\n' "$tools_root" >&2 - exit 2 -} +parent=$(dirname "$destination") +[[ ! -L "$parent" ]] || { printf 'Refusing symlink tool parent directory: %s\n' "$parent" >&2; exit 2; } +mkdir -p "$parent" +parent_real=$(cd "$parent" && pwd -P) +destination="$parent_real/$(basename "$destination")" [[ ! -L "$destination" ]] || { printf 'Refusing symlink destination: %s\n' "$destination" >&2; exit 2; } +if [[ "$destination" == "$script_root/.tools/gitleaks" ]]; then + case "$destination/" in + "$script_root/"*) ;; + *) printf 'Refusing default tool directory outside policy checkout: %s\n' "$destination" >&2; exit 2 ;; + esac +fi +if [[ -e "$destination" && ! -d "$destination" ]]; then + printf 'Refusing non-directory destination: %s\n' "$destination" >&2 + exit 2 +fi [[ ! -L "$destination/gitleaks" ]] || { printf 'Refusing symlink Gitleaks binary: %s\n' "$destination/gitleaks" >&2; exit 2; } [[ ! -L "$destination/gitleaks.sha256" ]] || { printf 'Refusing symlink Gitleaks receipt: %s\n' "$destination/gitleaks.sha256" >&2; exit 2; } mkdir -p "$destination" diff --git a/tests/run.sh b/tests/run.sh index c3d3041..1ce14d5 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -83,6 +83,15 @@ if [[ ! -x "$fresh_policy/.tools/gitleaks/gitleaks" ]]; then printf 'expected prepare to install Gitleaks in a fresh policy clone\n' >&2 exit 1 fi +relative_policy="$test_root/relative-policy" +git clone -q "$root" "$relative_policy" +git -C "$relative_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$relative_policy" checkout -q "$policy_sha" +(cd "$relative_policy" && scripts/install-gitleaks.sh .tools/gitleaks) +if [[ ! -x "$relative_policy/.tools/gitleaks/gitleaks" ]]; then + printf 'expected relative Gitleaks install destination to work\n' >&2 + exit 1 +fi if "$root/scripts/install-pre-push.sh" --repo "$custom_hook_repo" --ref 0000000000000000000000000000000000000000; then printf 'expected installer to refuse wrong policy SHA\n' >&2 @@ -119,7 +128,7 @@ git -C "$binary_symlink_policy" remote set-url origin https://github.com/vana-co git -C "$binary_symlink_policy" checkout -q "$policy_sha" mkdir -p "$binary_symlink_policy/.tools/gitleaks" ln -s "$test_root/external-gitleaks" "$binary_symlink_policy/.tools/gitleaks/gitleaks" -if "$binary_symlink_policy/scripts/install-gitleaks.sh" "$binary_symlink_policy/.tools/gitleaks"; then +if "$binary_symlink_policy/scripts/install-gitleaks.sh"; then printf 'expected Gitleaks installer to refuse symlink binary\n' >&2 exit 1 fi From 5f1b4b1019af6e3a528dd36d471f94be7dd83632 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 12 Aug 2026 09:59:59 -0500 Subject: [PATCH 14/14] Accept canonical GitHub origin forms --- hooks/pre-push | 2 +- scripts/install-pre-push.sh | 2 +- tests/run.sh | 21 ++++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/hooks/pre-push b/hooks/pre-push index 60d524e..780b78b 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -27,7 +27,7 @@ if [[ "$actual_sha" != "$expected_sha" ]]; then fi origin_url=$(git -C "$shared_dir" config --get remote.origin.url || true) case "$origin_url" in - git@github.com:vana-com/.github.git|https://github.com/vana-com/.github.git) ;; + git@github.com:vana-com/.github|git@github.com:vana-com/.github.git|https://github.com/vana-com/.github|https://github.com/vana-com/.github.git) ;; *) printf 'Vana scanner checkout origin is not vana-com/.github: %s\n' "${origin_url:-}" >&2; exit 2 ;; esac git -C "$shared_dir" diff --quiet -- . && git -C "$shared_dir" diff --cached --quiet -- . || { diff --git a/scripts/install-pre-push.sh b/scripts/install-pre-push.sh index cbdc652..84dac94 100755 --- a/scripts/install-pre-push.sh +++ b/scripts/install-pre-push.sh @@ -60,7 +60,7 @@ actual_sha=$(git -C "$shared_dir" rev-parse HEAD) } origin_url=$(git -C "$shared_dir" config --get remote.origin.url || true) case "$origin_url" in - git@github.com:vana-com/.github.git|https://github.com/vana-com/.github.git) ;; + git@github.com:vana-com/.github|git@github.com:vana-com/.github.git|https://github.com/vana-com/.github|https://github.com/vana-com/.github.git) ;; *) printf 'Policy checkout origin is not vana-com/.github: %s\n' "${origin_url:-}" >&2; exit 2 ;; esac if [[ -n "$(git -C "$shared_dir" status --porcelain --untracked-files=all -- ':!/.tools')" ]]; then diff --git a/tests/run.sh b/tests/run.sh index 1ce14d5..0f652c1 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -76,13 +76,19 @@ mkdir "$not_a_repo" "$root/scripts/install-pre-push.sh" prepare --repo "$not_a_repo" --ref "$policy_sha" fresh_policy="$test_root/fresh-policy" git clone -q "$root" "$fresh_policy" -git -C "$fresh_policy" remote set-url origin https://github.com/vana-com/.github.git +git -C "$fresh_policy" remote set-url origin https://github.com/vana-com/.github git -C "$fresh_policy" checkout -q "$policy_sha" "$fresh_policy/scripts/install-pre-push.sh" prepare --shared-dir "$fresh_policy" --ref "$policy_sha" if [[ ! -x "$fresh_policy/.tools/gitleaks/gitleaks" ]]; then printf 'expected prepare to install Gitleaks in a fresh policy clone\n' >&2 exit 1 fi +git -C "$fresh_policy" remote set-url origin https://github.com/vana-com/.github-lookalike +if "$fresh_policy/scripts/install-pre-push.sh" prepare --shared-dir "$fresh_policy" --ref "$policy_sha"; then + printf 'expected installer to reject lookalike policy origin\n' >&2 + exit 1 +fi +git -C "$fresh_policy" remote set-url origin https://github.com/vana-com/.github relative_policy="$test_root/relative-policy" git clone -q "$root" "$relative_policy" git -C "$relative_policy" remote set-url origin https://github.com/vana-com/.github.git @@ -602,6 +608,19 @@ git -C "$hook_policy" remote set-url origin https://github.com/vana-com/.github. git -C "$hook_policy" checkout -q "$policy_sha" "$root/scripts/install-pre-push.sh" uninstall --shared-dir "$root" --repo "$repo" --ref "$policy_sha" >/dev/null "$hook_policy/scripts/install-pre-push.sh" --repo "$repo" --shared-dir "$hook_policy" --ref "$policy_sha" >/dev/null +git -C "$hook_policy" remote set-url origin https://github.com/vana-com/.github +if ! (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | + .git/hooks/pre-push origin example.invalid) >/dev/null 2>&1; then + printf 'expected pre-push hook to accept GitHub origin without .git suffix\n' >&2 + exit 1 +fi +git -C "$hook_policy" remote set-url origin https://github.com/vana-com/.github-lookalike +if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | + .git/hooks/pre-push origin example.invalid) >/dev/null 2>&1; then + printf 'expected pre-push hook to reject lookalike policy origin\n' >&2 + exit 1 +fi +git -C "$hook_policy" remote set-url origin https://github.com/vana-com/.github.git mv "$hook_policy/.tools/gitleaks/gitleaks" "$hook_policy/.tools/gitleaks/gitleaks.missing" if (cd "$repo" && printf 'refs/heads/main %s refs/heads/main %040d\n' "$clean" 0 | .git/hooks/pre-push origin example.invalid) >/dev/null 2>&1; then