Compare commits

...
Author SHA1 Message Date
Garry Tan 6af3b1f58a Merge remote-tracking branch 'origin/master' into garrytan/contributor-security-sweep
# Conflicts:
#	.claude-plugin/plugin.json
#	.codex-plugin/plugin.json
#	BOOTSTRAP_FOR_AGENTS.md
#	CHANGELOG.md
#	TODOS.md
#	VERSION
#	openclaw.plugin.json
#	package.json
#	plugin/README.md
#	templates/bootstrap/template-repo/README.md
2026-08-16 15:27:49 -07:00
Garry Tan d2557b047d Merge remote-tracking branch 'origin/master' into garrytan/contributor-security-sweep
# Conflicts:
#	.claude-plugin/plugin.json
#	.codex-plugin/plugin.json
#	BOOTSTRAP_FOR_AGENTS.md
#	CHANGELOG.md
#	VERSION
#	openclaw.plugin.json
#	package.json
#	plugin/README.md
#	templates/bootstrap/template-repo/README.md
2026-08-16 15:24:51 -07:00
Garry TanandClaude Fable 5 92b8cf67e5 test: stop fixture git commits inheriting global commit.gpgsign (#1696 flake)
Fixture tests that `git commit` in temp repos inherited the developer's global
commit.gpgsign; a signing gpg-agent OOMs under full-suite memory pressure
("gpg: signing failed: Cannot allocate memory") and fails a random fixture
commit. The unit + serial runners now inject GIT_CONFIG commit.gpgsign=false
(highest-precedence, whole process tree), and the two fixtures I own set it in
their repo config directly. Deterministic, machine-config-independent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:09:55 -07:00
Garry TanandClaude Fable 5 63a2a4b2ef v0.46.12.1 chore: re-version supply-chain hardening 0.46.12.0 -> 0.46.12.1 (queue collision)
0.46.12.0 was claimed by concurrent ships; this PR takes the .1 micro slot.
Five-file version lockstep + CHANGELOG header + regenerated version-stamped
artifacts (bootstrap tag, template repo, plugin tree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:09:55 -07:00
Garry Tan 394a4343ce Merge remote-tracking branch 'origin/master' into garrytan/contributor-security-sweep 2026-08-16 14:47:37 -07:00
Garry TanandClaude Fable 5 371ae12615 docs(security): drop 'signed' from self-update wording; note downgrade-replay guard
The updater matches the attestation's digest/identity over GitHub API TLS but
does not verify the Sigstore signature chain — align SECURITY.md with the honest
wording already in upgrade.ts. Also document the version-mismatch downgrade guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:42:13 -07:00
Garry TanandClaude Fable 5 9249231e40 docs: fix codex-review doc drift (test-tier count, integrity version attribution)
Cross-model doc review (Codex, high effort) against origin/master...HEAD found:
- docs/TESTING.md: header said "Six test command tiers" but the table now lists
  seven after this release added the test:compile-smoke row.
- TODOS.md: the self-update integrity work (verifyIntegrity) was attributed to
  v0.46.11.0 in two places; it shipped in v0.46.12.0 (v0.46.11.0 was the
  unrelated five-issue operational wave). Corrected both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:40:03 -07:00
Garry TanandClaude Fable 5 28c766fe32 docs: sync contributor + README docs for v0.46.12.0 supply-chain hardening
- CONTRIBUTING.md: correct the PR-side Semgrep description — it graduated from
  advisory/non-blocking to blocking on findings new since the PR base
  (pre-existing findings never block; scheduled runs stay report-only), matching
  SECURITY.md's "Automated security scanning" section.
- README.md: expand the SECURITY.md doc-link description to surface the new
  install-path trust model, self-update integrity, and automated scanning
  content so it's discoverable from the entry point.
- llms-full.txt: regenerated (README is inlined in the bundle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:31:04 -07:00
Garry TanandClaude Fable 5 ad5419bf31 chore: bump plugin manifests to 0.46.12.0 (five-file version lockstep)
VERSION/package.json moved to 0.46.12.0; the hand-maintained plugin manifests
(openclaw.plugin.json, .codex-plugin/plugin.json, .claude-plugin/plugin.json)
must track it — pinned by test/codex-plugin-manifest.test.ts +
test/openclaw-plugin-manifest.test.ts (unit suite, not verify).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:10:42 -07:00
Garry TanandClaude Fable 5 12359d8d4a chore: regenerate version-stamped artifacts for 0.46.12.0 (bootstrap tag, template repo, plugin tree)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:52:05 -07:00
Garry TanandClaude Fable 5 5f0ef91bae v0.46.12.0 chore: version bump + CHANGELOG (supply-chain hardening: self-update integrity, fresh admin build, wave security scan)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:49:18 -07:00
Garry Tan ec4ca2af81 Merge remote-tracking branch 'origin/master' into garrytan/contributor-security-sweep
# Conflicts:
#	TODOS.md
2026-08-16 10:48:33 -07:00
Garry TanandClaude Fable 5 8a357092d1 fix(security): close adversarial-review findings in the self-update + wave-scan hardening
Pre-landing adversarial review (Codex + 2 Claude passes, cross-model consensus)
found real defects in the initial hardening; fixed here before merge:

- Downgrade-replay: verifyIntegrity accepted any historically-attested binary,
  so an asset-swap adversary could serve an older, validly-attested vulnerable
  build. Bind the staged binary to the release tag (--version must match) →
  new typed reason version_mismatch, fail-closed before rename.
- Builder-id: pin to exact @refs/heads/master (EXPECTED_BUILDER_IDS), not a
  prefix — a workflow_dispatch from an arbitrary branch mints a real attestation.
- parseAttestationBundle: reject non-SLSA predicateType; never-throws contract
  restored (injected fetchAttestation that throws → integrity_unavailable, no
  staged-file leak).
- upgrade.ts: drop "signed" from the user copy (we match digest/identity over
  TLS; we don't verify the Sigstore signature chain — honest wording).
- wave-security-scan.sh: cd to the caller's repo TOP LEVEL (a subdir run scoped
  the gate to a subtree); match shell `eval "$x"` / `source <(...)`, not just
  `eval(`; is_comment no longer hides JS `#field`/`*gen()`; anchor the diff
  header to `+++ b/` so a `++ x;` content line can't poison attribution; scan
  admin/{package.json,bun.lock} + admin/src (release-reachable); require python3;
  emit exit_code/gate in --json so a consumer can't read alarm:0 while exiting 1.
- semgrep.yml: baseline off `git merge-base "$BASE_SHA" HEAD`, not the raw
  event base.sha (which goes stale when master advances mid-PR).

New contract pins in release-workflow.test.ts (attest step + admin-fresh-build
+ builder-id ref). Compile smoke wired as `bun run test:compile-smoke` + docs.
Two residuals filed (P1 upgrade exit-code/autopilot; P3 API rate-limit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:47:48 -07:00
Garry Tan 5137e5f3c6 Merge remote-tracking branch 'origin/master' into garrytan/contributor-security-sweep
# Conflicts:
#	TODOS.md
2026-08-16 09:54:31 -07:00
Garry Tan 2b89855633 docs(security): install-path trust model, wave security-review step, follow-up TODOs
SECURITY.md documents the self-update integrity check and the install-path
trust model (which paths verify provenance vs trust-on-first-use).
docs/RELEASING.md adds a security-review step to the community-PR wave process
(run wave-security-scan over the collector branch before shipping). CLAUDE.md
carries the pointer; llms bundle regenerated; two follow-up TODOs filed.
2026-08-16 09:53:59 -07:00
Garry Tan 9283d01c6a feat(ci,scripts): wave-security-scan + Semgrep graduates to blocking on net-new findings
scripts/wave-security-scan.sh (bun run wave-security-scan <base>..<head>) is
the repeatable mechanical sweep for community-PR waves: alarm-level checks
(obfuscation/eval in code, gitleaks with the repo allowlist stripped, committed
admin-bundle changes) plus informational context (new outbound URLs, spawns,
env reads, dependency changes). Range guards, --json, non-zero exit on alarms.

semgrep.yml: fetch-depth 0 + --baseline-commit <PR base> so a PR fails only on
findings it introduces; continue-on-error removed (the documented graduation
path). Scheduled runs stay full-tree report-only.
2026-08-16 09:53:59 -07:00
Garry Tan 7dcc2387e0 feat(release): build the admin UI fresh from source in the release job
Release binaries now embed an admin bundle built from admin/src at release
time (frozen-lockfile install, cache keyed on admin/bun.lock) instead of the
committed admin/dist bytes, so the shipped bundle is always traceable to
reviewed source.
2026-08-16 09:53:58 -07:00
Garry Tan 2a450cb9b9 feat(security): verify build-provenance attestation before binary self-update installs
Before the downloaded binary is chmod'd, executed, or renamed over the live
path, compute its SHA-256 and verify it against the SLSA build-provenance
attestation from the GitHub REST API (origin-separated from the asset CDN):
the attested subject digest must match and the builder id must be this repo's
release workflow. Dependency-free (node:crypto + fetch + base64 + JSON — the
sigstore npm package does not bundle under bun build --compile). Fail-closed
with typed integrity_failed / integrity_unavailable reasons; gbrain upgrade
surfaces a dedicated message. Closes the D7a TODO.

Tests: unit suite over the deps.fetchIntegrity seam (tampered digest, wrong
builder, wrong asset name, missing attestation) + an opt-in compiled-binary
offline smoke test (GBRAIN_SELFUPDATE_COMPILE_SMOKE=1) proving the real verify
path survives bun build --compile.
2026-08-16 09:53:58 -07:00
32 changed files with 1398 additions and 55 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.46.12.2",
"version": "0.46.12.3",
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
"author": {
"name": "Garry Tan",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.46.12.2",
"version": "0.46.12.3",
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
"author": {
"name": "Garry Tan",
+12 -1
View File
@@ -94,9 +94,20 @@ jobs:
- uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.bun/install/cache
key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock') }}
key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock', 'admin/bun.lock') }}
restore-keys: bun-cache-${{ runner.os }}-
- run: bun install --frozen-lockfile
# Supply-chain: build the admin UI FRESH from admin/src so the compiled
# binary embeds a bundle a reviewer can trace to source — not the committed
# admin/dist bytes. `build:admin` runs `vite build` then regenerates
# src/admin-embedded.ts to reference the fresh (content-hashed) output, so
# the compile below embeds this build. --frozen-lockfile so the release
# bundle isn't built from caret-drifted admin deps (a supply-chain PR must
# not itself be non-reproducible).
- name: Build admin UI fresh from source
run: |
cd admin && bun install --frozen-lockfile && cd ..
bun run build:admin
# No test re-run here: the Test workflow already gated this exact SHA at
# merge (10 shards + E2E). Re-running the whole suite serially on the
# release runner is a flakier duplicate gate — it blocked the first
+24 -7
View File
@@ -27,10 +27,27 @@ jobs:
image: semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
# Non-blocking initially (continue-on-error): the first runs establish a
# baseline without failing unrelated PRs. Graduation path: once the
# baseline findings are triaged (fixed or `# nosemgrep`'d), remove
# continue-on-error so new findings block PRs.
- name: Semgrep scan (report-only)
run: semgrep scan --config p/default --config p/typescript --error
continue-on-error: true
with:
# Full history so --baseline-commit can diff against the PR base;
# a shallow clone would not contain the base commit.
fetch-depth: 0
# Graduated from advisory: on a PR, fail only on findings NEW since the PR
# base (semgrep --baseline-commit), so legacy findings never block an
# unrelated PR and no full-tree triage is required. Scheduled/dispatch
# runs have no PR base, so they do a full-tree report-only scan.
- name: Semgrep scan
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
# Diff against the MERGE BASE, not the base-branch head captured at
# event time: the checkout is the merge ref against current master,
# so a finding master landed after the event would otherwise be
# attributed to this PR. merge-base is the true common ancestor.
BASELINE="$(git merge-base "$BASE_SHA" HEAD || echo "$BASE_SHA")"
echo "PR scan — failing only on findings new since $BASELINE"
semgrep scan --config p/default --config p/typescript --error --baseline-commit "$BASELINE"
else
echo "Full-tree scan (schedule/dispatch) — report-only"
semgrep scan --config p/default --config p/typescript || true
fi
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.46.12.2 -->
<!-- gbrain-runbook-stamp: 0.46.12.3 -->
<!-- This stamp must equal the VERSION file at every release; CI enforces it
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
the installed binary and warns on skew. -->
+29
View File
@@ -2,6 +2,35 @@
All notable changes to GBrain will be documented in this file.
## [0.46.12.3] - 2026-08-16
**Supply-chain hardening for how gbrain updates and how community code lands.**
A security pass over the update path, the release build, and the contribution
workflow. Nothing here fixes an active exposure; it raises the floor so a
future compromised release channel or a slipped contribution can't turn into a
silent problem.
### Added
- `gbrain upgrade` (compiled-binary self-update) now confirms the download's
integrity before it installs anything. It checks the downloaded binary against
the build-provenance attestation GitHub publishes for each release, and confirms
the binary really is the release it was fetched for. If the check can't be
satisfied, the update is refused and your existing binary is left untouched.
- `wave-security-scan` (`bun run wave-security-scan <base>..<head>`): a repeatable
security sweep for reviewing batches of community contributions before they
ship. It surfaces newly introduced obfuscation, secrets (scanned without the
usual test/skills exclusions), and changes to the bundled admin UI, with
everything else as context.
### Changed
- Release binaries now build the admin UI fresh from source at release time, so
the shipped bundle always corresponds to reviewable source.
- Static analysis (Semgrep) now blocks a pull request on issues that PR
introduces, while never blocking on pre-existing findings.
- `SECURITY.md` documents which install paths verify update integrity and which
remain trust-on-first-use, and `docs/RELEASING.md` adds a security-review step
to the community-contribution process.
## [0.46.12.2] - 2026-08-16
**Your agent can now do over MCP what it could only do from the CLI.** An
+5 -1
View File
@@ -710,7 +710,11 @@ Before any ship, read **[docs/RELEASING.md](docs/RELEASING.md)** in full. It car
full release + contributor process: pre-ship test requirements (`bun run ci:local` / the
E2E lifecycle), the CHANGELOG voice + release-summary template, the "To take advantage of
vX" self-repair block, version migrations, the GitHub Actions SHA refresh, PR conventions,
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.**
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.** Every
community wave runs `bun run wave-security-scan <base>..<head>` (RELEASING.md step 5) before
ship — the repeatable mechanical sweep (obfuscation/eval, gitleaks with the test/skills
allowlist stripped, committed `admin/dist` changes as alarms; new endpoints/spawns/env/deps
as context).
The ship-critical IRON RULES stay inline in this file (do NOT relocate them): the
Version-locations table above (the 5-file sync + the 3-line VERSION/package.json/CHANGELOG
+6 -4
View File
@@ -193,10 +193,12 @@ narrower mappings via `scripts/e2e-test-map.ts`.
### PR-side security checks
Besides the test gate, PRs may trigger three security workflows: Semgrep CE
SAST (every PR — **advisory/non-blocking** while the baseline is tuned, so a
Semgrep finding won't fail your PR), OSV-Scanner (only when `package.json` or
`bun.lock` change), and actionlint (only when `.github/workflows/**` change).
See `SECURITY.md` → "Automated security scanning" for details.
SAST (every PR — **blocking for findings new since the PR base**, so a net-new
issue fails the check while pre-existing findings never block an unrelated PR;
scheduled/dispatch runs do a full-tree report-only scan), OSV-Scanner (only when
`package.json` or `bun.lock` change), and actionlint (only when
`.github/workflows/**` change). See `SECURITY.md` → "Automated security
scanning" for details.
## Building
+1 -1
View File
@@ -495,7 +495,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
- [`AGENTS.md`](AGENTS.md) — entry point for non-Claude agents
- [`CLAUDE.md`](CLAUDE.md) — entry point for Claude Code (deep operating context)
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contributor guide, test discipline, eval-capture mode
- [`SECURITY.md`](SECURITY.md) — OAuth threat model, hardening defaults
- [`SECURITY.md`](SECURITY.md) — install-path trust model, self-update integrity, automated scanning, OAuth threat model, hardening defaults
## Contributing
+32 -5
View File
@@ -16,13 +16,16 @@ CI runs three automated security checks alongside secret scanning (Gitleaks):
(`.github/workflows/osv-scanner.yml`) runs weekly and on any PR that touches
`package.json` or `bun.lock`.
- **Static analysis (SAST)** — Semgrep CE (`.github/workflows/semgrep.yml`)
runs on every PR and weekly. It is currently **advisory (non-blocking)**
while the finding baseline is tuned; the graduation path to a blocking check
is documented in the workflow file.
runs on every PR and weekly. On a PR it is **blocking for findings new since
the PR base** (`--baseline-commit`), so a net-new issue fails the check while
pre-existing findings never block an unrelated PR. Scheduled/dispatch runs do
a full-tree report-only scan.
- **Release binary provenance** — release builds
(`.github/workflows/release.yml`) attest each compiled binary with
[GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations).
Verify a downloaded release binary with:
[GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations),
and build the admin UI fresh from `admin/src` at release time so the shipped
binary embeds a bundle traceable to source (not committed `admin/dist` bytes).
Verify a downloaded release binary manually with:
```bash
gh attestation verify ./gbrain-darwin-arm64 -R garrytan/gbrain
@@ -32,6 +35,30 @@ CI runs three automated security checks alongside secret scanning (Gitleaks):
All security workflows use SHA-pinned actions and least-privilege permissions,
enforced structurally by actionlint on every workflow change.
### Install-path trust model
- **Compiled-binary self-update (`gbrain upgrade` on `darwin-arm64` /
`linux-x64`)** verifies integrity automatically before it installs: it
computes the downloaded binary's SHA-256 and checks it against the build
provenance attestation fetched from the GitHub REST API — a different origin
than the asset CDN — confirming both the attested digest and that the
attestation's builder id is this repo's release workflow. Verification is
fail-closed: on a mismatch or an unfetchable attestation, the download is
discarded and the running binary is left untouched. It also refuses a binary
whose reported version doesn't match the release it was fetched for (a
downgrade-replay guard). The dependency-free check is GitHub-account trust
plus origin separation and a digest/identity match against the attestation
fetched over TLS; it does NOT independently verify the attestation's Sigstore
signature (the Fulcio certificate chain or Rekor inclusion).
- **From-source and pinned-tag installs remain trust-on-first-use.**
`bun install -g github:garrytan/gbrain#latest-stable` follows a force-moved
tag, and the `codex-plugin` branch / template repo are force-published; these
paths trust TLS + GitHub without an independent integrity check. From-source
installs also serve the committed `admin/dist` bundle (devDeps for a fresh
admin build are not installed by a global install), so that bundle is
trust-on-first-use on this path. For the strongest guarantee, install the
attested release binary and run `gh attestation verify` as above.
## Remote MCP Security
### Keep dynamic client registration disabled unless explicitly needed
+34 -7
View File
@@ -1,5 +1,31 @@
# TODOS
## Security-sweep mitigation follow-ups (filed 2026-08-16)
- [ ] **P1 — `gbrain upgrade` binary lane returns success exit status on failure (autopilot false-success).** **What:** `runUpgrade`'s `binary` case logs every failure reason (`smoke_failed`, `download_failed`, `integrity_failed`, `integrity_unavailable`, `version_mismatch`, `replace_failed`) but never sets a non-zero CLI exit verdict, so callers see exit 0. **Why:** autopilot (`src/commands/autopilot.ts`) can read a false success, record "applied," relaunch, and then mark a transiently-unavailable version permanently bad — an amplification loop, now more reachable because `integrity_unavailable` fires on ordinary GitHub API rate limits. **Context:** PRE-EXISTING for the whole binary lane (not introduced by the v0.46.12.3 integrity work); surfaced by that PR's adversarial review with 2-model consensus. Fix needs care: distinguish hard-fail (`integrity_failed`/`version_mismatch` → exit non-zero, autopilot should NOT mark-bad on a security rejection) from transient (`integrity_unavailable` → retry, not a version fault), with autopilot-loop tests — hence its own PR, not a rushed rider. **Start:** `src/commands/upgrade.ts` binary case + `setCliExitVerdict` + `src/commands/autopilot.ts` upgrade handling.
- [ ] **P3 — Self-update GitHub API rate-limit resilience.** **What:** each `gbrain upgrade` makes 2 unauthenticated `api.github.com` calls (releases/latest + attestations), 60/hr/IP; corporate NAT / CI fleets hit 403 → `integrity_unavailable` → fail-closed. **Why:** a hard availability regression for shared-egress fleets vs the pre-integrity path. **Options:** honor an ambient `GH_TOKEN`/`GITHUB_TOKEN` when present (weigh against widening what a leaked env token authorizes), or a small bounded retry with backoff, and align the attestation fetch timeout (10s) with the download budget so a slow-but-working link doesn't spuriously fail. **Start:** `defaultFetchRelease`/`defaultFetchAttestation` in `src/core/binary-self-update.ts`.
- [ ] **P3 — Integrity for the from-source / `latest-stable` install paths.** **What:** the
compiled-binary self-update now verifies the GitHub build-provenance attestation before
installing (`src/core/binary-self-update.ts`), but the primary documented install
(`bun install -g github:garrytan/gbrain#latest-stable`, a force-moved tag) and the
force-published `codex-plugin` branch / template repo remain TLS+GitHub trust-on-first-use.
**Why:** those paths are how most users actually install; a compromised GitHub account could
serve an unverified tree. **Context:** documented as a residual in SECURITY.md
("Install-path trust model"). A postinstall attestation check (or a documented
`gh attestation verify` step for tag installs) would close it, but a from-source tree has no
single binary to attest — needs design. **Start:** `scripts/postinstall.ts` +
SECURITY.md residual note. **Depends on:** the WS2 self-update integrity that just landed.
- [ ] **P3 — Make `check:admin-embedded` deterministic so it can gate.** **What:**
`scripts/build-admin-embedded.ts` stamps today's date into a comment in
`src/admin-embedded.ts`, so `check-admin-embedded.sh`'s `git diff --exit-code` fails on any
day after commit — which is why it's `EXECUTION_EXEMPT` and unwired. **Why:** if the date
stamp were dropped (or the check ignored it), the embedded-manifest freshness guard could
actually run in CI. **Context:** correctness guard (catches a forgotten manifest regen), not
a security control — a backdoored dist regenerates the manifest and passes. The real dist
trust anchor is build-fresh-in-release (WS1, landed). **Start:** the date-comment line in
`scripts/build-admin-embedded.ts` + `guards-manifest.tsv:50`.
## CLI→MCP gap-closure wave follow-ups (2026-08-16; plan: ~/.claude/plans/system-instruction-you-are-working-concurrent-lantern.md)
- [ ] **P2 — publish-gate fail-open on a DB-config read failure.**
@@ -1842,13 +1868,14 @@ Filed from the self-upgrading-gbrain wave. All deliberately scoped OUT (D7a/D7b
+ eng-review notes); none is a v0.42.12.0 regression. Plan + reviews at
`~/.claude/plans/system-instruction-you-are-working-nifty-badger.md`.
- [ ] **P2 — Signature/checksum verification before applying an auto-upgrade
(D7a).** Auto-upgrade currently trusts TLS + GitHub, same as `gbrain upgrade`.
This is the prerequisite for ever making `auto` a default instead of opt-in:
verify a release-asset checksum/signature before `atomicReplace`. Until it
lands, `self_upgrade.mode` stays opt-in everywhere. Touches
`src/core/binary-self-update.ts` (stage step) + the release workflow (publish
the signature/checksum alongside the asset).
- [x] **P2 — Signature/checksum verification before applying an auto-upgrade
(D7a).** **Completed:** v0.46.12.3 (2026-08-16). `verifyIntegrity` in
`src/core/binary-self-update.ts` now checks the downloaded asset's SHA-256 +
builder identity against the GitHub build-provenance attestation (already
published by release.yml's `attest-build-provenance`) BEFORE chmod/exec/rename
— fail-closed with typed `integrity_failed`/`integrity_unavailable`. No new
release asset needed. Residual (from-source/`latest-stable` install paths) is
re-filed as the P3 entry at the top of this file.
- [ ] **P2 — `gbrain serve` host graceful request-drain on auto-upgrade (D7b).**
The silent channel currently skips while any request/stream/job/tx is in
flight and retries next window. A true drain (stop accepting new, finish
+1 -1
View File
@@ -1 +1 @@
0.46.12.2
0.46.12.3
+11 -2
View File
@@ -470,10 +470,19 @@ Never merge external PRs directly into master. Instead, use the "fix wave" workf
read the diff, understand the fix, and write it yourself if needed.
4. **Test the wave** — verify with `bun test && bun run test:e2e` (full E2E lifecycle).
Every fix in the wave must have test coverage.
5. **Close with context** — every closed PR gets a comment explaining why and what (if
5. **Security review** — run `bun run wave-security-scan <base>..<collector-head>` over the
collector branch (the repeatable mechanical sweep). It ALARMS on newly-introduced
obfuscation/eval in code, secrets found by gitleaks **with the test/skills allowlist
stripped**, and any committed `admin/dist` change (the bundle-backdoor artifact); new
outbound endpoints, spawns, env reads, and dependency changes print as context. Exit 1
means "eyeball before shipping," not "unsafe" — read the ALARM rows and the context lists,
and confirm each is benign. Link the result (or a one-line "clean") in the wave PR body.
This is the standard's teeth: a wave PR body that claims "security reviewed" must have run
this. It is a net, not a proof — a human still reads the diffs.
6. **Close with context** — every closed PR gets a comment explaining why and what (if
anything) supersedes it. Contributors did real work; respect that with clear communication
and thank them.
6. **Ship as one PR** — single PR to master with all attributions preserved via
7. **Ship as one PR** — single PR to master with all attributions preserved via
`Co-Authored-By:` trailers. Include a summary of what merged and what closed.
**Community PR guardrails:**
+2 -1
View File
@@ -7,7 +7,7 @@ only.
### Test command tiers
Six test command tiers, each with a clear scope:
Seven test command tiers, each with a clear scope:
| Command | What it runs | Wallclock | When to use |
|---|---|---|---|
@@ -17,6 +17,7 @@ Six test command tiers, each with a clear scope:
| `bun run test:slow` | Just the `*.slow.test.ts` set (intentional cold-path correctness checks). | seconds-to-minutes | When touching slow-path code. |
| `bun run test:serial` | Just the `*.serial.test.ts` set (cross-file-contention quarantine; one bun process per file for true module-registry isolation), run through a POOL of concurrent per-file processes — the isolation is per-process, not per-machine. Pool defaults to `min(detect_cpus, 4)` then memory-adapts (same doctrine as the parallel runner); a small growth-guarded set of files (machine-global state or contention-critical timing — see the justified `EXCLUSIVE_FILES` list in `scripts/run-serial-tests.sh`, capped at 3 by `test/scripts/serial-files.test.ts`) runs on a sequential EXCLUSIVE lane after the pool. Per-test timeout 120s (pooled contention headroom); each pooled file is wall-clock-killed at 300s (`timeout -k`, exit-hang containment). Externally-killed files (exit 143/137 or a missing exit sentinel — sibling-workspace cleanup, memory jetsam) get ONE sequential rescue re-run, mirroring the parallel runner's doctrine: phantoms stay green with a rescue note, real failures stay red. Prints per-file PASS lines plus a top-10 slowest-files list. Knobs: `GBRAIN_SERIAL_POOL=N` (explicit pool width — bypasses the memory clamp; `1` restores fully-sequential), `GBRAIN_SERIAL_FILE_TIMEOUT`. | ~2.5min for all ~140 files at pool=4 (was ~8.5min sequential) | Debugging quarantined files; CI's serial-tests job. |
| `bun run test:e2e` | Real Postgres E2E. Requires Docker + `DATABASE_URL`. Sequential. | ~5-10min | Pre-ship; nightly. |
| `bun run test:compile-smoke` | Self-update integrity verify under a REAL `bun build --compile` binary, offline (sets `GBRAIN_SELFUPDATE_COMPILE_SMOKE=1`). The unit suite mocks the network seams; this proves the dependency-free crypto/base64/JSON verify path survives compilation — the failure mode `sigstore-js` would have hit. | ~5s (one compile) | When touching `src/core/binary-self-update.ts`; pre-ship on self-update changes. |
There is no `check:all` script anymore — it was a second, hand-synced guard
registry that drifted from `verify` (three checks were reachable ONLY from it,
+6 -2
View File
@@ -865,7 +865,11 @@ Before any ship, read **[docs/RELEASING.md](docs/RELEASING.md)** in full. It car
full release + contributor process: pre-ship test requirements (`bun run ci:local` / the
E2E lifecycle), the CHANGELOG voice + release-summary template, the "To take advantage of
vX" self-repair block, version migrations, the GitHub Actions SHA refresh, PR conventions,
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.**
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.** Every
community wave runs `bun run wave-security-scan <base>..<head>` (RELEASING.md step 5) before
ship — the repeatable mechanical sweep (obfuscation/eval, gitleaks with the test/skills
allowlist stripped, committed `admin/dist` changes as alarms; new endpoints/spawns/env/deps
as context).
The ship-critical IRON RULES stay inline in this file (do NOT relocate them): the
Version-locations table above (the 5-file sync + the 3-line VERSION/package.json/CHANGELOG
@@ -2165,7 +2169,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
- [`AGENTS.md`](AGENTS.md) — entry point for non-Claude agents
- [`CLAUDE.md`](CLAUDE.md) — entry point for Claude Code (deep operating context)
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contributor guide, test discipline, eval-capture mode
- [`SECURITY.md`](SECURITY.md) — OAuth threat model, hardening defaults
- [`SECURITY.md`](SECURITY.md) — install-path trust model, self-update integrity, automated scanning, OAuth threat model, hardening defaults
## Contributing
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.46.12.2",
"version": "0.46.12.3",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+3 -1
View File
@@ -37,6 +37,7 @@
"build:admin-embedded": "bun run scripts/build-admin-embedded.ts",
"build:schema": "bash scripts/build-schema.sh",
"build:llms": "bun run scripts/build-llms.ts",
"wave-security-scan": "bash scripts/wave-security-scan.sh",
"build:flag-registry": "bun run scripts/generate-flag-registry.ts",
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
"test": "bash scripts/run-unit-parallel.sh",
@@ -62,6 +63,7 @@
"check:wasm": "bash scripts/check-wasm-embedded.sh",
"check:pglite-embedded": "bash scripts/check-pglite-embedded.sh",
"check:newlines": "bash scripts/check-trailing-newline.sh",
"test:compile-smoke": "GBRAIN_SELFUPDATE_COMPILE_SMOKE=1 bun test test/binary-self-update-compiled.serial.test.ts",
"test:e2e": "bash scripts/run-e2e.sh",
"test:slow": "bash scripts/run-slow-tests.sh",
"test:heavy": "bash scripts/run-heavy.sh",
@@ -168,7 +170,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.46.12.2",
"version": "0.46.12.3",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-plugin-tree-stamp: 0.46.12.2 -->
<!-- gbrain-plugin-tree-stamp: 0.46.12.3 -->
# gbrain plugin skill tree (generated — do not hand-edit)
This tree is the curated skill set for the gbrain Codex and Claude Code
+7
View File
@@ -30,6 +30,13 @@
set -euo pipefail
# Fixture tests that `git commit` in temp repos must not inherit the developer's
# global commit.gpgsign — a signing gpg-agent can OOM under full-suite memory
# pressure and fail the commit ("gpg: signing failed: Cannot allocate memory",
# #1696). git applies these env keys as highest-precedence config on every
# invocation in this process tree, so all child `git commit`s run unsigned.
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="commit.gpgsign" GIT_CONFIG_VALUE_0="false"
# #3485: serial tests need no database — strip ambient DB URLs at this
# wrapper boundary (same four-layer guard as run-slow-tests.sh / the
# parallel runner) so the bunfig preload guard passes and nothing can
+7
View File
@@ -44,6 +44,13 @@
set -uo pipefail
# Fixture tests that `git commit` in temp repos must not inherit the developer's
# global commit.gpgsign — a signing gpg-agent can OOM under full-suite memory
# pressure and fail the commit ("gpg: signing failed: Cannot allocate memory",
# #1696). git applies these env keys as highest-precedence config on every
# invocation in this process tree, so all child `git commit`s run unsigned.
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="commit.gpgsign" GIT_CONFIG_VALUE_0="false"
# #3485: unit tests need no database — strip ambient DB URLs at this wrapper
# boundary so the bunfig preload guard passes and nothing can reach a real
# brain. The e2e wrapper (run-e2e.sh) is the only lane that keeps them.
+1 -1
View File
@@ -160,7 +160,7 @@ test/redos-hardening.test.ts #1569 --no-schema-pack + heartbeat wiring (structur
test/register-client-source-normalize.test.ts register-client route wiring (structural) 1 readFileSync
test/regression-strict-source-id.test.ts cycle reverse-write call sites use the consolidated path 4 readFileSync
test/regression-strict-source-id.test.ts utils.ts no longer carries an inline permissive regex 2 readFileSync
test/release-workflow.test.ts release.yml ↔ binary-self-update asset contract 7 readFileSync
test/release-workflow.test.ts release.yml ↔ binary-self-update asset contract 10 readFileSync
test/resolver.test.ts RESOLVER.md trigger round-trip (D5/C) 2 readFileSync
test/resolver.test.ts Skill example-name validator (D13) 4 readFileSync
test/schema-cli-contract.test.ts v0.39 T6 — schema CLI contract 7 readFileSync
Can't render this file because it contains an unexpected character in line 27 and column 63.
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
# Wave security scan — the repeatable mechanical sweep for community-PR waves.
#
# Runs the high-recall checks a maintainer should apply to a batch of external
# contributions BEFORE shipping a collector branch (see docs/RELEASING.md,
# "Community PR wave process"). It is NOT a proof of safety — it is a fast net
# that surfaces the shapes worth a human look: newly-introduced outbound
# endpoints, obfuscation/eval, new process spawns, new env reads, dependency
# changes, secrets (gitleaks with the test/skills allowlist STRIPPED), and any
# change to the committed admin bundle.
#
# Usage:
# scripts/wave-security-scan.sh <base>..<head> # explicit range
# scripts/wave-security-scan.sh <base> <head> # two refs
# scripts/wave-security-scan.sh # defaults to origin/master..HEAD
# scripts/wave-security-scan.sh --json <range> # machine-readable summary
#
# Exit code: 0 = nothing high-signal; 1 = high-signal hit(s) worth review;
# 2 = usage / environment error. Findings are advisory: exit 1 means
# "look", not "unsafe".
#
# On-demand only (never wired into the hot CI path): gitleaks-over-history and
# the per-file diff walk are too slow for every push.
set -euo pipefail
# Deliberately NO cd-to-script-repo: the scan operates on the CALLER's git repo
# (the collector branch being reviewed), which is not necessarily the repo this
# script lives in. The not-a-git-repository guard below handles stray cwds.
JSON=0
ARGS=()
for a in "$@"; do
case "$a" in
--json) JSON=1 ;;
*) ARGS+=("$a") ;;
esac
done
# --- Resolve the commit range (guard empty / non-git / bad refs) ---
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "wave-security-scan: not a git repository" >&2
exit 2
fi
# Operate on the CALLER's repo, but ROOTED at its top level. Without this, a run
# from a subdirectory would scope every cwd-relative pathspec (`-- .`, root
# manifests, `admin/dist`) to the subtree and silently report a clean gate.
_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "wave-security-scan: cannot resolve repo top level" >&2; exit 2; }
cd "$_TOPLEVEL"
# python3 does the regex/JSON work; without it the checks can't run and set -e
# would exit 127 outside the documented 0/1/2 contract. Fail as a usage error.
if ! command -v python3 >/dev/null 2>&1; then
echo "wave-security-scan: python3 is required but not found" >&2
exit 2
fi
RANGE=""
if [ "${#ARGS[@]}" -eq 0 ]; then
if git rev-parse --verify -q origin/master >/dev/null; then
RANGE="origin/master..HEAD"
else
RANGE="HEAD~1..HEAD"
fi
elif [ "${#ARGS[@]}" -eq 1 ]; then
RANGE="${ARGS[0]}"
elif [ "${#ARGS[@]}" -eq 2 ]; then
RANGE="${ARGS[0]}..${ARGS[1]}"
else
echo "wave-security-scan: too many arguments" >&2
exit 2
fi
# Normalise `a..b`; verify both endpoints resolve.
BASE="${RANGE%%..*}"
HEAD="${RANGE##*..}"
if [ "$BASE" = "$RANGE" ] || [ -z "$BASE" ] || [ -z "$HEAD" ]; then
echo "wave-security-scan: range must be <base>..<head> (got '$RANGE')" >&2
exit 2
fi
if ! git rev-parse --verify -q "$BASE^{commit}" >/dev/null || ! git rev-parse --verify -q "$HEAD^{commit}" >/dev/null; then
echo "wave-security-scan: cannot resolve one end of '$RANGE'" >&2
exit 2
fi
COMMIT_COUNT=$(git rev-list --count "$RANGE" 2>/dev/null || echo 0)
if [ "$COMMIT_COUNT" -eq 0 ]; then
echo "wave-security-scan: empty range ($RANGE) — nothing to scan" >&2
if [ "$JSON" -eq 1 ]; then
# Same schema as the main --json path (zero/empty values), safely encoded.
python3 -c 'import json,sys; print(json.dumps({"range": sys.argv[1], "commits": 0, "checks": {}, "alarm": 0, "dependency_changed": False, "admin_dist_changed": False, "gitleaks_hits": "n/a"}))' "$RANGE"
fi
exit 0
fi
# Generated / minified / vendored artifacts: excluded from the CONTENT greps
# (they trip every obfuscation heuristic and drown real signal), but admin/dist
# changes are still surfaced separately below (that is a real threat artifact).
is_scannable() {
case "$1" in
admin/dist/*|*/admin/dist/*) return 1 ;;
llms.txt|llms-full.txt) return 1 ;;
*.snapshot|*.snap|*.tar|*.tgz|*.wasm|*.png|*.jpg|*.jpeg|*.gif|*.pdf|*.ico) return 1 ;;
bun.lock|*/bun.lock|package-lock.json|yarn.lock) return 1 ;;
*) return 0 ;;
esac
}
TMP=$(mktemp -d /tmp/wave-scan.XXXXXX)
trap 'rm -rf "$TMP"' EXIT
# --- Build the added-line corpus (content-scannable files only) ---
: > "$TMP/added.txt"
# Anchor the file-header match to the git unified-diff form (`+++ b/<path>` or
# `+++ /dev/null`). A looser `^+++ ` also matches a CONTENT line like `++ x;`
# (a `++`-prefixed statement renders as `+++ x;`), which would reassign the
# current filename to garbage and suppress checks for the rest of the file.
git diff --no-color --unified=0 "$RANGE" -- . 2>/dev/null | awk '
/^\+\+\+ (b\/|\/dev\/null)/{ f=$0; sub(/^\+\+\+ b\//,"",f); next }
/^\+/ && !/^\+\+\+/ { line=$0; sub(/^\+/,"",line); print f"\t"line }
' > "$TMP/added_all.txt" || true
while IFS=$'\t' read -r f rest; do
[ -z "$f" ] && continue
if is_scannable "$f"; then printf '%s\t%s\n' "$f" "$rest" >> "$TMP/added.txt"; fi
done < "$TMP/added_all.txt"
# Python does the regex work (BSD grep/ugrep differ; python is portable).
python3 - "$TMP/added.txt" "$TMP" <<'PY'
import re, sys, json
added = sys.argv[1]; tmp = sys.argv[2]
rows = []
for line in open(added, encoding='utf-8', errors='replace').read().splitlines():
p = line.split('\t', 1)
if len(p) == 2:
rows.append(p)
CODE_EXT = ('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.sh', '.bash')
SHELL_EXT = ('.sh', '.bash')
def is_code(f):
return f.endswith(CODE_EXT)
def is_test(f):
return f.startswith('test/') or '/test/' in f or f.startswith('skills/')
# Execution-reachable source: src/scripts + admin/src (the release job now builds
# and embeds admin/src, so its spawns/env reads matter too).
def is_exec_source(f):
return f.startswith(('src/', 'scripts/', 'admin/src/'))
def is_comment(f, c):
# Only suppress lines that genuinely can't execute. Do NOT over-broaden:
# a leading `#` is a comment only in shell (in JS/TS it's a private field);
# a leading `*` is a comment only as `*/` or a JSDoc continuation `* ...`
# (with a following space) — `*gen(){}` / `*eval(` are generator/multiply
# constructs that DO execute.
t = c.lstrip()
if t.startswith('//') or t.startswith('/*') or t.startswith('*/'):
return True
if t.startswith('* ') or t == '*':
return True
if f.endswith(SHELL_EXT) and t.startswith('#'):
return True
return False
# Code-shaped checks fire on CODE FILES only (obfuscation/eval in a .md is prose,
# not a payload). ALARM checks (exit 1) are the low-false-positive ones:
# obfuscation/eval in executable code lines. The rest are INFORMATIONAL context.
# The obfuscation pattern covers JS call form `eval(`/`atob(`/`new Function(` AND
# shell forms `eval "$x"` / `eval $x` / `source <(...)`.
checks = {
'obfuscation': (True, lambda f, c: is_code(f) and not is_comment(f, c) and bool(re.search(
r'\beval\s*[("\'$]|\beval\s+\S|\bnew\s+Function\s*\(|\batob\s*\(|Buffer\.from\([^)]*[\'"]base64|String\.fromCharCode|\bsource\s+<\(|(\\x[0-9a-fA-F]{2}){4,}|[A-Za-z0-9+/]{120,}={0,2}', c))),
'outbound_url': (False, lambda f, c: bool(re.search(r'https?://|wss?://', c))
and not re.search(r'localhost|127\.0\.0\.1|0\.0\.0\.0|example\.(com|org|net|test|invalid)|\.example\b|schema|xmlns|w3\.org|json-schema|spdx|in-toto\.io|slsa\.dev|sigstore|githubusercontent|github\.com/garrytan/gbrain', c)),
'new_spawn_exec': (False, lambda f, c: is_code(f) and bool(re.search(r'child_process|execSync|\bexecFileSync|\bspawnSync|\bspawn\s*\(|Bun\.spawn|shell\s*:\s*true', c)) and is_exec_source(f)),
'new_env_read': (False, lambda f, c: is_code(f) and bool(re.search(r'(?:process|Bun)\.env[.\[]', c)) and is_exec_source(f)),
}
results = {k: [] for k in checks}
for f, c in rows:
for k, (_alarm, pred) in checks.items():
try:
if pred(f, c):
results[k].append((f, c.strip()[:160]))
except re.error:
pass
# alarm_total drives exit 1; informational checks are printed but never fail.
summary = {}
alarm_total = 0
for k, hits in results.items():
alarm = checks[k][0]
summary[k] = {'total': len(hits), 'alarm': alarm, 'sample': hits[:8]}
if alarm:
alarm_total += len(hits)
json.dump({'checks': summary, 'alarm': alarm_total}, open(tmp + '/checks.json', 'w'))
PY
# --- Dependency diff (root AND admin — the release job installs admin deps too) ---
DEP_CHANGED=0
if ! git diff --quiet "$RANGE" -- package.json bun.lock admin/package.json admin/bun.lock 2>/dev/null; then DEP_CHANGED=1; fi
# --- Admin bundle change (WS1 threat artifact — always flag for manual review) ---
ADMIN_DIST_CHANGED=0
if git diff --name-only "$RANGE" -- 'admin/dist' 2>/dev/null | grep -q .; then ADMIN_DIST_CHANGED=1; fi
# --- gitleaks with the test/skills allowlist STRIPPED (temp config; never edits repo .gitleaks.toml) ---
# Fail-closed lane: this script's exit code is the RELEASING.md step-5 gate, so a
# secrets sweep that DID NOT RUN (gitleaks missing) or ran-but-unparseable ("?")
# must alarm — never silently report clean.
GITLEAKS_HITS="n/a"
if command -v gitleaks >/dev/null 2>&1; then
# extend useDefault = gitleaks' built-in rules WITHOUT the repo .gitleaks.toml
# (which allowlists test/ + skills/) — the whole point is to see the blind spot.
printf '[extend]\nuseDefault = true\n' > "$TMP/gitleaks.toml"
if gitleaks git --no-banner -c "$TMP/gitleaks.toml" --log-opts="$RANGE" --report-format json --report-path "$TMP/leaks.json" >/dev/null 2>&1; then
GITLEAKS_HITS=0
else
GITLEAKS_HITS=$(python3 -c "import json;print(len(json.load(open('$TMP/leaks.json'))))" 2>/dev/null || echo "?")
fi
fi
LEAK_LANE_BROKEN=0
if [ "$GITLEAKS_HITS" = "n/a" ]; then
echo "wave-security-scan: WARNING — gitleaks is not installed; the secrets lane DID NOT RUN (install gitleaks, then re-run)" >&2
LEAK_LANE_BROKEN=1
elif [ "$GITLEAKS_HITS" = "?" ]; then
echo "wave-security-scan: WARNING — gitleaks exited non-zero and its report is unreadable; the secrets lane result is UNKNOWN" >&2
LEAK_LANE_BROKEN=1
fi
# --- Report ---
ALARM=$(python3 -c "import json;print(json.load(open('$TMP/checks.json'))['alarm'])")
LEAK_SIGNAL=0
if [ "$GITLEAKS_HITS" != "n/a" ] && [ "$GITLEAKS_HITS" != "0" ] && [ "$GITLEAKS_HITS" != "?" ]; then LEAK_SIGNAL=$GITLEAKS_HITS; fi
# Compute the gate result up front so --json carries it (a machine consumer must
# not read alarm:0 and conclude "clean" while the process exits 1 on an
# admin/dist change, a gitleaks hit, or a broken secrets lane).
GATE_EXIT=0
if [ "$ALARM" -gt 0 ] || [ "$LEAK_SIGNAL" -gt 0 ] || [ "$ADMIN_DIST_CHANGED" = 1 ] || [ "$LEAK_LANE_BROKEN" = 1 ]; then
GATE_EXIT=1
fi
if [ "$JSON" -eq 1 ]; then
python3 - "$TMP/checks.json" "$RANGE" "$COMMIT_COUNT" "$DEP_CHANGED" "$ADMIN_DIST_CHANGED" "$GITLEAKS_HITS" "$GATE_EXIT" "$LEAK_LANE_BROKEN" <<'PY'
import json, sys
checks = json.load(open(sys.argv[1]))
out = {
'range': sys.argv[2], 'commits': int(sys.argv[3]),
'checks': checks['checks'], 'alarm': checks['alarm'],
'dependency_changed': sys.argv[4] == '1',
'admin_dist_changed': sys.argv[5] == '1',
'gitleaks_hits': sys.argv[6],
'gitleaks_lane_broken': sys.argv[8] == '1',
'exit_code': int(sys.argv[7]),
'gate': 'review' if sys.argv[7] == '1' else 'clean',
}
print(json.dumps(out))
PY
else
echo "wave-security-scan range=$RANGE commits=$COMMIT_COUNT"
echo " (ALARM = exit 1, worth review before ship; other rows are context)"
echo "-------------------------------------------------------------"
python3 - "$TMP/checks.json" <<'PY'
import json, sys
c = json.load(open(sys.argv[1]))['checks']
labels = {'obfuscation':'obfuscation / eval (code)','outbound_url':'new outbound URLs/hosts','new_spawn_exec':'new spawn/exec (src/scripts)','new_env_read':'new env reads (src)'}
for k, lab in labels.items():
s = c[k]
tag = 'ALARM' if s['alarm'] else 'info '
flag = ' <-- REVIEW' if (s['alarm'] and s['total']) else ''
print(f" [{tag}] {lab:30} count={s['total']}{flag}")
for f, snip in s['sample'][:4]:
print(f" {f}: {snip[:100]}")
PY
echo " [info ] dependency change (package.json/bun.lock): $([ "$DEP_CHANGED" = 1 ] && echo YES || echo no)"
echo " [ALARM] admin/dist change (bundle-backdoor artifact): $([ "$ADMIN_DIST_CHANGED" = 1 ] && echo 'YES <-- REVIEW' || echo no)"
echo " [ALARM] gitleaks (test/skills allowlist stripped): $GITLEAKS_HITS"
echo "-------------------------------------------------------------"
fi
exit "$GATE_EXIT"
+27
View File
@@ -66,6 +66,33 @@ export async function runUpgrade(args: string[]) {
console.log('No published binary for this platform/arch.');
console.log('Download the latest binary from GitHub Releases:');
console.log(' https://github.com/garrytan/gbrain/releases');
} else if (
result.reason === 'integrity_failed' ||
result.reason === 'integrity_unavailable' ||
result.reason === 'version_mismatch'
) {
// Fail-closed: the downloaded binary was never installed (renamed over
// the live path). "signed" is intentionally omitted — we match against
// the build-provenance attestation's digest + builder identity fetched
// over TLS from the GitHub API; we do NOT independently verify the
// Sigstore signature chain (see src/core/binary-self-update.ts header).
const detail =
result.reason === 'integrity_failed'
? 'the downloaded binary did not match its build-provenance attestation (digest/builder mismatch)'
: result.reason === 'version_mismatch'
? 'the downloaded binary reported a different version than the release it was fetched for (possible downgrade)'
: 'the build-provenance attestation could not be fetched (offline, rate-limited, or missing)';
console.error(`Binary self-update rejected — integrity not confirmed: ${detail}.`);
console.error('Your existing binary is unchanged and the download was discarded.');
console.error('Retry later, or download + verify manually:');
console.error(' https://github.com/garrytan/gbrain/releases');
recordUpgradeError({
phase: 'binary-self-update',
fromVersion: oldVersion,
toVersion: '',
error: result.reason,
hint: 'Integrity check failed; existing binary retained. Retry or download manually.',
});
} else {
console.error(`Binary self-update failed (${result.reason}${result.error ? `: ${result.error}` : ''}).`);
console.error('Your existing binary is unchanged. Download manually if needed:');
+215 -7
View File
@@ -8,25 +8,63 @@
* it's the only place we can (and now do) guarantee atomicity:
*
* resolve published asset download to a temp sibling of the live binary
* fsync + chmod +x `--version` smoke test renameSync over the live path.
* verify attestation integrity fsync + chmod +x `--version` smoke test
* verify version matches the release tag (downgrade-replay guard)
* renameSync over the live path.
*
* rename(2) over a running binary is safe on darwin/linux (the running process
* keeps the old inode; the next exec picks up the new file). Every failure
* (no asset / fetch / download / smoke / rename) leaves the OLD binary
* untouched there is no half-written-binary brick path. Windows can't rename
* over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
* (no asset / fetch / download / integrity / smoke / rename) leaves the OLD
* binary untouched there is no half-written-binary brick path. Windows can't
* rename over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
* published, so those degrade to notify-only via `resolvePlatformAsset`
* returning null. Trust model: TLS + GitHub, same as `gbrain upgrade` (no
* signature verification this wave D7a TODO).
* returning null.
*
* Integrity (D7a, done): before the downloaded binary is ever executed, its
* SHA-256 is verified against the SLSA build-provenance attestation
* `attest-build-provenance` publishes for every release
* (`.github/workflows/release.yml`). The attestation is fetched from the GitHub
* REST API (`/repos/OWNER/REPO/attestations/sha256:<digest>`) a DIFFERENT
* origin than the `objects.githubusercontent.com` CDN that serves the bytes
* and we check that (a) an attested subject's digest equals the locally-computed
* digest and (b) the attestation's builder id is THIS repo's release workflow.
* The verify is dependency-free (node:crypto + fetch + base64 + JSON only, all
* Bun built-ins that survive `bun build --compile`; the `sigstore` npm package
* does NOT bundle under `--compile`, so it is deliberately not used). Honest
* guarantee: this is GitHub-account trust + origin separation + a signed
* digest/identity match it does NOT independently validate the Fulcio cert
* chain or Rekor inclusion (that needs the trusted-root material sigstore-js
* loads from disk). An unverified binary is NEVER chmod-exec'd or renamed over
* the live path; integrity failure is fail-closed.
*
* Published asset matrix mirrors `.github/workflows/release.yml`:
* darwin-arm64 gbrain-darwin-arm64
* linux-x64 gbrain-linux-x64
*/
import { chmodSync, closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { chmodSync, closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
/**
* The attestation's builder id must be EXACTLY one of these it binds the
* provenance to THIS repo's release workflow running on a trusted ref, so a
* valid attestation for some OTHER artifact, a fork's workflow, or a
* workflow_dispatch of release.yml from an arbitrary branch can't be replayed.
* If tag-triggered releases ever ship, add their ref form here in the same PR.
* Mirrors `expectedAssetName`'s coupling to release.yml; pinned by
* test/release-workflow.test.ts.
*/
export const EXPECTED_BUILDER_ID_PREFIX =
'https://github.com/garrytan/gbrain/.github/workflows/release.yml@';
export const EXPECTED_BUILDER_IDS: readonly string[] = [
`${EXPECTED_BUILDER_ID_PREFIX}refs/heads/master`,
];
/** Base for the GitHub attestation REST endpoint (per-subject-digest lookup). */
const ATTESTATION_API_BASE =
'https://api.github.com/repos/garrytan/gbrain/attestations/sha256:';
export interface ReleaseAsset {
name: string;
@@ -38,9 +76,24 @@ export type BinarySelfUpdateReason =
| 'fetch_failed'
| 'no_asset'
| 'download_failed'
| 'integrity_unavailable'
| 'integrity_failed'
| 'version_mismatch'
| 'smoke_failed'
| 'replace_failed';
/** One attested subject: an artifact name + its SHA-256 (hex, no `sha256:` prefix). */
export interface AttestedSubject {
name: string;
sha256: string;
}
/** A parsed build-provenance attestation: the subjects it covers + its builder id. */
export interface ParsedAttestation {
subjects: AttestedSubject[];
builderId: string;
}
export interface BinarySelfUpdateResult {
ok: boolean;
reason?: BinarySelfUpdateReason;
@@ -76,6 +129,26 @@ export interface BinarySelfUpdateDeps {
download?: (url: string, destPath: string) => Promise<void>;
/** Smoke-test the staged binary; returns true if `<path> --version` looks like gbrain. */
smoke?: (stagedPath: string) => boolean;
/**
* Confirm the staged binary actually IS the release it claims to be its
* `--version` must contain `expectedVersion` (derived from the release tag).
* Defaults to a real `--version` exec. Blocks a downgrade-replay: an attacker
* who swaps the published asset for an OLDER, still-validly-attested binary
* passes the digest+builder check (the old digest has a real attestation) but
* reports the wrong version here. Injected in tests that stage non-binary bytes.
*/
checkVersion?: (stagedPath: string, expectedVersion: string) => boolean;
/** SHA-256 (hex) of the file at `path`. Default reads the file with node:crypto. */
computeDigest?: (path: string) => string;
/**
* Fetch + parse the build-provenance attestations for `digest` (hex, no
* prefix). Returns the parsed attestations, or null when none are available
* (missing / network / rate-limited) null maps to `integrity_unavailable`,
* NOT `integrity_failed`. Default hits the GitHub attestation REST API.
* Injected in tests so the real digest/identity verify logic is exercised
* against crafted attestation data (the network is the only mocked seam).
*/
fetchAttestation?: (digest: string) => Promise<ParsedAttestation[] | null>;
platform?: NodeJS.Platform;
arch?: NodeJS.Architecture;
}
@@ -125,6 +198,121 @@ function defaultSmoke(stagedPath: string): boolean {
}
}
function defaultCheckVersion(stagedPath: string, expectedVersion: string): boolean {
try {
const out = execFileSync(stagedPath, ['--version'], { encoding: 'utf-8', timeout: 10_000 });
// Substring, not equality: `--version` prints `gbrain <version>` (+ maybe a
// build suffix). The release workflow enforces binary-version == VERSION at
// build time, so the tag's numeric version must appear here.
return out.includes(expectedVersion);
} catch {
return false;
}
}
export function defaultComputeDigest(path: string): string {
return createHash('sha256').update(readFileSync(path)).digest('hex');
}
/**
* Decode one GitHub attestation `bundle` into `{subjects, builderId}`.
* The DSSE payload is a base64-encoded in-toto Statement:
* { subject: [{name, digest:{sha256}}], predicate:{ runDetails:{ builder:{id} } } }
* Returns null when the bundle is malformed (missing/undecodable payload).
*/
export function parseAttestationBundle(bundle: any): ParsedAttestation | null {
try {
const payloadB64 = bundle?.dsseEnvelope?.payload;
if (typeof payloadB64 !== 'string' || payloadB64.length === 0) return null;
const stmt = JSON.parse(Buffer.from(payloadB64, 'base64').toString('utf8'));
// Only accept SLSA build-provenance statements — don't let some other
// attestation type that happens to carry subject[]+builder.id be read as
// provenance.
if (typeof stmt?.predicateType === 'string' && !stmt.predicateType.includes('slsa.dev/provenance')) {
return null;
}
const subjects: AttestedSubject[] = Array.isArray(stmt?.subject)
? stmt.subject
.map((s: any) => ({ name: String(s?.name ?? ''), sha256: String(s?.digest?.sha256 ?? '') }))
.filter((s: AttestedSubject) => s.sha256.length > 0)
: [];
const builderId = String(stmt?.predicate?.runDetails?.builder?.id ?? '');
if (subjects.length === 0 || builderId.length === 0) return null;
return { subjects, builderId };
} catch {
return null;
}
}
export async function defaultFetchAttestation(digest: string): Promise<ParsedAttestation[] | null> {
try {
const res = await fetch(`${ATTESTATION_API_BASE}${digest}`, {
headers: { 'User-Agent': 'gbrain-self-upgrade', Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(10_000),
});
// 404 (no attestation), 403 (unauthenticated rate limit, 60/hr), any non-2xx
// → treat as "unavailable" (caller fails closed), never as "verified".
if (!res.ok) return null;
const data = (await res.json()) as any;
const raw = Array.isArray(data?.attestations) ? data.attestations : [];
const parsed = raw
.map((a: any) => parseAttestationBundle(a?.bundle))
.filter((p: ParsedAttestation | null): p is ParsedAttestation => p !== null);
// Distinguish "endpoint reachable but no usable attestation" (null →
// unavailable) from "reachable with data" (return the list, possibly empty
// only if all bundles were malformed, which we also treat as unavailable).
return parsed.length > 0 ? parsed : null;
} catch {
return null;
}
}
/**
* Verify the staged binary against its build-provenance attestation. Returns a
* reason on failure (fail-closed), or null on success.
* - digest can't be computed integrity_unavailable
* - no attestation available integrity_unavailable
* - attestation exists but does not
* cover this (name, digest) under
* our release-workflow builder id integrity_failed
*/
export async function verifyIntegrity(
stagedPath: string,
assetName: string,
computeDigest: (path: string) => string,
fetchAttestation: (digest: string) => Promise<ParsedAttestation[] | null>,
): Promise<BinarySelfUpdateReason | null> {
let digest: string;
try {
digest = computeDigest(stagedPath);
} catch {
return 'integrity_unavailable';
}
if (!/^[0-9a-f]{64}$/.test(digest)) return 'integrity_unavailable';
// fetchAttestation is an injected seam; a throwing implementation must not
// escape runBinarySelfUpdate's never-throws contract (which would skip the
// staged-file cleanup). Any failure to obtain attestations is fail-closed.
let attestations: ParsedAttestation[] | null;
try {
attestations = await fetchAttestation(digest);
} catch {
return 'integrity_unavailable';
}
if (!attestations || attestations.length === 0) return 'integrity_unavailable';
// A match requires: an attestation from OUR release workflow ON A TRUSTED REF
// that names this asset with exactly this digest. Digest-match alone is
// insufficient (any artifact could carry it), and workflow-match alone is
// insufficient (a dispatch from an untrusted branch mints a real attestation).
const verified = attestations.some(
(att) =>
EXPECTED_BUILDER_IDS.includes(att.builderId) &&
att.subjects.some((s) => s.name === assetName && s.sha256 === digest),
);
return verified ? null : 'integrity_failed';
}
let _tmpCounter = 0;
/**
@@ -141,6 +329,9 @@ export async function runBinarySelfUpdate(
const fetchRelease = deps.fetchRelease ?? defaultFetchRelease;
const download = deps.download ?? defaultDownload;
const smoke = deps.smoke ?? defaultSmoke;
const checkVersion = deps.checkVersion ?? defaultCheckVersion;
const computeDigest = deps.computeDigest ?? defaultComputeDigest;
const fetchAttestation = deps.fetchAttestation ?? defaultFetchAttestation;
const assetName = expectedAssetName(platform, arch);
if (!assetName) {
@@ -166,6 +357,14 @@ export async function runBinarySelfUpdate(
return { ok: false, reason: 'download_failed', error: errMsg(e), asset: assetName };
}
// Integrity BEFORE chmod/exec: never make an unverified binary executable and
// never run its `--version` smoke test. Fail-closed on unavailable or mismatch.
const integrityFailure = await verifyIntegrity(staged, assetName, computeDigest, fetchAttestation);
if (integrityFailure) {
safeUnlink(staged);
return { ok: false, reason: integrityFailure, asset: assetName };
}
try {
chmodSync(staged, 0o755);
} catch (e) {
@@ -178,6 +377,15 @@ export async function runBinarySelfUpdate(
return { ok: false, reason: 'smoke_failed', asset: assetName };
}
// Downgrade-replay guard: the staged binary must actually be the release it
// claims. A swapped asset serving an older, still-validly-attested binary
// clears digest+builder but reports the wrong version here.
const expectedVersion = release.tag.replace(/^v/, '').trim();
if (expectedVersion && !checkVersion(staged, expectedVersion)) {
safeUnlink(staged);
return { ok: false, reason: 'version_mismatch', asset: assetName };
}
try {
renameSync(staged, targetPath); // atomic on same fs; old binary intact if this throws
} catch (e) {
+1 -1
View File
@@ -1,6 +1,6 @@
# gbrain agent workspace — template
<!-- gbrain-template-stamp: 0.46.12.2 -->
<!-- gbrain-template-stamp: 0.46.12.3 -->
This repository is the **"Use this template"** distribution artifact for a
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
@@ -0,0 +1,37 @@
/**
* WS2 acceptance gate: the integrity-verify path must run in a REAL
* `bun build --compile` binary, offline. Unit tests mock the seams; this proves
* node:crypto + base64 + JSON (the dependency-free verify primitives) survive
* compilation the exact thing that would break had we used `sigstore-js`.
*
* Opt-in: compiling is slow (~seconds) and writes a large temp binary, so this
* is skipped unless GBRAIN_SELFUPDATE_COMPILE_SMOKE=1 (run it locally / in a
* heavy-test lane, not the hot unit path). Mirrors the repo's e2e gating.
*/
import { describe, expect, test } from 'bun:test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const RUN = process.env.GBRAIN_SELFUPDATE_COMPILE_SMOKE === '1';
const HARNESS = join(import.meta.dir, 'helpers', 'binary-self-update-smoke-harness.ts');
describe.skipIf(!RUN)('binary-self-update integrity verify — compiled binary (offline)', () => {
test('real crypto + base64 + JSON verify path runs under bun build --compile', () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-smoke-build-'));
const out = join(dir, 'smoke-harness');
try {
execFileSync('bun', ['build', '--compile', `--outfile=${out}`, HARNESS], {
encoding: 'utf-8',
timeout: 180_000,
});
// No network reachable is fine — the harness crafts the attestation in-process.
const result = execFileSync(out, [], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env } });
expect(result).toContain('SMOKE_OK');
expect(result).not.toContain('SMOKE_FAIL');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+313 -2
View File
@@ -1,11 +1,15 @@
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
defaultFetchAttestation,
expectedAssetName,
parseAttestationBundle,
resolvePlatformAsset,
runBinarySelfUpdate,
verifyIntegrity,
type ParsedAttestation,
type ReleaseAsset,
} from '../src/core/binary-self-update.ts';
@@ -14,6 +18,26 @@ const ASSETS: ReleaseAsset[] = [
{ name: 'gbrain-linux-x64', url: 'https://example.com/linux-x64' },
];
// A builder id that matches EXPECTED_BUILDER_ID_PREFIX (this repo's release workflow).
const VALID_BUILDER = 'https://github.com/garrytan/gbrain/.github/workflows/release.yml@refs/heads/master';
const FAKE_DIGEST = 'a'.repeat(64);
const OTHER_DIGEST = 'b'.repeat(64);
/**
* Integrity deps that PASS for `assetName` at `digest`: computeDigest is pinned
* so we don't depend on file bytes, and fetchAttestation returns a matching
* SLSA-provenance attestation. Used to isolate the non-integrity behaviors that
* predate this gate.
*/
function passingIntegrity(assetName: string, digest = FAKE_DIGEST) {
return {
computeDigest: () => digest,
fetchAttestation: async (): Promise<ParsedAttestation[]> => [
{ subjects: [{ name: assetName, sha256: digest }], builderId: VALID_BUILDER },
],
};
}
describe('expectedAssetName / resolvePlatformAsset', () => {
test('maps the two published targets', () => {
expect(expectedAssetName('darwin', 'arm64')).toBe('gbrain-darwin-arm64');
@@ -42,7 +66,7 @@ async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
}
describe('runBinarySelfUpdate', () => {
test('happy path: stages, smokes, atomically replaces the target', async () => {
test('happy path: stages, verifies integrity, smokes, atomically replaces the target', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD BINARY');
@@ -52,6 +76,8 @@ describe('runBinarySelfUpdate', () => {
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'NEW BINARY'),
smoke: () => true,
checkVersion: () => true, // staged bytes are not a real binary; version bind is covered in e2e
...passingIntegrity('gbrain-darwin-arm64'),
});
expect(res.ok).toBe(true);
expect(res.asset).toBe('gbrain-darwin-arm64');
@@ -130,9 +156,294 @@ describe('runBinarySelfUpdate', () => {
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'CORRUPT'),
smoke: () => false,
...passingIntegrity('gbrain-darwin-arm64'),
});
expect(res.reason).toBe('smoke_failed');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
test('version mismatch (downgrade-replay) → version_mismatch, target untouched, no rename', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
// Release claims v9.9.9, but the staged (validly-attested, older) binary
// reports a different version → downgrade blocked before rename.
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'OLD ATTESTED BINARY'),
smoke: () => true,
checkVersion: (_p, expected) => expected === '1.2.3', // staged reports 1.2.3, expected 9.9.9
...passingIntegrity('gbrain-darwin-arm64'),
});
expect(res.reason).toBe('version_mismatch');
expect(readFileSync(target, 'utf8')).toBe('OLD');
expect(readdirSync(dir).filter((f) => f.includes('.tmp.'))).toEqual([]);
});
});
// --- Integrity gate (WS2) ---
test('tampered binary (digest not attested) → integrity_failed, target untouched, NEVER executed', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
let smokeCalled = false;
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'TAMPERED'),
computeDigest: () => FAKE_DIGEST,
// Attestation exists but covers a DIFFERENT digest.
fetchAttestation: async () => [
{ subjects: [{ name: 'gbrain-darwin-arm64', sha256: OTHER_DIGEST }], builderId: VALID_BUILDER },
],
smoke: () => {
smokeCalled = true;
return true;
},
});
expect(res.reason).toBe('integrity_failed');
expect(readFileSync(target, 'utf8')).toBe('OLD');
expect(smokeCalled).toBe(false); // integrity gates BEFORE exec
expect(readdirSync(dir).filter((f) => f.includes('.tmp.'))).toEqual([]); // unverified download discarded
});
});
test('untrusted builder id → integrity_failed even when digest matches', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'NEW'),
computeDigest: () => FAKE_DIGEST,
fetchAttestation: async () => [
{
subjects: [{ name: 'gbrain-darwin-arm64', sha256: FAKE_DIGEST }],
builderId: 'https://github.com/evil/fork/.github/workflows/release.yml@refs/heads/master',
},
],
smoke: () => true,
});
expect(res.reason).toBe('integrity_failed');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
test('no attestation available (404/offline/rate-limited) → integrity_unavailable, fail-closed', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
let smokeCalled = false;
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'NEW'),
computeDigest: () => FAKE_DIGEST,
fetchAttestation: async () => null, // endpoint unreachable / 403 / 404
smoke: () => {
smokeCalled = true;
return true;
},
});
expect(res.reason).toBe('integrity_unavailable');
expect(readFileSync(target, 'utf8')).toBe('OLD');
expect(smokeCalled).toBe(false);
expect(readdirSync(dir).filter((f) => f.includes('.tmp.'))).toEqual([]); // unverified download discarded
});
});
test('right workflow, wrong ref (dispatch from a non-master branch) → integrity_failed', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'NEW'),
computeDigest: () => FAKE_DIGEST,
fetchAttestation: async () => [
{
subjects: [{ name: 'gbrain-darwin-arm64', sha256: FAKE_DIGEST }],
// Same repo + workflow, but minted from an arbitrary branch dispatch.
builderId:
'https://github.com/garrytan/gbrain/.github/workflows/release.yml@refs/heads/attacker-branch',
},
],
smoke: () => true,
});
expect(res.reason).toBe('integrity_failed');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
test('attestation names a different asset → integrity_failed', async () => {
await withTmp(async (dir) => {
const target = join(dir, 'gbrain');
writeFileSync(target, 'OLD');
const res = await runBinarySelfUpdate(target, {
platform: 'darwin',
arch: 'arm64',
fetchRelease: async () => ({ tag: 'v9.9.9', assets: ASSETS }),
download: async (_url, dest) => writeFileSync(dest, 'NEW'),
computeDigest: () => FAKE_DIGEST,
// Right digest + builder, but attests the linux asset, not ours.
fetchAttestation: async () => [
{ subjects: [{ name: 'gbrain-linux-x64', sha256: FAKE_DIGEST }], builderId: VALID_BUILDER },
],
smoke: () => true,
});
expect(res.reason).toBe('integrity_failed');
expect(readFileSync(target, 'utf8')).toBe('OLD');
});
});
});
describe('verifyIntegrity (unit)', () => {
const good = (): ParsedAttestation[] => [
{ subjects: [{ name: 'gbrain-linux-x64', sha256: FAKE_DIGEST }], builderId: VALID_BUILDER },
];
test('passes on name + digest + builder match', async () => {
expect(await verifyIntegrity('/x', 'gbrain-linux-x64', () => FAKE_DIGEST, async () => good())).toBeNull();
});
test('non-hex digest → integrity_unavailable', async () => {
expect(await verifyIntegrity('/x', 'gbrain-linux-x64', () => 'not-a-digest', async () => good())).toBe(
'integrity_unavailable',
);
});
test('computeDigest throws → integrity_unavailable', async () => {
expect(
await verifyIntegrity('/x', 'gbrain-linux-x64', () => {
throw new Error('nope');
}, async () => good()),
).toBe('integrity_unavailable');
});
test('empty attestation array → integrity_unavailable (not failed, not verified)', async () => {
expect(await verifyIntegrity('/x', 'gbrain-linux-x64', () => FAKE_DIGEST, async () => [])).toBe(
'integrity_unavailable',
);
});
});
describe('defaultFetchAttestation (global fetch stubbed)', () => {
const REAL_FETCH = globalThis.fetch;
function stubFetch(impl: (url: string) => Promise<Response> | Response) {
(globalThis as any).fetch = (input: any) => Promise.resolve(impl(String(input)));
}
function restoreFetch() {
(globalThis as any).fetch = REAL_FETCH;
}
function apiResponse(attestations: unknown[]): Response {
return new Response(JSON.stringify({ attestations }), { status: 200 });
}
function bundleFor(stmt: unknown) {
return { bundle: { dsseEnvelope: { payload: Buffer.from(JSON.stringify(stmt)).toString('base64') } } };
}
const VALID_STMT = {
subject: [{ name: 'gbrain-linux-x64', digest: { sha256: FAKE_DIGEST } }],
predicate: { runDetails: { builder: { id: VALID_BUILDER } } },
};
test('200 with valid bundles → parsed attestations', async () => {
stubFetch(() => apiResponse([bundleFor(VALID_STMT)]));
try {
const parsed = await defaultFetchAttestation(FAKE_DIGEST);
expect(parsed).not.toBeNull();
expect(parsed![0]!.builderId).toBe(VALID_BUILDER);
expect(parsed![0]!.subjects).toEqual([{ name: 'gbrain-linux-x64', sha256: FAKE_DIGEST }]);
} finally {
restoreFetch();
}
});
test('404 / 403 (non-2xx) → null (unavailable, never verified)', async () => {
stubFetch(() => new Response('nope', { status: 404 }));
try {
expect(await defaultFetchAttestation(FAKE_DIGEST)).toBeNull();
} finally {
restoreFetch();
}
stubFetch(() => new Response('rate limited', { status: 403 }));
try {
expect(await defaultFetchAttestation(FAKE_DIGEST)).toBeNull();
} finally {
restoreFetch();
}
});
test('200 with empty attestations array → null', async () => {
stubFetch(() => apiResponse([]));
try {
expect(await defaultFetchAttestation(FAKE_DIGEST)).toBeNull();
} finally {
restoreFetch();
}
});
test('200 with only malformed bundles → null', async () => {
stubFetch(() => apiResponse([{ bundle: {} }, { bundle: { dsseEnvelope: { payload: '!!!' } } }]));
try {
expect(await defaultFetchAttestation(FAKE_DIGEST)).toBeNull();
} finally {
restoreFetch();
}
});
test('fetch throws (network down) → null', async () => {
stubFetch(() => {
throw new Error('ENETDOWN');
});
try {
expect(await defaultFetchAttestation(FAKE_DIGEST)).toBeNull();
} finally {
restoreFetch();
}
});
});
describe('parseAttestationBundle', () => {
function bundleFor(stmt: unknown) {
return { dsseEnvelope: { payload: Buffer.from(JSON.stringify(stmt)).toString('base64') } };
}
test('decodes subjects + builder id from a real-shaped SLSA statement', () => {
const parsed = parseAttestationBundle(
bundleFor({
_type: 'https://in-toto.io/Statement/v1',
subject: [{ name: 'gbrain-linux-x64', digest: { sha256: FAKE_DIGEST } }],
predicateType: 'https://slsa.dev/provenance/v1',
predicate: { runDetails: { builder: { id: VALID_BUILDER } } },
}),
);
expect(parsed).not.toBeNull();
expect(parsed!.builderId).toBe(VALID_BUILDER);
expect(parsed!.subjects).toEqual([{ name: 'gbrain-linux-x64', sha256: FAKE_DIGEST }]);
});
test('missing payload → null', () => {
expect(parseAttestationBundle({})).toBeNull();
expect(parseAttestationBundle({ dsseEnvelope: {} })).toBeNull();
});
test('undecodable / non-JSON payload → null', () => {
expect(parseAttestationBundle({ dsseEnvelope: { payload: '!!!not base64 json!!!' } })).toBeNull();
});
test('no subject or no builder → null', () => {
expect(parseAttestationBundle(bundleFor({ subject: [], predicate: {} }))).toBeNull();
expect(
parseAttestationBundle(bundleFor({ subject: [{ name: 'x', digest: { sha256: FAKE_DIGEST } }], predicate: {} })),
).toBeNull();
});
test('non-SLSA predicateType → null (only build-provenance is accepted)', () => {
expect(
parseAttestationBundle(
bundleFor({
predicateType: 'https://in-toto.io/attestation/vuln/v0.1',
subject: [{ name: 'gbrain-linux-x64', digest: { sha256: FAKE_DIGEST } }],
predicate: { runDetails: { builder: { id: VALID_BUILDER } } },
}),
),
).toBeNull();
});
});
+95 -5
View File
@@ -7,23 +7,51 @@
* (defaultSmoke / execFileSync) real renameSync over a running "binary"
* re-exec the swapped binary and assert it reports the new version.
*
* Only `fetchRelease` is injected (to point at the local server instead of the
* GitHub API). The "binary" is a `#!/bin/sh` script so the swap mechanics are
* exercised identically on darwin + linux; platform/arch are pinned to
* linux/x64 so `expectedAssetName` resolves deterministically regardless of host.
* Only `fetchRelease` and `fetchAttestation` are injected (to point at the local
* server / an in-process attestation instead of the GitHub API tests must
* never hit the real network). The DIGEST computation stays REAL
* (`defaultComputeDigest` runs, uninjected): the in-process attestation carries
* the true sha256 of the served bytes, so the integrity gate is exercised
* end-to-end, not bypassed. The "binary" is a `#!/bin/sh` script so the swap
* mechanics are exercised identically on darwin + linux; platform/arch are
* pinned to linux/x64 so `expectedAssetName` resolves deterministically
* regardless of host.
*
* No DB runs in every environment.
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { chmodSync, existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runBinarySelfUpdate, type ReleaseAsset } from '../../src/core/binary-self-update.ts';
import {
EXPECTED_BUILDER_IDS,
runBinarySelfUpdate,
type ParsedAttestation,
type ReleaseAsset,
} from '../../src/core/binary-self-update.ts';
const NEW_BINARY = '#!/bin/sh\necho "gbrain 0.43.0"\n';
const OLD_BINARY = '#!/bin/sh\necho "gbrain 0.42.0"\n';
const NON_GBRAIN = '#!/bin/sh\necho "not the tool"\n';
// A real gbrain binary, but an OLDER version than the release claims — the
// downgrade-replay an asset-swap adversary would serve (its old digest still
// has a valid attestation).
const OLD_ATTESTED_BINARY = '#!/bin/sh\necho "gbrain 0.41.0"\n';
/** Attestation deps whose subject digest is the REAL sha256 of `content`
* integrity passes only because the served bytes genuinely match. */
function attestationFor(content: string): {
fetchAttestation: () => Promise<ParsedAttestation[]>;
} {
const digest = createHash('sha256').update(content).digest('hex');
return {
fetchAttestation: async () => [
{ subjects: [{ name: 'gbrain-linux-x64', sha256: digest }], builderId: EXPECTED_BUILDER_IDS[0]! },
],
};
}
let server: ReturnType<typeof Bun.serve>;
let base: string;
@@ -34,6 +62,7 @@ beforeAll(() => {
fetch(req) {
const path = new URL(req.url).pathname;
if (path === '/good-asset') return new Response(NEW_BINARY, { status: 200 });
if (path === '/downgrade-asset') return new Response(OLD_ATTESTED_BINARY, { status: 200 });
if (path === '/bad-smoke-asset') return new Response(NON_GBRAIN, { status: 200 });
if (path === '/404-asset') return new Response('nope', { status: 404 });
if (path === '/empty-asset') return new Response('', { status: 200 });
@@ -74,6 +103,7 @@ describe('binary self-update — real swap E2E', () => {
expect(versionOf(target)).toBe('gbrain 0.42.0');
const result = await runBinarySelfUpdate(target, {
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/good-asset`) }),
...attestationFor(NEW_BINARY),
platform: 'linux',
arch: 'x64',
});
@@ -92,6 +122,9 @@ describe('binary self-update — real swap E2E', () => {
try {
const result = await runBinarySelfUpdate(target, {
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/bad-smoke-asset`) }),
// Attestation genuinely matches the served bytes, so integrity passes
// and the failure is isolated to the smoke step (the case under test).
...attestationFor(NON_GBRAIN),
platform: 'linux',
arch: 'x64',
});
@@ -104,6 +137,63 @@ describe('binary self-update — real swap E2E', () => {
}
});
test('missing attestation → integrity_unavailable: never executed, old binary intact, no leftovers', async () => {
const { dir, target } = makeTargetBinary();
try {
const result = await runBinarySelfUpdate(target, {
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/good-asset`) }),
fetchAttestation: async () => null, // 404 / offline / rate-limited
platform: 'linux',
arch: 'x64',
});
expect(result.ok).toBe(false);
expect(result.reason).toBe('integrity_unavailable');
expect(versionOf(target)).toBe('gbrain 0.42.0'); // fail-closed, old binary intact
expect(tmpLeftovers(dir)).toEqual([]); // unverified download discarded
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('downgrade replay: older attested binary served for a newer tag → version_mismatch, no swap', async () => {
const { dir, target } = makeTargetBinary();
try {
const result = await runBinarySelfUpdate(target, {
// Release tag is v0.43.0, but the served bytes are a real, validly
// attested gbrain 0.41.0 (its digest has a genuine attestation).
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/downgrade-asset`) }),
...attestationFor(OLD_ATTESTED_BINARY), // digest+builder verify PASSES
platform: 'linux',
arch: 'x64',
});
expect(result.ok).toBe(false);
expect(result.reason).toBe('version_mismatch'); // real --version says 0.41.0, tag says 0.43.0
expect(versionOf(target)).toBe('gbrain 0.42.0'); // running binary untouched
expect(tmpLeftovers(dir)).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('tampered bytes (digest not attested) → integrity_failed before any exec', async () => {
const { dir, target } = makeTargetBinary();
try {
const result = await runBinarySelfUpdate(target, {
// Server serves NEW_BINARY, but the attestation covers different bytes.
fetchRelease: async () => ({ tag: 'v0.43.0', assets: assets(`${base}/good-asset`) }),
...attestationFor('DIFFERENT CONTENT ENTIRELY'),
platform: 'linux',
arch: 'x64',
});
expect(result.ok).toBe(false);
expect(result.reason).toBe('integrity_failed');
expect(versionOf(target)).toBe('gbrain 0.42.0');
expect(tmpLeftovers(dir)).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('download HTTP error leaves the old binary untouched', async () => {
const { dir, target } = makeTargetBinary();
try {
@@ -0,0 +1,70 @@
/**
* Standalone entrypoint for the WS2 compiled-binary integrity smoke test.
*
* The unit tests inject `computeDigest`/`fetchAttestation`, which HIDES the one
* risk that actually matters for a compiled binary: do node:crypto (sha256),
* base64 decode, and JSON parse survive `bun build --compile`? (The `sigstore`
* npm package does NOT that's why the verify is dependency-free.) This harness
* runs the REAL verify path `defaultComputeDigest` (node:crypto) +
* `parseAttestationBundle` (base64 + JSON) + `verifyIntegrity` fully OFFLINE
* (the attestation is crafted in-process), and prints a sentinel.
*
* Compiled + executed by test/binary-self-update-compiled.serial.test.ts.
*/
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
defaultComputeDigest,
EXPECTED_BUILDER_IDS,
parseAttestationBundle,
verifyIntegrity,
type ParsedAttestation,
} from '../../src/core/binary-self-update.ts';
// Derived from the source constant so a workflow rename can't silently split
// the harness from the real verify. (test/binary-self-update.test.ts keeps its
// own literal deliberately, as a regression pin.)
const BUILDER = EXPECTED_BUILDER_IDS[0]!;
async function main(): Promise<void> {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-smoke-'));
try {
const bin = join(dir, 'gbrain-linux-x64');
writeFileSync(bin, 'PRETEND BINARY BYTES\n');
// Real crypto: sha256 of the file.
const digest = defaultComputeDigest(bin);
if (!/^[0-9a-f]{64}$/.test(digest)) throw new Error(`bad digest: ${digest}`);
// Real base64 + JSON: build a bundle exactly like the GitHub API returns,
// then round-trip it through parseAttestationBundle.
const stmt = {
_type: 'https://in-toto.io/Statement/v1',
subject: [{ name: 'gbrain-linux-x64', digest: { sha256: digest } }],
predicateType: 'https://slsa.dev/provenance/v1',
predicate: { runDetails: { builder: { id: BUILDER } } },
};
const bundle = { dsseEnvelope: { payload: Buffer.from(JSON.stringify(stmt)).toString('base64') } };
const parsed = parseAttestationBundle(bundle);
if (!parsed) throw new Error('parseAttestationBundle returned null on a valid bundle');
const fetchOk = async (): Promise<ParsedAttestation[]> => [parsed];
const fetchNone = async (): Promise<ParsedAttestation[] | null> => null;
const okReason = await verifyIntegrity(bin, 'gbrain-linux-x64', defaultComputeDigest, fetchOk);
if (okReason !== null) throw new Error(`expected verified, got ${okReason}`);
const failReason = await verifyIntegrity(bin, 'gbrain-linux-x64', defaultComputeDigest, fetchNone);
if (failReason !== 'integrity_unavailable') throw new Error(`expected integrity_unavailable, got ${failReason}`);
console.log('SMOKE_OK');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
main().catch((e) => {
console.log(`SMOKE_FAIL: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
});
+38 -1
View File
@@ -12,7 +12,7 @@ import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expectedAssetName } from '../src/core/binary-self-update.ts';
import { EXPECTED_BUILDER_IDS, expectedAssetName } from '../src/core/binary-self-update.ts';
const ROOT = join(import.meta.dir, '..');
const WORKFLOW = readFileSync(join(ROOT, '.github/workflows/release.yml'), 'utf8');
@@ -59,6 +59,43 @@ describe('release.yml ↔ binary-self-update asset contract', () => {
expect(topLevel).not.toContain('contents: write');
});
test('build attests provenance for the compiled binary (self-update integrity depends on it)', () => {
// binary-self-update verifies the downloaded asset against this attestation
// (verifyIntegrity). Removing the attest step would fail-close every fleet
// self-update (integrity_unavailable) with CI still green — this is the pin.
expect(WORKFLOW).toContain('attest-build-provenance');
expect(WORKFLOW).toMatch(/subject-path:\s*bin\/\$\{\{ matrix\.artifact \}\}/);
});
test('expected builder ids name this workflow file on a trusted ref', () => {
// verifyIntegrity accepts only attestations whose builder id is exactly
// release.yml@<trusted ref>. If the workflow file is renamed or the ref
// scheme changes, this test forces the constant to move in lockstep.
for (const id of EXPECTED_BUILDER_IDS) {
expect(id).toContain('/.github/workflows/release.yml@');
const ref = id.split('@')[1]!;
expect(ref.startsWith('refs/')).toBe(true);
}
// The workflow this repo actually ships from is the one the ids name.
expect(EXPECTED_BUILDER_IDS.some((id) => id.endsWith('@refs/heads/master'))).toBe(true);
});
test('release builds the admin UI fresh from source before compiling', () => {
// Supply-chain pin: the distributed binary's admin bundle must come from
// admin/src (built in the release job), never from committed admin/dist
// bytes. Deleting this step would silently re-trust the committed bundle.
expect(WORKFLOW).toContain('bun run build:admin');
expect(WORKFLOW).toMatch(/cd admin && bun install --frozen-lockfile/);
// Cache key covers the admin lockfile so the fresh build is reproducible.
expect(WORKFLOW).toContain("hashFiles('bun.lock', 'admin/bun.lock')");
// Ordering: the admin build must run BEFORE the compile that embeds it.
const adminIdx = WORKFLOW.indexOf('bun run build:admin');
const compileIdx = WORKFLOW.indexOf('bun build --compile');
expect(adminIdx).toBeGreaterThan(-1);
expect(compileIdx).toBeGreaterThan(-1);
expect(adminIdx).toBeLessThan(compileIdx);
});
test('template-repo push keeps the PAT out of argv (askpass, not URL-embedded)', () => {
// The token must never ride the git command line: no
// `https://x-access-token:${TEMPLATE_REPO_PAT}@...` remote URLs.
@@ -64,6 +64,9 @@ describe('#1696 — inline sync extract stamps links_extracted_at', () => {
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
execSync('git config user.email "t@t.com"', { cwd: repoPath, stdio: 'pipe' });
execSync('git config user.name "T"', { cwd: repoPath, stdio: 'pipe' });
// Never inherit the machine's global commit.gpgsign — a signing gpg-agent can
// OOM under full-suite memory pressure and fail the fixture commit (#1696).
execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'pipe' });
mkdirSync(join(repoPath, 'people'), { recursive: true });
mkdirSync(join(repoPath, 'companies'), { recursive: true });
writeFileSync(join(repoPath, 'people/alice.md'), [
+136
View File
@@ -0,0 +1,136 @@
/**
* Exit-code contract tests for scripts/wave-security-scan.sh the mechanical
* security sweep RELEASING.md step 5 gates community-PR waves on.
*
* Contract: exit 0 = nothing alarm-worthy; exit 1 = alarm (obfuscation in code,
* secrets, admin/dist change, or a broken secrets lane); exit 2 = usage/env
* error. Each case runs against a throwaway fixture git repo (same pattern as
* scripts/changelog-entry.sh's fixture tests in test/release-workflow.test.ts).
*
* gitleaks may be absent on a dev box; the script treats that as a broken
* secrets lane (fail-closed exit 1 with a WARNING) cases that assert exit 0
* therefore skip when gitleaks is not installed.
*/
import { describe, expect, test } from 'bun:test';
import { execFileSync, execSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const ROOT = join(import.meta.dir, '..');
const SCRIPT = join(ROOT, 'scripts', 'wave-security-scan.sh');
const HAS_GITLEAKS = (() => {
try {
execSync('command -v gitleaks', { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
function run(cwd: string, args: string[]): { out: string; code: number } {
try {
const out = execFileSync('bash', [SCRIPT, ...args], { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
return { out, code: 0 };
} catch (e: any) {
return { out: String(e.stdout ?? '') + String(e.stderr ?? ''), code: e.status ?? 1 };
}
}
/** Fixture repo with a base commit; returns its path. Caller adds commits. */
function fixtureRepo(): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-wavescan-'));
const git = (cmd: string) => execSync(`git ${cmd}`, { cwd: dir, stdio: 'ignore' });
git('init -q');
git('config user.email t@example.invalid');
git('config user.name t');
git('config commit.gpgsign false'); // don't inherit the machine's signing config under load
writeFileSync(join(dir, 'README.md'), '# fixture\n');
git('add README.md');
git('commit -qm base');
return dir;
}
function commitFile(dir: string, rel: string, content: string, msg: string): void {
const abs = join(dir, rel);
mkdirSync(join(abs, '..'), { recursive: true });
writeFileSync(abs, content);
execSync(`git add ${JSON.stringify(rel)} && git commit -qm ${JSON.stringify(msg)}`, { cwd: dir, stdio: 'ignore' });
}
describe('wave-security-scan.sh exit-code contract', () => {
test('exit 2: not a resolvable range', () => {
const dir = fixtureRepo();
try {
expect(run(dir, ['not-a-ref..HEAD']).code).toBe(2);
expect(run(dir, ['HEAD']).code).toBe(2); // missing ..head form
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30000);
test('exit 0: empty range, --json emits the standard schema', () => {
const dir = fixtureRepo();
try {
const { out, code } = run(dir, ['--json', 'HEAD..HEAD']);
expect(code).toBe(0);
const parsed = JSON.parse(out.trim().split('\n').pop()!);
expect(parsed.commits).toBe(0);
expect(parsed.alarm).toBe(0);
expect(parsed).toHaveProperty('checks');
expect(parsed).toHaveProperty('admin_dist_changed');
expect(parsed).toHaveProperty('gitleaks_hits');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30000);
test.skipIf(!HAS_GITLEAKS)('exit 0: benign code change', () => {
const dir = fixtureRepo();
try {
commitFile(dir, 'src/util.ts', 'export const add = (a: number, b: number) => a + b;\n', 'benign');
const { out, code } = run(dir, ['HEAD~1..HEAD']);
expect(code).toBe(0);
expect(out).toContain('gitleaks');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30000);
test('exit 1: eval( in an added code line is an ALARM', () => {
const dir = fixtureRepo();
try {
commitFile(dir, 'src/sneaky.ts', 'export const run = (s: string) => eval(s);\n', 'sneaky');
const { out, code } = run(dir, ['HEAD~1..HEAD']);
expect(code).toBe(1);
expect(out).toContain('obfuscation');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30000);
test('exit 1: admin/dist change is an ALARM even though its content is not grepped', () => {
const dir = fixtureRepo();
try {
commitFile(dir, 'admin/dist/assets/index-XYZ.js', 'var x=1;\n', 'bundle');
const { out, code } = run(dir, ['HEAD~1..HEAD']);
expect(code).toBe(1);
expect(out).toContain('admin/dist');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30000);
test('eval( in a markdown file is NOT an alarm (prose, not payload)', () => {
const dir = fixtureRepo();
try {
commitFile(dir, 'docs/notes.md', 'We never call eval( in production code.\n', 'docs');
const { out } = run(dir, ['--json', 'HEAD~1..HEAD']);
const parsed = JSON.parse(out.trim().split('\n').pop()!);
expect(parsed.checks.obfuscation.total).toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 30000);
});