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..fdeb836 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 scripts/evm-key-liveness/scan.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..f2dfff4 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 +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 @@ -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 @@ -42,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 @@ -59,21 +69,33 @@ 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 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. 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 +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 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 @@ -103,14 +125,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 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. +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. ## 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/hooks/pre-push b/hooks/pre-push index eb588be..780b78b 100755 --- a/hooks/pre-push +++ b/hooks/pre-push @@ -8,17 +8,37 @@ 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" -[[ -x "$scanner" && -x "$installer" ]] || { +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 } +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@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 -- . || { + 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" +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 remote_name=${1:-origin} diff --git a/scripts/evm-key-liveness/LICENSE b/scripts/evm-key-liveness/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/scripts/evm-key-liveness/LICENSE @@ -0,0 +1,201 @@ + 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 + (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. 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 + 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 (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 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. 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. + 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 (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 (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/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..af0ec8f --- /dev/null +++ b/scripts/evm-key-liveness/scan.mjs @@ -0,0 +1,260 @@ +// 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"; +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, 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: requestId, method, params }), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`RPC returned HTTP ${response.status}`); + const payload = await response.json(); + 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); + } +} + +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++) { + 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; + 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(async () => parseEthereumQuantity(await 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) => { + const [nonce, balance] = await Promise.allSettled([ + 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]); + } + 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 }); + } + 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(`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(`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 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}`); +} + +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/scripts/evm-key-liveness/scan.test.mjs b/scripts/evm-key-liveness/scan.test.mjs new file mode 100644 index 0000000..b09a09d --- /dev/null +++ b/scripts/evm-key-liveness/scan.test.mjs @@ -0,0 +1,280 @@ +// 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, + parseEthereumQuantity, + parseCli, + reportAdvisory, + sanitizeError, +} from "./scan.mjs"; + +const key = ["0xd1e5b1a0f6c8e3a94b7f2c5d8e0a3f6b", "9c2d5e8f1a4b7c0d3e6f9a2b5c8d1e4f"].join(""); +const address = "0x0ceb6d5e139c6f79ab76d69a0d81d4ade23f0f3b"; + +function response(result, id) { + return { ok: true, json: async () => ({ jsonrpc: "2.0", id, 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("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([ + { 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, id } = JSON.parse(request.body); + calls.push(method); + 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"]); + 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) => { + const { method, id } = JSON.parse(request.body); + return response(method === "eth_blockNumber" ? "0x10" : "0x0", id); + }, + 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, id } = JSON.parse(request.body); + if (method === "eth_blockNumber" && attempts++ === 0) throw new Error("transient failure"); + return response(method === "eth_blockNumber" ? "0x10" : "0x0", id); + }, + attempts: 2, + baseDelayMs: 0, + }); + assert.equal(attempts, 2); + 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("rejects a noncanonical block-number quantity", async () => { + 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, + }); + 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([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); + return response("0x01", id); + }, + attempts: 1, + baseDelayMs: 0, + }); + assert.deepEqual(result.used.get(address), ["mock"]); + 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" }], { + 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("0x01", id); + return response("0x1", id); + }, + attempts: 1, + baseDelayMs: 0, + }); + assert.deepEqual(result.used.get(address), ["mock"]); + assert.equal(result.status[0].ok, false); + 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" }], { + 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 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], /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); + 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 }); + } +}); diff --git a/scripts/install-gitleaks.sh b/scripts/install-gitleaks.sh index 6167ae9..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,6 +37,24 @@ case "$(uname -s)-$(uname -m)" in ;; esac +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" workdir=$(mktemp -d) trap 'rm -rf "$workdir"' EXIT diff --git a/scripts/install-pre-push.sh b/scripts/install-pre-push.sh index f283424..84dac94 100755 --- a/scripts/install-pre-push.sh +++ b/scripts/install-pre-push.sh @@ -3,36 +3,175 @@ 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 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 } +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=${2:-}; shift 2 ;; --help|-h) usage ;; *) printf 'Unknown argument: %s\n' "$1" >&2; usage ;; esac done -[[ -n "$shared_dir" ]] || usage -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) +case "$command_name" in + install|prepare|status|uninstall) ;; + *) printf 'Unknown command: %s\n' "$command_name" >&2; usage ;; +esac +[[ -n "$expected_sha" ]] || { printf 'Missing required --ref .\n' >&2; exit 2; } +[[ "$expected_sha" =~ ^[0-9a-f]{40}$ ]] || { + 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 + 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@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 + 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" -if [[ -e "$hook" ]]; then +if [[ -L "$hook" ]]; then + 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 + if [[ $is_exact_managed -eq 1 && -x "$hook" ]]; then + printf 'Vana EVM keyscan pre-push hook is installed at %s\n' "$hook" + 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" >&2 + fi + exit 1 + ;; + uninstall) + if [[ $is_exact_managed -eq 1 ]]; then + rm "$hook" + printf 'Removed Vana EVM keyscan pre-push hook at %s\n' "$hook" + exit 0 + fi + if [[ -e "$hook" ]]; then + printf 'Refusing to remove hook not installed from this policy checkout: %s\n' "$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_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 +prepare_gitleaks 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/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 diff --git a/scripts/verify-gitleaks.sh b/scripts/verify-gitleaks.sh new file mode 100755 index 0000000..7ee2918 --- /dev/null +++ b/scripts/verify-gitleaks.sh @@ -0,0 +1,43 @@ +#!/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 +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; } +[[ -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 601d065..0f652c1 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,140 @@ 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" +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) +[[ "$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 +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 +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" +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 -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 +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 + exit 1 +fi +bad_origin="$test_root/bad-origin" +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 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 + 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 +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"; then + printf 'expected Gitleaks installer to refuse symlink binary\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" +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 + 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"; } expect_scan_status() { @@ -455,7 +590,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 @@ -467,6 +602,42 @@ if grep -Fq "$key" "$hook_output"; then exit 1 fi +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 +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 + printf 'expected pre-push hook to fail closed when Gitleaks is missing\n' >&2 + exit 1 +fi +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 +"$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. new_branch=$(commit_file new-branch.ts 'export const release = true;' new-branch) git -C "$repo" update-ref refs/remotes/origin/main "$upstream_mutation" @@ -475,10 +646,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)