Repository navigation
ci: pr-guard, a security gate for pull requests + a lock on the deployed contracts #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
220f612
contracts: deployed.lock pins the live sources; check-deployed-source…
kevincodex1 7265d16
ci: pr-guard, a security gate for pull requests; actions and Foundry …
kevincodex1 094d192
ci: pr-guard review fixes: who pushed the head, off-registry specs, s…
kevincodex1 7140291
ci: pr-guard: SVGs are read whole, served files need an owner, delete…
kevincodex1 22879a0
ci: pr-guard: the XHTML namespace is matched by its end; CONTRIBUTING…
kevincodex1 4c69fa3
docs: CONTRIBUTING lists the guard's outcomes rule by rule, and a tes…
kevincodex1 f2a5c06
ci: pr-guard: hosts finished at run time, checks lost with a deleted …
kevincodex1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # Review is requested from the owner automatically when a pull request touches these paths. The pr-guard workflow is | ||
| # what enforces it (.github/scripts/pr-guard.mjs); keep OWNERS there and the names here the same. | ||
|
|
||
| /contracts/ @kevincodex1 | ||
| /.github/ @kevincodex1 | ||
| /.gitmodules @kevincodex1 | ||
| /app/package.json @kevincodex1 | ||
| /app/package-lock.json @kevincodex1 | ||
| /app/.npmrc @kevincodex1 | ||
| /SECURITY.md @kevincodex1 | ||
| /LICENSE @kevincodex1 |
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| name: pr-guard | ||
|
|
||
| # The security gate for pull requests into main. The rules and their wording live in .github/scripts/pr-guard.mjs. | ||
| # | ||
| # pull_request_target runs main's copy of this workflow and of the script, so a pull request cannot edit its own judge. | ||
| # The job has a read-only token and no secrets. It never checks out, installs or runs anything from the pull request: | ||
| # the head commit is fetched as git objects and only read (git diff-tree, git cat-file, git diff). | ||
| # | ||
| # A change to this file, or to anything under .github/, is itself refused by the guard for everyone. Maintainers make it | ||
| # in a pull request of its own and merge that with an admin bypass. | ||
| # | ||
| # "pr-guard" is the name of the required status check in the branch rules for main. Do not rename the job. | ||
| # | ||
| # The check is a snapshot of the reviews at the moment it ran. It is not started by a review being submitted or | ||
| # dismissed, on purpose: pull_request_review runs the pull request's copy of this file, not main's, and on a fork its | ||
| # token is read-only, so that run could neither be trusted to judge nor re-run this one. Refreshing the check from a | ||
| # trusted run takes a write permission, and no workflow here has one. So after approving a commit, or after taking an | ||
| # approval back, an owner re-runs this job (CONTRIBUTING.md, "For maintainers"). | ||
|
|
||
| on: | ||
| pull_request_target: # zizmor: ignore[dangerous-triggers] main's copy judges, and nothing from the pull request is run | ||
| branches: [main] | ||
| types: [opened, synchronize, reopened] | ||
|
|
||
| permissions: {} | ||
|
|
||
| concurrency: | ||
| group: pr-guard-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| pr-guard: | ||
| name: pr-guard | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 10 | ||
| permissions: | ||
| contents: read # fetch the pull request's commits as git objects; read who pushed its branch | ||
| pull-requests: read # read who approved which commit | ||
| steps: | ||
| - name: check out main (trusted) | ||
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | ||
| with: | ||
| fetch-depth: 0 | ||
| persist-credentials: false | ||
| - name: fetch the pull request as data | ||
| env: | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| run: | | ||
| git fetch --quiet --no-tags --no-recurse-submodules origin "+refs/pull/${PR_NUMBER}/head:refs/pr-guard/head" | ||
| git cat-file -e "${HEAD_SHA}^{commit}" | ||
| - name: guard | ||
| env: | ||
| GUARD_BASE: HEAD | ||
| GUARD_HEAD: ${{ github.event.pull_request.head.sha }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| PR_AUTHOR: ${{ github.event.pull_request.user.login }} | ||
| # who started this run, and how: the sender pushed the head commit only when the event is a push (synchronize) | ||
| # and the commit that push left behind is the head | ||
| EVENT_ACTION: ${{ github.event.action }} | ||
| EVENT_AFTER: ${{ github.event.after }} | ||
| EVENT_SENDER: ${{ github.event.sender.login }} | ||
| GITHUB_TOKEN: ${{ github.token }} | ||
| run: node .github/scripts/pr-guard.mjs | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # Deployed contract sources, and everything that decides their bytecode. These files stay byte-identical to what is live. | ||
| # | ||
| # contracts/src and the pinned libraries were compared with the exact-match verified source on Sourcify for Base (8453), | ||
| # Robinhood Chain (4663) and Arc (5042) on 2026-10-11: every source file identical, solc 0.8.26, optimizer 200 runs, via-IR, | ||
| # cancun. | ||
| # | ||
| # A change for another chain is a new copied file, never an edit (src/LaunchFactoryArc.sol is the pattern). A contract that | ||
| # goes live is added here with: contracts/script/check-deployed-sources.sh --update <path> | ||
| # CI runs the same script without arguments, and the pull request guard refuses any change to a path listed here. | ||
| sha256 1b9e3dc7a32975db40bbdfe9ce3dd0be679ce187a5a4037597d583be1a615215 contracts/src/LaunchFactory.sol | ||
| sha256 ed49094964c189ac8b42aa34f2c2cce5b0c0430f2c7278c16b2785ae25c49789 contracts/src/LaunchFactoryArc.sol | ||
| sha256 e394dcba3d27cf1ecff39f4ee0da5ef36346795163bc938aa0ad045a4db68fb9 contracts/src/LaunchLocker.sol | ||
| sha256 d5a6481e74e890232d99d9079f1df9cbdedeee4f4b6d9b0fe434586092258765 contracts/src/LaunchToken.sol | ||
| sha256 cae205b3fe08f484af012412ae4db540b3ca60118f32eed6bb15d228f8f0655f contracts/docs/LaunchFactoryArc.diff | ||
| sha256 341d960dac7e913296719d5a87bd9cd7f1a1217cfb7c3aeb36226bc6c0e8cbff contracts/foundry.toml | ||
| sha256 34d5dcb81a28ea5643b761a188b3a30323bcf9068cdcf139bd06b3a9753e3bec contracts/remappings.txt | ||
| sha256 ead32adedb4800c3dbb07e312d558696780d55a4f11751a32fe0001f8269c0d0 contracts/deployments/launchpad-arc.json | ||
| sha256 771d778fc16d42362623e65048dcc4b93f2b72a6c45b13a15df3db13b02675ed contracts/deployments/launchpad-base.json | ||
| sha256 924b0a14b5c1d42a240c99a99106e1ebc460027f5541fa7ca1648ed953812bdd contracts/deployments/launchpad-robinhood.json | ||
| sha256 749fcc571c1a79f57ad7ea1e45c287abdeb021b6d0fa014ecb7005265a8742e3 .gitmodules | ||
| gitlink 886b4f8b63409ef474542de6394d25a9b5908ed3 contracts/lib/forge-std | ||
| gitlink 07336f2144f522874e2c3c85e04d1d3f8d5fa471 contracts/lib/v4-periphery |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| #!/usr/bin/env bash | ||
| # The deployed contract sources, their build settings and the pinned libraries must match contracts/deployed.lock byte for | ||
| # byte. Fails on any drift. Run from anywhere. | ||
| # script/check-deployed-sources.sh check | ||
| # script/check-deployed-sources.sh --update [path…] rewrite the hashes; extra paths (from the repo root) are added | ||
| set -euo pipefail | ||
| cd "$(dirname "$0")/../.." | ||
| lock=contracts/deployed.lock | ||
|
|
||
| # path -> the file's sha256 | ||
| sha() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; } | ||
| # path -> the commit a submodule is pinned at, empty when the path is not a submodule | ||
| gitlink() { git ls-files -s -- "$1" | awk '$1 == "160000" { print $2 }'; } | ||
| # path -> succeeds when git tracks the file. A file that only exists on this machine is not there in a fresh checkout. | ||
| tracked() { git --literal-pathspecs ls-files --error-unmatch -- "$1" >/dev/null 2>&1; } | ||
| current() { # kind path -> the value the lock should hold, empty when the path is gone or git does not track it | ||
| if [[ "$1" == gitlink ]]; then gitlink "$2"; elif [[ -f "$2" && ! -L "$2" ]] && tracked "$2"; then sha "$2"; fi | ||
| } | ||
|
|
||
| if [[ "${1:-}" == "--update" ]]; then | ||
| shift | ||
| tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT | ||
| grep -E '^(#|$)' "$lock" > "$tmp" || true | ||
| { grep -E '^(sha256|gitlink) ' "$lock" | awk '{ print $1, $3 }' || true # a lock with no entries yet | ||
| for extra in "$@"; do | ||
| # the lock holds each path as git spells it from the repository root, however it was typed here: the guard | ||
| # compares the lock's paths with the paths of a pull request letter for letter | ||
| path=$(git --literal-pathspecs ls-files --full-name -- "$extra" 2>/dev/null) || path= | ||
| [[ -n "$path" && "$path" != *$'\n'* ]] || { echo "cannot lock $extra: not one file or submodule that git tracks in this repository (git add it first)" >&2; exit 1; } | ||
| if [[ -n "$(gitlink "$path")" ]]; then echo "gitlink $path"; else echo "sha256 $path"; fi | ||
| done | ||
| } | awk '!seen[$2]++' | while read -r kind path; do | ||
| value=$(current "$kind" "$path") | ||
| [[ -n "$value" ]] || { echo "cannot lock $path: the file is gone, or git no longer tracks it" >&2; exit 1; } | ||
| printf '%s %s %s\n' "$kind" "$value" "$path" | ||
| done >> "$tmp" | ||
| mv "$tmp" "$lock"; echo "updated $lock"; exit 0 | ||
| fi | ||
|
|
||
| failed=0; checked=0 | ||
| while read -r kind want path || [[ -n "$kind" ]]; do # the last line counts with or without its newline | ||
| [[ "$kind" == sha256 || "$kind" == gitlink ]] || continue | ||
| checked=$((checked + 1)) | ||
| got=$(current "$kind" "$path") | ||
| if [[ "$got" != "$want" ]]; then | ||
| if [[ "$kind" == sha256 && -z "$got" && -f "$path" ]]; then echo "DRIFT $path: the file is here, but git does not track it" | ||
| else echo "DRIFT $path: locked $kind ${want:0:12}…, found ${got:-nothing}"; fi | ||
| failed=1; continue | ||
| fi | ||
| # the library that gets compiled is the checked-out one, so it has to be the pinned commit too, with nothing edited, | ||
| # added or re-pinned inside it. A library that is not checked out at all (an empty folder) has nothing to compile. | ||
| if [[ "$kind" == gitlink && -e "$path/.git" ]]; then | ||
| out=$(git -C "$path" rev-parse HEAD) | ||
| if [[ "$out" != "$want" ]]; then | ||
| echo "DRIFT $path: checked out at ${out:0:12}…, pinned at ${want:0:12}… (git submodule update)"; failed=1 | ||
| elif ! changed=$(git -C "$path" status --porcelain --untracked-files=normal --ignore-submodules=none) || [[ -n "$changed" ]]; then | ||
| echo "DRIFT $path: files differ from the pinned commit, or could not be compared with it (git -C $path status)"; failed=1 | ||
| fi | ||
| elif [[ "$kind" == gitlink && -n "$(ls -A "$path" 2>/dev/null)" ]]; then | ||
| echo "DRIFT $path: holds files, but is not a checkout of the pinned commit (git submodule update --init)"; failed=1 | ||
| fi | ||
| done < "$lock" | ||
|
|
||
| if [[ "$checked" -eq 0 ]]; then echo "$lock lists nothing to check"; exit 1; fi | ||
| if [[ "$failed" -ne 0 ]]; then | ||
| echo "Deployed contract files are never edited. A change for another chain goes in a new copied file (see src/LaunchFactoryArc.sol)." | ||
| exit 1 | ||
| fi | ||
| echo "ok: $checked deployed contract files and library pins match $lock" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Rerun the guard when owner approval changes.
If the guard requires owner approval, an approval submitted after its initial run leaves the required check failed. A dismissed approval can also leave an earlier passing check in place.
opened,synchronize, andreopeneddo not cover review submissions or dismissals. Add a trusted-base mechanism that reevaluates the current reviews and reports a check for the same head commit; do not check out PR code in a review-triggered workflow. (docs.github.com)🧰 Tools
🪛 zizmor (1.30.1)
[error] 14-17: use of fundamentally insecure workflow trigger (dangerous-triggers): pull_request_target is almost always used insecurely
(dangerous-triggers)
🤖 Prompt for AI Agents