mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 02:12:40 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af3b1f58a | ||
|
|
d2557b047d | ||
|
|
92b8cf67e5 | ||
|
|
63a2a4b2ef | ||
|
|
394a4343ce | ||
|
|
371ae12615 | ||
|
|
9249231e40 | ||
|
|
28c766fe32 | ||
|
|
ad5419bf31 | ||
|
|
12359d8d4a | ||
|
|
5f0ef91bae | ||
|
|
ec4ca2af81 | ||
|
|
8a357092d1 | ||
|
|
5137e5f3c6 | ||
|
|
2b89855633 | ||
|
|
9283d01c6a | ||
|
|
7dcc2387e0 | ||
|
|
2a450cb9b9 |
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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. -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
Executable
+277
@@ -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"
|
||||
@@ -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:');
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'), [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user