Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/audit-freeze.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Audit Freeze Gate

# Blocks PRs that touch an audit-frozen path (per audit-prep/ENGAGEMENT.md's
# freeze_paths / freeze_until front matter) unless they carry the
# "audit-approved" label.
#
# SECURITY, two layers:
#
# 1. This job checks out the PR's BASE ref (github.event.pull_request.base.sha),
# not the default `pull_request` merge ref. That means the audit-freeze
# script that actually runs is always the trusted, already-merged version
# -- a PR cannot modify scripts/audit-freeze/*.ts to weaken or disable its
# own gate.
# 2. check.ts itself additionally fetches ENGAGEMENT.md's content from that
# same base SHA via the GitHub API (rather than trusting whatever is on
# disk), and treats ENGAGEMENT.md as an always-frozen path in its own
# right while any freeze is active. See scripts/audit-freeze/check.ts and
# decide.ts for the full rationale.
#
# Net effect: nothing this job does depends on any file content contributed
# by the PR under test. Every input is either the base-ref checkout, or a
# read-only GitHub API call pinned to the base SHA.

on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]

permissions:
contents: read
pull-requests: read

jobs:
audit-freeze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Base ref, not the PR merge ref -- see security note above.
ref: ${{ github.event.pull_request.base.sha }}

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Enable corepack (pins pnpm from packageManager field)
run: |
corepack enable
corepack prepare pnpm@10.28.2 --activate

- name: Check whether audit-freeze exists on the base branch yet
id: gate_exists
run: |
if [ -f scripts/audit-freeze/check.ts ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Install root dependencies (for tsx)
if: steps.gate_exists.outputs.exists == 'true'
run: pnpm install --frozen-lockfile

- name: Run audit-freeze check
if: steps.gate_exists.outputs.exists == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: npx tsx scripts/audit-freeze/check.ts

- name: No audit-freeze system on base branch yet
if: steps.gate_exists.outputs.exists == 'false'
run: echo "scripts/audit-freeze/check.ts does not exist on the base branch yet -- nothing to enforce. Expected for the PR that first introduces the audit-freeze system."
84 changes: 84 additions & 0 deletions audit-prep/ENGAGEMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
freeze_paths:
- 'stellar/stealth-announcer/**'
- 'stellar/stealth-registry/**'
- 'stellar/stealth-sender/**'
- 'stellar/wraith-names/**'
freeze_until: 'TBD'
---

# Audit Engagement

> **Template.** This document has the front-matter shape and section
> structure `audit-freeze.yml` expects, with the actual engagement details
> left as clearly-marked `[TBD]` placeholders below. The freeze is inactive
> (`freeze_until: "TBD"`) until those placeholders -- and the front matter
> above -- are filled in with real values from a signed SOW.
>
> `freeze_paths` above lists the four core in-scope crates from
> [`audit-prep/README.md`](./README.md) as a starting point; adjust it if
> the signed SOW's scope differs. See [How the freeze works](#how-the-freeze-works)
> below for exactly how these two fields are interpreted.

## Engagement Summary

| Field | Value |
| --------------------- | ----- |
| **Audit Firm** | [TBD] |
| **SOW Signed** | [TBD] |
| **Kickoff Date** | [TBD] |
| **Expected Delivery** | [TBD] |

## Scope

### In-Scope Crates

- [TBD] -- e.g. `stellar/stealth-announcer`
- [TBD] -- e.g. `stellar/stealth-registry`
- [TBD] -- e.g. `stellar/stealth-sender`
- [TBD] -- e.g. `stellar/wraith-names`

### Out-of-Scope Crates

- [TBD] -- e.g. `stellar/stealth-splitter` (optional, per audit-prep/README.md)
- [TBD] -- e.g. `evm/`, `solana/`, `ckb/` (separate audits planned)

## Delivery Milestones

| Milestone | Target Date | Status |
| -------------------------------- | ----------- | ------ |
| [TBD] -- e.g. Kickoff | [TBD] | [TBD] |
| [TBD] -- e.g. Initial findings | [TBD] | [TBD] |
| [TBD] -- e.g. Final report | [TBD] | [TBD] |
| [TBD] -- e.g. Remediation review | [TBD] | [TBD] |

## Escalation Contacts

| Role | Name | Contact |
| ----------------- | ----- | ------- |
| Audit Coordinator | [TBD] | [TBD] |
| Technical Contact | [TBD] | [TBD] |
| Audit Firm Lead | [TBD] | [TBD] |

## Disclosure Policy

[TBD] -- e.g. coordinated disclosure terms, embargo period, public
disclosure timeline once remediation is verified.

## How the Freeze Works

While `freeze_until` (above, in the front matter) is a real timestamp in the
future, [`.github/workflows/audit-freeze.yml`](../.github/workflows/audit-freeze.yml)
blocks any pull request that touches a path matching `freeze_paths` unless
the PR carries the `audit-approved` label. Once `freeze_until` passes, or is
reset to `"TBD"`, the gate is inactive again.

This file (`audit-prep/ENGAGEMENT.md`) is always treated as a frozen path in
its own right whenever a freeze is active, regardless of whether it's
explicitly listed in `freeze_paths` -- so a PR can't shorten or remove its
own freeze window to slip changes past the gate. The gate also always reads
this file's content from the pull request's base ref, never its head ref,
as a second, independent layer of the same protection. See
[`scripts/audit-freeze/check.ts`](../scripts/audit-freeze/check.ts) and
[`scripts/audit-freeze/decide.ts`](../scripts/audit-freeze/decide.ts) for
the implementation.
84 changes: 84 additions & 0 deletions scripts/audit-freeze/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# audit-freeze

CI gate that blocks pull requests from touching audit-frozen paths without
the `audit-approved` label, once a real audit engagement is signed.

## How it works

[`audit-prep/ENGAGEMENT.md`](../../audit-prep/ENGAGEMENT.md) carries two
front-matter fields:

```yaml
---
freeze_paths:
- 'stellar/stealth-announcer/**'
freeze_until: '2026-09-30T00:00:00Z'
---
```

[`.github/workflows/audit-freeze.yml`](../../.github/workflows/audit-freeze.yml)
runs `check.ts` on every pull request. If `freeze_until` is a real timestamp
in the future, and the PR touches a path matching `freeze_paths`, the check
fails (exit 1) unless the PR carries the `audit-approved` label.

If `freeze_until` is missing, in the past, or the literal placeholder
`"TBD"` (its default value in the template), the gate is inactive and every
PR passes.

## Security design: why a PR can't shorten its own freeze

A freeze window that's defined by a file in the repo has an obvious hole: a
PR could edit `ENGAGEMENT.md` to shorten or remove `freeze_until`, then have
that same PR's check read its own edited version and pass. This gate closes
that hole with two independent layers:

1. **The workflow checks out the PR's base ref, not the default PR merge
ref** (`ref: ${{ github.event.pull_request.base.sha }}` in
`audit-freeze.yml`). This means the copy of `check.ts` (and everything
else in `scripts/audit-freeze/`) that actually executes is always the
already-merged, trusted version -- a PR cannot modify the gate's own
logic to weaken or disable it. `check.ts` then separately fetches
`ENGAGEMENT.md`'s content from that same base SHA via the GitHub
Contents API (`fetchEngagementDocAtRef` in `check.ts`), rather than
reading whatever is checked out on disk -- so even if the checkout step
were ever changed to use the head ref instead, the freeze parameters
themselves would still come from the base.

2. **`ENGAGEMENT.md` is always treated as a frozen path in its own right**
whenever a freeze is active, regardless of whether it's explicitly
listed in `freeze_paths` (see `ENGAGEMENT_DOC_PATH` in `decide.ts`, and
the "always treats it as frozen" test in `test/decide.test.ts`). This
holds even if the base-ref-reading approach above were ever to regress.

Both were straightforward to add, so both are in: the base-ref read is the
primary mechanism (it's what actually prevents the freeze _parameters_ from
being attacker-controlled), and treating `ENGAGEMENT.md` as self-frozen is a
cheap second layer that still requires the `audit-approved` label for any
edit to it while a freeze is active, including legitimate ones (e.g.
updating milestones mid-engagement).

## Testing

```bash
cd scripts/audit-freeze
npm install
npm test
```

- `test/glob.test.ts`, `test/parse.test.ts`, `test/decide.test.ts` -- unit
tests for the pure logic (glob matching, front-matter parsing, the
pass/fail decision), no network required.
- `test/check.e2e.test.ts` -- runs the actual `check.ts` CLI as a
subprocess against a local mock GitHub API server, covering: a blocked PR,
an approved-label override, no active freeze, a missing `ENGAGEMENT.md` at
the base ref (404), and the ENGAGEMENT.md self-protection case.

### Manual dry-run against a real PR

```bash
GITHUB_TOKEN=<token> npx tsx check.ts --repo wraith-protocol/contracts --pr <number> --dry-run
```

Reports what the gate _would_ decide for an existing PR without exiting
non-zero -- useful for sanity-checking `freeze_paths` changes before they go
live, or for reproducing a CI failure locally.
Loading
Loading