diff --git a/.github/workflows/linux-lifecycle.yml b/.github/workflows/linux-lifecycle.yml new file mode 100644 index 0000000..f3ae9c9 --- /dev/null +++ b/.github/workflows/linux-lifecycle.yml @@ -0,0 +1,39 @@ +name: Linux lifecycle + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/linux-lifecycle.yml + - scripts/check-linux-lifecycle.sh + - scripts/check-update-rollback-hosted.mjs + +permissions: + contents: read + +jobs: + lifecycle: + # A hosted VM with real PID-1 systemd: a job container cannot prove service installation. + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + - name: Install rootless runtime prerequisites in the disposable VM + run: | + sudo apt-get update + sudo apt-get install -y podman uidmap slirp4netns fuse-overlayfs + - name: Exercise installation, restart, fixture recovery and uninstall + run: bash scripts/check-linux-lifecycle.sh "$RUNNER_TEMP/lifecycle-evidence" + - name: Upload non-secret lifecycle evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: linux-lifecycle-${{ github.sha }} + path: ${{ runner.temp }}/lifecycle-evidence/ + if-no-files-found: error diff --git a/.github/workflows/linux-reboot.yml b/.github/workflows/linux-reboot.yml new file mode 100644 index 0000000..5322a27 --- /dev/null +++ b/.github/workflows/linux-reboot.yml @@ -0,0 +1,34 @@ +name: Linux guest reboot + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/linux-reboot.yml + - scripts/check-linux-reboot.sh + - scripts/install-systemd.sh + - scripts/uninstall-systemd.sh + +permissions: + contents: read + +jobs: + reboot: + # The hosted runner survives while a separate, disposable Ubuntu guest actually reboots. + # KVM availability is experimental on hosted runners: missing support fails explicitly. + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install, reboot and uninstall in a disposable KVM guest + run: bash scripts/check-linux-reboot.sh + - name: Upload sanitized reboot acceptance evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: linux-guest-reboot-${{ github.sha }} + path: ${{ runner.temp }}/reboot-evidence/reboot.log + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08bfe87..6fcef0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,10 @@ permissions: jobs: evidence: runs-on: ubuntu-latest + defaults: + run: + # Fail if docker save fails, even when the gzip process itself exits successfully. + shell: bash permissions: contents: read id-token: write @@ -19,6 +23,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22.13" @@ -38,6 +43,7 @@ jobs: docker image inspect channelgate/release-candidate > dist/release/runtime-image.json docker run --rm -i --network none --read-only --entrypoint node channelgate/release-candidate - < scripts/runtime-model-inventory.mjs > dist/release/runtime-models.json docker save channelgate/release-candidate | gzip > dist/release/runtime-image.tar.gz + gzip --test dist/release/runtime-image.tar.gz - name: Inventory installed OS, Python and npm packages in the image uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 (pinned) with: diff --git a/CHANGELOG.md b/CHANGELOG.md index aa19217..147d200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,22 @@ product overview. No changes recorded after the current candidate. +## 0.6.0-rc.2 — 2026-09-08 (release candidate) + +Follow-up candidate for the installer findings from disposable Linux acceptance. Full live QA +remains deferred; stable promotion is not approved. + +- Provision rootless images with a clean service-account environment and from the installed + application directory, so operator storage settings and an inaccessible invocation folder + cannot break a dedicated-account installation. +- Export the release image with pipeline failure propagation and verify gzip integrity before + generating its checksums and attestations. +- Recognize exact reviewed public vendor fixtures during release-artifact scanning, including + complete self-test/development keys and compiler-adjacent constants. Unknown values still fail, + and repository files and Git history receive no exceptions. +- Add disposable Linux installation, encrypted backup/restore, real updater rollback and actual + OS reboot checks; authenticated engine and chat acceptance remains a separate live gate. + ## 0.6.0-rc.1 — 2026-09-08 (release candidate) Candidate source and build evidence for review. The GitHub Release remains a draft; the planned diff --git a/FEATURES.md b/FEATURES.md index f2248e1..78226cc 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,7 +1,27 @@ # ChannelGate — Features +- Release artifact scanning recognizes only exact SHA-256 fingerprints of reviewed public + toolchain fixtures. PEM exceptions bind the complete key, never a header or first body line; + repository/history scans and unknown or altered artifact credentials remain strict. Public + provenance is recorded with every exception. + A categorized catalog of what's shipped. Cross-linked to `TEST-PLAN.md` checks. +- **Disposable Linux lifecycle evidence:** the manually dispatchable Linux lifecycle workflow + exercises a fresh dedicated-account systemd install with the full rootless Podman image, + HTTP liveness after restart, encrypted fixture backup/restore and non-destructive uninstall. + A local fixture upstream also proves the real CLI updater restores Git and restarts systemd + after a candidate test failure or readiness failure; engine smoke and test/pretest commands + are controlled fixture inputs. + Its script refuses non-hosted or occupied hosts. A separate KVM guest workflow exercises an + actual OS reboot, service autostart and persistent database/container-volume fixtures. + Authenticated engine update smoke and conversation/session acceptance remain separate live + gates. → TEST-PLAN: Disposable Linux lifecycle workflow. +- **Service-account image provisioning** uses the same explicit environment as the systemd + daemon so operator XDG/container storage settings cannot redirect a fresh build into another + user's private Podman store, and runs from the service-owned checkout so an operator-private + invocation directory cannot prevent Podman namespace setup. + - Development acceptance policy: behavior changes include reproducible Claude and Codex acceptance definitions and clearly separate automated evidence from live operator validation. Public contributors do not need access to a private QA service. → TEST-PLAN: Development acceptance policy. diff --git a/TEST-PLAN.md b/TEST-PLAN.md index c5226ee..f31f2ad 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -3,6 +3,85 @@ Cumulative functional + security regression. Extended per slice. Run top-to-bottom for a full pass. Many checks are manual (require a real Slack workspace + an authenticated `claude` CLI). +## Disposable Linux lifecycle workflow + +- [x] Real fresh-install/restart/encrypted fixture backup/restore/uninstall acceptance passed on + [hosted run 34163913150](https://github.com/makeitfutureDev/channelgate/actions/runs/34163913150), + tested merge SHA `5d9d424f41217945c841603622cc3035a4d61def`. This used synthetic SQL/config data + and a public fixture passphrase in a new dedicated identity, never production data or keys. + The fixture backup was taken while its daemon ran; the replacement restore removed stale + sidecars/stray config and preserved the exact markers with SQLite integrity `ok`. +- [x] Full lifecycle plus real CLI updater rollback passed on + [hosted run 34164530300](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530300), + tested merge SHA `547ae2454d96fc038e97ae111174cebfb3bcd998`. Both the controlled test failure + and deliberately wrong live revision produced durable `rolled_back` state, restored checkout + and healthy service, preserved recovery-snapshot SQL/config markers, and released the lock. + The original candidate was restored before uninstall. Engine smoke and test/pretest commands + were controlled LOCAL fixture inputs; the CLI updater, Git, npm install/audit/static checks, + systemd restart, snapshot and rollback operations were real. Separate unit regressions: 37/37. +- [x] Actual guest OS reboot/autostart/durable fixture/uninstall acceptance passed on + [KVM run 34164530261](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530261), + tested merge SHA `547ae2454d96fc038e97ae111174cebfb3bcd998`. Both OS boot ID and daemon instance + changed, the service started without manual repair, and SQL/rootless-volume markers survived. + These are engine-independent operations results; authenticated conversation gates below remain + unexecuted. The first guest attempt timed out downloading the browser; the passing attempt used + an IPv4 guest network. No production download timeout or image contents were changed. + +- Automated setup: dispatch `.github/workflows/linux-lifecycle.yml` for the candidate ref (a PR + changing this workflow/script also runs it). GitHub-hosted Ubuntu 24.04, Node 24, real PID-1 + systemd and rootless Podman; no job container, provider credentials or chat connection. + `scripts/check-linux-lifecycle.sh` refuses non-hosted runners, occupied fixture paths, service + units and accounts before mutation. It operates only on `/opt/channelgate-lifecycle` and the + newly installed `/var/lib/channelgate-lifecycle` service identity; never an operator deployment. + The installer image build must succeed even when the invoking runner has its own container + storage configuration; the service account must use its own HOME/store and explicit environment. + Invoke the installer by absolute path while cwd is an operator-private directory; the image + probe/build must run from the service-owned checkout rather than inherit that inaccessible cwd. +- Pass evidence: `linux-lifecycle-` artifact records source revision, VM image, versions and + every PASS line. Require successful fresh install/image build, non-root container with zero + effective capabilities/no-new-privileges, enabled active service, distinct healthy instance ID + after real systemd restart, encrypted snapshot while the fixture daemon runs, restored + `before-backup` SQL/config markers, SQLite integrity `ok`, removal of stale WAL/SHM and stray + config, healthy restart and uninstall preserving the account/database/encrypted backup. + These checks are engine-independent because they issue no engine turn. Update transaction + unit evidence is uploaded separately as `update-fixture-tests.tap`; injected failures do not + count as real authenticated engine update/rollback acceptance. +- Hosted updater operations: `scripts/check-update-rollback-hosted.mjs` is restricted to that + exact disposable checkout/runtime, controls the LOCAL fixture's engine smoke and test commands, + and creates a local bare Git upstream. It invokes the unmodified CLI updater first against a + candidate whose test exits 42, then one whose live health reports an incorrect revision. + Require two durable `rolled_back` results, checkout/running revision A, new healthy service + instance, released update lock, private operator recovery snapshot and unchanged SQL marker. + The readiness failure must name the deliberately wrong live revision, not just any timeout; + both recovery-snapshot SQL/config markers must match and snapshot integrity must be `ok`. + Finally restore the original tested source revision and verify its fresh healthy instance. + Public evidence must name the smoke stub; no Claude/Codex authentication is proven here. +- Guest OS reboot: dispatch `.github/workflows/linux-reboot.yml` for the candidate ref. The + hosted runner must expose KVM; unavailable acceleration fails explicitly. The wrapper verifies + the official Ubuntu Noble image checksum, boots a separate cloud-init guest, installs Node 24 + and the real service/full image, writes `persisted-through-os-reboot` into SQLite and a rootless + named volume, and reboots the GUEST while the runner stays alive. Pass only with different OS + boot ID and daemon instance ID, enabled active service without manual post-boot start, usable + rootless runtime, intact SQL/volume markers, SQLite integrity `ok` and uninstall preserving data. + Artifact `linux-guest-reboot-` contains sanitized logs only. No keys, disk images, runtime + config or databases are uploaded. This operations case is engine-independent; it does not prove + a resumed Claude/Codex conversation. +- [ ] Post-reboot conversation gate (both configured engines): on a separate disposable Linux VM, install + the candidate and create a channel that writes `LIFECYCLE-BEFORE-REBOOT` in its own work folder. + Record instance ID and engine/session identity, reboot the machine, then ask each engine in + its existing thread to read the marker. Require automatic service start without manual repair, + a new daemon instance ID, preserved marker/session and container-only engine execution. + Engine-free guest reboot evidence cannot clear this conversation/session gate. +- [ ] Authenticated update rollback gate (Claude and Codex): on that disposable deployment, + configure both engine credentials in its own service identity and a local fixture upstream. + Baseline revision A must answer the fixed update smoke response for both engines. Create + fast-forward candidate B with an intentional failing test; invoke `npm run update`. + Require a visible candidate failure, durable `rolled_back` state, restored A checkout and + an operator recovery snapshot of database/config (runtime data is not automatically rolled + back), a new healthy A instance, passing smoke for both baseline engines and a new + ordinary turn in each existing channel. Repeat with a candidate that passes tests but fails + readiness after restart. Never perform induced-failure checks on a production deployment. + ## Base modes and independent options (2026-09-08) - Automated: `modes`, `channel-settings-modal`, `mode-command-audit`, `folders-settings`, @@ -4285,3 +4364,20 @@ acceptance gates; no production restart or external message was performed by the both files successfully and returned the exact marker CG_PERSONAL_REFERENCE_OK_7319. The fixture was removed. This verifies catalog/reference readability in an existing container; the deployed author-grant, resume/revocation and Slack cases above remain unexecuted. + + +## Reviewed public fixtures in release artifacts (2026-09-08) + +- Engine-independent automated acceptance: run `node --test test/release-artifact-fixtures.test.js + test/release-secret-history.test.js`. Disposable repositories use synthetic token/PEM fixtures + with a private test copy of the exception catalog. Pass when exact fixtures are waived only + for generated artifacts; tracked files and history still reject them; an unknown token, + incomplete key or same-prefix/different-body key fails with values redacted; gzip stream + boundaries preserve complete-key matching and report each finding once. No engine participates. +- Candidate gate: run the exact-tag Release evidence workflow. Download the actual image archive, + inventory, model hashes and checksums; verify provenance and inspect the scan's reviewed-fixture + count. All unrecognized findings must fail the job. The checked-in catalog records public + source/binary URLs and fingerprints; adding an exception requires identifying its purpose and + verifying exact upstream bytes, not merely finding the value on the internet. Do not exempt + entire paths, packages, credential patterns or repository/history scans. +- Candidate execution remains pending; this regression does not waive the separate live QA campaign. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 47ab36b..642266b 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,6 +1,6 @@ # Compatibility matrix -Candidate: **0.6.0-rc.1**, prepared 2026-09-08. Stable promotion awaits full live QA. +Candidate: **0.6.0-rc.2**, prepared 2026-09-08. Stable promotion awaits full live QA. The component matrix below describes this candidate. ## What the GitHub checks prove diff --git a/docs/MAINTAINER-RELEASE.md b/docs/MAINTAINER-RELEASE.md index 187a87a..56c407c 100644 --- a/docs/MAINTAINER-RELEASE.md +++ b/docs/MAINTAINER-RELEASE.md @@ -19,6 +19,14 @@ It is not evidence of a built image or signed provenance. No local run should be successful release workflow. Review upstream runtime licenses and model notices for the actual candidate before distributing its image. +Generated-artifact secret scanning has a narrow reviewed-public-fixture catalog in +`scripts/reviewed-artifact-fixtures.json`. Each entry binds an exact matched value to public +upstream evidence and an identified purpose; private keys require the complete PEM fingerprint. +Repository/history scans never apply those exceptions. On a new finding, keep the gate failed +until provenance and purpose are verified; add no directory, package or pattern-wide exclusions. +Review the new image's count and digests after a toolchain update rather than carrying an +unexplained match forward. The catalog contains hashes and public evidence links, never values. + ## Repository presentation Repository maintainers can edit the About description, homepage and topics with GitHub settings diff --git a/docs/RELEASE-ACCEPTANCE.md b/docs/RELEASE-ACCEPTANCE.md index 0859797..794ba1f 100644 --- a/docs/RELEASE-ACCEPTANCE.md +++ b/docs/RELEASE-ACCEPTANCE.md @@ -1,7 +1,7 @@ # Release acceptance packet Status: **prepared; full live QA deferred to the planned campaign** (owner instruction, -2026-09-08). Candidate: **0.6.0-rc.1**. These are reproducible definitions, not claimed +2026-09-08). Candidate: **0.6.0-rc.2**. These are reproducible definitions, not claimed passes. Use disposable private fixtures only. Record the actual channel IDs, host/image revision, engine/model versions, timestamps and evidence links when executing. No live chat/provider fixture was created or used during the source remediation. A separate disposable container lifecycle test diff --git a/docs/RELEASE-CHECKLIST.md b/docs/RELEASE-CHECKLIST.md index 094a46f..a52c87c 100644 --- a/docs/RELEASE-CHECKLIST.md +++ b/docs/RELEASE-CHECKLIST.md @@ -2,7 +2,7 @@ > 0.5.0 was published on 2026-09-06 by decision of the Licensor. Items still unticked below stay > tracked for the next release. -> Current candidate: **0.6.0-rc.1** (2026-09-08), draft pending the planned full live QA campaign. +> Current candidate: **0.6.0-rc.2** (2026-09-08), draft pending the planned full live QA campaign. > The deferred QA gate is not waived. - [x] Authorized owner selected and documented the Makeitfuture Sustainable Use License; the @@ -16,13 +16,20 @@ - [x] Every commit in the candidate carries a `Signed-off-by` trailer per `CLA.md`, and any contribution predating the CLA has a recorded acceptance (all authorship is the Licensor's). - [x] The published version's public-availability date is recorded in `CHANGELOG.md` (0.5.0 — 2026-09-06). -- [ ] Candidate version/tag/changelog and `docs/COMPATIBILITY.md` match `v0.6.0-rc.1`; +- [ ] Candidate version/tag/changelog and `docs/COMPATIBILITY.md` match `v0.6.0-rc.2`; record the exact tag and verified evidence before checking this item. - [x] CI, security coverage, dependency/secret scans, and real CLI nightly canaries are green (2026-09-06). - [ ] Release workflow emitted the image SBOM, model hashes, exact image archive, checksums and verifiable GitHub artifact attestations for this candidate. -- [ ] Encrypted backup completed and `npm run restore:drill` passed off production data. -- [ ] The Linux systemd service package passed install/restart/uninstall checks. +- [x] Encrypted backup and `npm run restore:drill` passed using isolated synthetic data, + including replacement restore with database/config verification (2026-09-08, + [Linux lifecycle evidence](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530300)). + This does not claim a restore of this deployment's production data. +- [x] Linux systemd installation, restart, real CLI updater rollback and uninstall passed in + the disposable lifecycle VM above; actual OS reboot, automatic service startup and + database/container-volume persistence passed in the + [separate guest reboot](https://github.com/makeitfutureDev/channelgate/actions/runs/34164530261). + Rollback uses controlled engine smoke and test commands; authenticated canary remains below. - [ ] Canary passed health, Slack, engine, approval, confinement, update, and rollback checks. - [x] Project requests use GitHub; direct security/privacy/legal/support requests use `contact@makeitfuture.com`. Contracted support requires an active Makeitfuture or approved diff --git a/package-lock.json b/package-lock.json index ce4f280..1052ae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "channelgate", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "channelgate", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@composio/core": "0.14.0", diff --git a/package.json b/package.json index ad20c4d..6c01288 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "channelgate", - "version": "0.6.0-rc.1", + "version": "0.6.0-rc.2", "private": true, "license": "SEE LICENSE IN LICENSE.md", "author": "Tiberiu Socaci (MAKEITFUTURE S.R.L.)", diff --git a/scripts/check-linux-lifecycle.sh b/scripts/check-linux-lifecycle.sh new file mode 100644 index 0000000..3d4f84e --- /dev/null +++ b/scripts/check-linux-lifecycle.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Destructive operations acceptance, ONLY on an empty GitHub-hosted Ubuntu VM. +# No provider credentials, chat connections or engine prompts are needed or accepted here. +set -euo pipefail +if [ "${GITHUB_ACTIONS:-}" != true ] || [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ] || + [ "$(cat /proc/1/comm)" != systemd ]; then + echo "Refusing: this check requires a disposable GitHub-hosted VM with PID-1 systemd." >&2 + exit 2 +fi +SOURCE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +EVIDENCE="${1:?Pass the evidence output directory}" +APP_DIR=/opt/channelgate-lifecycle +SERVICE_USER=cg-lifecycle +SERVICE_HOME=/var/lib/channelgate-lifecycle +for occupied in "$APP_DIR" "$SERVICE_HOME" /etc/systemd/system/channelgate.service /etc/systemd/system/claude-gateway.service; do + [ ! -e "$occupied" ] || { echo "Refusing occupied lifecycle fixture: $occupied" >&2; exit 2; } +done +if id "$SERVICE_USER" >/dev/null 2>&1 || id claude-gateway >/dev/null 2>&1; then + echo "Refusing existing lifecycle or legacy service account" >&2; exit 2 +fi +mkdir -p "$EVIDENCE" +exec > >(tee "$EVIDENCE/lifecycle.log") 2>&1 +printf 'revision=%s\n' "$(git -C "$SOURCE_DIR" rev-parse HEAD)" +printf 'runner=%s\n' "${ImageOS:-unknown} ${ImageVersion:-unknown}" +uname -sr +node --version +podman --version +systemctl --version | head -1 +df -h /opt /var/lib + +cleanup() { + outcome=$? + trap - EXIT + if [ -f /etc/systemd/system/channelgate.service ]; then + sudo bash "$APP_DIR/scripts/uninstall-systemd.sh" --system || true + fi + printf 'exit_code=%s\n' "$outcome" + exit "$outcome" +} +trap cleanup EXIT +pass() { printf 'PASS %s\n' "$1"; } +as_service() { + sudo runuser -u "$SERVICE_USER" -- env -i HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ + CHANNELGATE_DB="" CLAUDE_GATEWAY_DIR="" CLAUDE_GATEWAY_DB="" \ + XDG_RUNTIME_DIR="/run/user/$(id -u "$SERVICE_USER")" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u "$SERVICE_USER")/bus" \ + PATH="$PATH" /bin/sh -c 'cd "$HOME" && exec "$@"' sh "$@" +} +health() { + node --input-type=module - "$1" <<'NODE' +import assert from 'node:assert/strict'; +const previous = process.argv[2]; +for (let attempt = 0; attempt < 90; attempt += 1) { + try { + const response = await fetch('http://127.0.0.1:4747/api/health', { signal: AbortSignal.timeout(2000) }); + assert.equal(response.status, 200); + const data = await response.json(); + assert.equal(data.ok, true); + assert.ok(data.instanceId && data.instanceId !== previous); + assert.equal(data.slack.connected, false); + console.log(data.instanceId); + process.exit(0); + } catch { await new Promise((resolve) => setTimeout(resolve, 1000)); } +} +throw new Error('Fresh healthy daemon instance did not become available'); +NODE +} + +# Clone public source only; never copy the caller's home, credentials or runtime state. +sudo git clone --no-checkout "https://github.com/${GITHUB_REPOSITORY:?}.git" "$APP_DIR" +sudo git -C "$APP_DIR" fetch origin "$(git -C "$SOURCE_DIR" rev-parse HEAD)" +sudo git -C "$APP_DIR" checkout --detach "$(git -C "$SOURCE_DIR" rev-parse HEAD)" +BOOTSTRAP_ROOT="$(mktemp -d)" +sudo env PATH="$PATH" CHANNELGATE_DIR="$BOOTSTRAP_ROOT" CHANNELGATE_DB="" CLAUDE_GATEWAY_DIR="" CLAUDE_GATEWAY_DB="" \ + bash "$APP_DIR/scripts/install.sh" --without-whisper +# Public, disposable fixture password prevents a generated bootstrap secret reaching CI logs. +# The daemon listens only on loopback; no provider/chat tokens are configured. +sudo tee "$APP_DIR/.env" >/dev/null <<'ENV' +ADMIN_PASSWORD=public-disposable-lifecycle-fixture +CG_BIND_HOST=127.0.0.1 +PORT=4747 +ENV +sudo env PATH="$PATH" CG_SERVICE_USER="$SERVICE_USER" CG_SERVICE_HOME="$SERVICE_HOME" \ + bash "$APP_DIR/scripts/install-systemd.sh" +FIRST_INSTANCE="$(health '')" +systemctl is-enabled channelgate.service +systemctl is-active channelgate.service +[ "$(systemctl show channelgate.service -p User --value)" = "$SERVICE_USER" ] +[ "$(systemctl show channelgate.service -p Delegate --value)" = yes ] +[ "$(sudo stat -c %a "$SERVICE_HOME/service.env")" = 600 ] +as_service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true +pass 'fresh systemd installation, dedicated identity, enablement and HTTP liveness' +as_service podman run --rm --userns=keep-id --cap-drop=all --security-opt=no-new-privileges \ + --network=bridge channelgate/runtime:latest node --input-type=module -e \ + 'import assert from "node:assert/strict"; import {readFileSync} from "node:fs"; assert.notEqual(process.getuid(),0); const s=readFileSync("/proc/self/status","utf8"); assert.match(s,/CapEff:\s+0+\n/); assert.match(s,/NoNewPrivs:\s+1\n/); console.log("container uid="+process.getuid()+" zero capabilities, no-new-privileges");' +pass 'production image runs with rootless identity and dropped capabilities (no engine prompt)' +sudo systemctl restart channelgate.service +SECOND_INSTANCE="$(health "$FIRST_INSTANCE")" +pass 'real systemd restart produces a new healthy daemon instance' + +# Use the freshly installed disposable daemon database, never an operator deployment. +sudo systemctl stop channelgate.service +as_service node --input-type=module - "$SERVICE_HOME" <<'NODE' +import { DatabaseSync } from 'node:sqlite'; +import { writeFileSync } from 'node:fs'; +const root = process.argv[2]; +const db = new DatabaseSync(`${root}/gateway.db`); +db.exec("CREATE TABLE lifecycle_proof(value TEXT); INSERT INTO lifecycle_proof VALUES ('before-backup')"); +db.close(); +writeFileSync(`${root}/config/lifecycle-proof.json`, '{"value":"before-backup"}\n'); +NODE +sudo systemctl start channelgate.service +THIRD_INSTANCE="$(health "$SECOND_INSTANCE")" +as_service env CG_BACKUP_PASSPHRASE=public-disposable-backup-fixture bash "$APP_DIR/scripts/backup-config.sh" +as_service env CG_BACKUP_PASSPHRASE=public-disposable-backup-fixture bash "$APP_DIR/scripts/restore-drill.sh" +sudo systemctl stop channelgate.service +as_service node --input-type=module - "$SERVICE_HOME" <<'NODE' +import { DatabaseSync } from 'node:sqlite'; +import { writeFileSync } from 'node:fs'; +const root = process.argv[2]; +const db = new DatabaseSync(`${root}/gateway.db`); +db.exec("UPDATE lifecycle_proof SET value='after-backup'"); +db.close(); +writeFileSync(`${root}/config/stray-lifecycle.json`, '{}'); +writeFileSync(`${root}/gateway.db-wal`, 'stale-wal'); +writeFileSync(`${root}/gateway.db-shm`, 'stale-shm'); +NODE +as_service env CG_BACKUP_PASSPHRASE=public-disposable-backup-fixture CG_RESTORE_CONFIRM=YES \ + bash "$APP_DIR/scripts/restore-config.sh" +as_service node --input-type=module - "$SERVICE_HOME" <<'NODE' +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { existsSync, readFileSync } from 'node:fs'; +const root = process.argv[2]; +assert.equal(existsSync(`${root}/gateway.db-wal`), false); +assert.equal(existsSync(`${root}/gateway.db-shm`), false); +assert.equal(existsSync(`${root}/config/stray-lifecycle.json`), false); +assert.equal(JSON.parse(readFileSync(`${root}/config/lifecycle-proof.json`)).value, 'before-backup'); +const db = new DatabaseSync(`${root}/gateway.db`, { readOnly: true }); +assert.equal(db.prepare('SELECT value FROM lifecycle_proof').get().value, 'before-backup'); +assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check, 'ok'); +db.close(); +NODE +sudo systemctl start channelgate.service +health "$THIRD_INSTANCE" +pass 'live encrypted fixture snapshot, disposable drill, replacement restore and healthy restart' + +# Existing injected transaction tests are separately labelled: these do not claim a real update +# across authenticated engine versions or an induced failure in a production deployment. +as_service env -u CHANNELGATE_DIR -u CHANNELGATE_DB -u CLAUDE_GATEWAY_DIR -u CLAUDE_GATEWAY_DB \ + -u CG_WORKSPACE_DIR -u CG_TEST_SCRATCH \ + bash -c 'cd "$1" && node --test --test-reporter=tap test/update-runner.test.js test/update-state.test.js test/update-smoke.test.js' bash "$APP_DIR" \ + > "$EVIDENCE/update-fixture-tests.tap" 2>&1 +pass 'injected update failure/rollback and container smoke regression tests' +as_service env CG_DISPOSABLE_LIFECYCLE=1 node "$APP_DIR/scripts/check-update-rollback-hosted.mjs" +sudo bash "$APP_DIR/scripts/uninstall-systemd.sh" --system +[ ! -e /etc/systemd/system/channelgate.service ] +if systemctl is-active --quiet channelgate.service; then echo 'Service remained active after uninstall'; exit 1; fi +id "$SERVICE_USER" >/dev/null +as_service test -s "$SERVICE_HOME/gateway.db" +as_service test -s "$SERVICE_HOME/backups/config.tar.gz.enc" +pass 'uninstall removes and stops system unit while preserving account, database and backup' +printf '%s\n' \ + 'NOT RUN: actual host reboot and post-reboot recovery (hosted job does not survive reboot).' \ + 'NOT RUN: authenticated Claude/Codex update smoke (real Git/service rollback uses a controlled smoke fixture).' \ + 'NOT RUN: Slack/Airtable/Composio or other external acceptance campaigns.' diff --git a/scripts/check-linux-reboot.sh b/scripts/check-linux-reboot.sh new file mode 100644 index 0000000..31e2989 --- /dev/null +++ b/scripts/check-linux-reboot.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +# Real OS reboot acceptance in a disposable nested Ubuntu guest; NEVER reboot the runner. +# Guest setup follows the official Ubuntu/cloud-init QEMU guidance: +# https://documentation.ubuntu.com/public-images/public-images-how-to/launch-qcow-with-qemu/ +# https://docs.cloud-init.io/en/24.3/tutorial/qemu.html +# Requires KVM. Nested virtualization on GitHub-hosted runners is experimental; unavailable +# acceleration is an explicit failure, never a skipped/passing acceptance or a slow TCG fallback. +set -euo pipefail +if [ "${GITHUB_ACTIONS:-}" != true ] || [ "${RUNNER_ENVIRONMENT:-}" != github-hosted ] || + [ "$(cat /proc/1/comm)" != systemd ]; then + echo 'Refusing: requires a disposable GitHub-hosted VM with PID-1 systemd.' >&2 + exit 2 +fi +: "${RUNNER_TEMP:?}" "${GITHUB_REPOSITORY:?}" +[[ "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || exit 2 +SOURCE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REVISION="$(git -C "$SOURCE_DIR" rev-parse HEAD)" +[[ "$REVISION" =~ ^[a-f0-9]{40}$ ]] || exit 2 +EVIDENCE="$RUNNER_TEMP/reboot-evidence" +FIXTURE="$RUNNER_TEMP/channelgate-reboot-guest" +[ ! -e "$FIXTURE" ] || { echo 'Refusing occupied reboot fixture.' >&2; exit 2; } +umask 077 +mkdir "$FIXTURE" +mkdir -p "$EVIDENCE" +exec > >(tee "$EVIDENCE/reboot.log") 2>&1 +QEMU_PID='' +cleanup() { + outcome=$? + trap - EXIT + if [ -n "$QEMU_PID" ]; then + kill "$QEMU_PID" 2>/dev/null || true + wait "$QEMU_PID" 2>/dev/null || true + fi + # No disks, cloud-init data, private keys, runtime files or raw guest logs are retained. + rm -rf -- "$FIXTURE" + printf 'exit_code=%s\n' "$outcome" + exit "$outcome" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +printf 'revision=%s\nrunner=%s %s\n' "$REVISION" "${ImageOS:-unknown}" "${ImageVersion:-unknown}" +if [ ! -c /dev/kvm ]; then + echo 'UNAVAILABLE: /dev/kvm is absent; actual guest reboot acceptance was NOT RUN.' + exit 1 +fi +sudo apt-get update -qq > "$FIXTURE/host-apt.log" 2>&1 +sudo apt-get install -y -qq qemu-system-x86 qemu-utils cloud-image-utils acl >> "$FIXTURE/host-apt.log" 2>&1 +sudo setfacl -m "u:$(id -u):rw" /dev/kvm +[ -r /dev/kvm ] && [ -w /dev/kvm ] || { + echo 'UNAVAILABLE: KVM is inaccessible; actual guest reboot acceptance was NOT RUN.'; exit 1; +} +# Sparse backing disk still needs real space for the full production toolchain build. +[ "$(df -Pk "$FIXTURE" | awk 'NR==2 {print $4}')" -ge 25165824 ] || { + echo 'UNAVAILABLE: fewer than 24 GiB free for the production-image guest fixture.'; exit 1; +} +echo 'CHECK downloading official Ubuntu Noble cloud image and checksum manifest' +IMAGE=noble-server-cloudimg-amd64.img +IMAGE_BASE=https://cloud-images.ubuntu.com/noble/current +curl --fail --silent --show-error --location --retry 3 "$IMAGE_BASE/SHA256SUMS" -o "$FIXTURE/SHA256SUMS" +curl --fail --silent --show-error --location --retry 3 "$IMAGE_BASE/$IMAGE" -o "$FIXTURE/$IMAGE" +( + cd "$FIXTURE" + awk -v image="$IMAGE" '$2 == image || $2 == "*" image { print }' SHA256SUMS > image.sha256 + [ "$(wc -l < image.sha256)" -eq 1 ] + sha256sum --check image.sha256 +) +printf 'image_sha256=%s\n' "$(awk '{print $1}' "$FIXTURE/image.sha256")" +qemu-img create -q -f qcow2 -F qcow2 -b "$FIXTURE/$IMAGE" "$FIXTURE/guest.qcow2" 48G +ssh-keygen -q -t ed25519 -N '' -C disposable-reboot-fixture -f "$FIXTURE/ssh-key" +cat > "$FIXTURE/user-data" < "$FIXTURE/meta-data" +cloud-localds "$FIXTURE/seed.img" "$FIXTURE/user-data" "$FIXTURE/meta-data" +# Run as the runner user. The owned child lives only for this script and is reaped by its trap. +qemu-system-x86_64 -enable-kvm -cpu host -smp 2 -m 4096 -display none -monitor none \ + -serial "file:$FIXTURE/serial.log" \ + -drive "file=$FIXTURE/guest.qcow2,format=qcow2,if=virtio" \ + -drive "file=$FIXTURE/seed.img,format=raw,if=virtio" \ + -netdev user,id=net0,ipv6=off,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=net0 \ + > "$FIXTURE/qemu.log" 2>&1 & +QEMU_PID=$! +SSH=(ssh -i "$FIXTURE/ssh-key" -p 2222 -o BatchMode=yes -o ConnectTimeout=5 + -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$FIXTURE/known_hosts" + -o ServerAliveInterval=15 -o ServerAliveCountMax=4 reboot-check@127.0.0.1) +wait_ssh() { + for attempt in $(seq 1 120); do + kill -0 "$QEMU_PID" 2>/dev/null || { + echo 'UNAVAILABLE: KVM guest exited before SSH readiness; reboot acceptance incomplete.' + tail -n 20 "$FIXTURE/qemu.log" + return 1; + } + if "${SSH[@]}" true >/dev/null 2>&1; then return 0; fi + sleep 3 + done + echo 'FAIL guest SSH readiness timed out'; return 1 +} +wait_ssh +"${SSH[@]}" sudo cloud-init status --wait > "$FIXTURE/cloud-init.log" 2>&1 +echo 'PASS disposable KVM Ubuntu guest booted' +# Arguments are restricted to an owner/repo slug and a full SHA before SSH constructs a command. +"${SSH[@]}" sudo bash -s -- "$GITHUB_REPOSITORY" "$REVISION" <<'GUEST' +set -euo pipefail +[ "$(cat /proc/1/comm)" = systemd ] +[ "$(hostname)" = channelgate-reboot-fixture ] +APP_DIR=/opt/channelgate-reboot +SERVICE_USER=cg-reboot +SERVICE_HOME=/var/lib/channelgate-reboot +for occupied in "$APP_DIR" "$SERVICE_HOME" /etc/systemd/system/channelgate.service /etc/systemd/system/claude-gateway.service; do + [ ! -e "$occupied" ] || { echo 'FAIL occupied guest fixture'; exit 2; } +done +if id "$SERVICE_USER" >/dev/null 2>&1 || id claude-gateway >/dev/null 2>&1; then exit 2; fi +phase=prerequisites +setup_failed() { + outcome=$? + printf 'FAIL guest setup phase=%s line=%s\n' "$phase" "$1" + # Only synthetic public-source installation output, bounded and scrubbed. Never print the + # daemon journal, environment/config, cloud-init seed, SSH key or runtime database. + for log in /var/tmp/reboot-prerequisites.log /var/tmp/reboot-install.log; do + if [ -f "$log" ]; then + tail -n 60 "$log" | sed -E 's/public-disposable-[A-Za-z0-9-]+/[fixture-password]/g; s/(sk-|xox[baprs]-)[A-Za-z0-9_-]+/[redacted]/g' + fi + done + exit "$outcome" +} +trap 'setup_failed "$LINENO"' ERR +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq > /var/tmp/reboot-prerequisites.log 2>&1 +apt-get install -y -qq ca-certificates curl git xz-utils podman uidmap slirp4netns fuse-overlayfs >> /var/tmp/reboot-prerequisites.log 2>&1 +phase=node +mkdir /var/tmp/reboot-node +cd /var/tmp/reboot-node +curl -fsSL --retry 3 https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt -o SHASUMS256.txt +awk '$2 ~ /^node-v24\.[0-9]+\.[0-9]+-linux-x64\.tar\.xz$/ {print}' SHASUMS256.txt > node.sha256 +[ "$(wc -l < node.sha256)" -eq 1 ] +NODE_ARCHIVE="$(awk '{print $2}' node.sha256)" +curl -fsSL --retry 3 "https://nodejs.org/dist/latest-v24.x/$NODE_ARCHIVE" -o "$NODE_ARCHIVE" +sha256sum --check node.sha256 +tar -xJf "$NODE_ARCHIVE" -C /usr/local --strip-components=1 +printf 'node_version=%s\nnode_sha256=%s\n' "$(node --version)" "$(awk '{print $1}' node.sha256)" +phase=public-source +echo 'CHECK public browser CDN connectivity from the IPv4 guest network' +curl -4 --head --location --silent --show-error --connect-timeout 15 --max-time 45 \ + --output /dev/null --write-out 'browser_cdn_http=%{http_code} connect_seconds=%{time_connect}\n' \ + https://cdn.playwright.dev/ || true +git clone --quiet --no-checkout "https://github.com/$1.git" "$APP_DIR" +git -C "$APP_DIR" fetch --quiet origin "$2" +git -C "$APP_DIR" checkout --quiet --detach "$2" +[ "$(git -C "$APP_DIR" rev-parse HEAD)" = "$2" ] +# Public synthetic password avoids first-boot random secrets. No Slack/provider credentials. +cat > "$APP_DIR/.env" <<'ENV' +ADMIN_PASSWORD=public-disposable-reboot-fixture +CG_BIND_HOST=127.0.0.1 +PORT=4747 +ENV +phase=install-dependencies +echo 'CHECK installing daemon dependencies in guest' +CHANNELGATE_DIR=/var/tmp/reboot-bootstrap bash "$APP_DIR/scripts/install.sh" --without-whisper > /var/tmp/reboot-install.log 2>&1 +phase=install-system-service +echo 'CHECK production systemd installer is building the complete rootless image in guest' +CG_SERVICE_USER="$SERVICE_USER" CG_SERVICE_HOME="$SERVICE_HOME" bash "$APP_DIR/scripts/install-systemd.sh" >> /var/tmp/reboot-install.log 2>&1 +cat > /var/tmp/reboot-health.mjs <<'NODE' +import assert from 'node:assert/strict'; +for (let attempt = 0; attempt < 120; attempt++) { + try { + const response = await fetch('http://127.0.0.1:4747/api/health', { signal: AbortSignal.timeout(2000) }); + assert.equal(response.status, 200); + const data = await response.json(); + assert.equal(data.ok, true); + assert.equal(data.slack.connected, false); + assert.ok(data.instanceId && data.instanceId !== process.argv[2]); + console.log(data.instanceId); + process.exit(0); + } catch { await new Promise(resolve => setTimeout(resolve, 1000)); } +} +throw Error('Fresh healthy daemon instance did not become available'); +NODE +cat > /var/tmp/reboot-as-service <<'SERVICE' +#!/usr/bin/env bash +set -euo pipefail +uid="$(id -u cg-reboot)" +cd /var/lib/channelgate-reboot +exec runuser -u cg-reboot -- env -i HOME=/var/lib/channelgate-reboot CHANNELGATE_DIR=/var/lib/channelgate-reboot \ + XDG_RUNTIME_DIR="/run/user/$uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + PATH=/usr/local/bin:/usr/bin:/bin "$@" +SERVICE +chmod 700 /var/tmp/reboot-as-service +phase=pre-reboot-state +node /var/tmp/reboot-health.mjs > /var/tmp/reboot-first-instance +systemctl is-enabled --quiet channelgate.service +systemctl is-active --quiet channelgate.service +[ "$(systemctl show channelgate.service -p User --value)" = "$SERVICE_USER" ] +/var/tmp/reboot-as-service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true +/var/tmp/reboot-as-service node --input-type=module <<'NODE' +import { DatabaseSync } from 'node:sqlite'; +const db = new DatabaseSync('/var/lib/channelgate-reboot/gateway.db'); +db.exec("CREATE TABLE reboot_proof(value TEXT); INSERT INTO reboot_proof VALUES ('persisted-through-os-reboot')"); +db.close(); +NODE +/var/tmp/reboot-as-service podman volume create reboot-proof >/dev/null +/var/tmp/reboot-as-service podman run --rm --userns=keep-id --cap-drop=all --security-opt=no-new-privileges \ + --network=none -v reboot-proof:/proof:U channelgate/runtime:latest node -e \ + 'require("node:assert/strict").notEqual(process.getuid(), 0); require("node:fs").writeFileSync("/proof/marker", "persisted-through-os-reboot")' +cat /proc/sys/kernel/random/boot_id > /var/tmp/reboot-first-boot-id +sync +echo 'PASS real service installation, HTTP health, database and rootless volume fixture before reboot' +GUEST +FIRST_BOOT="$("${SSH[@]}" cat /proc/sys/kernel/random/boot_id)" +printf 'guest_boot_before=%s\n' "$FIRST_BOOT" +echo 'CHECK rebooting guest OS; GitHub runner stays alive' +# systemctl schedules a real guest OS reboot. It may close SSH before reporting success. +"${SSH[@]}" sudo systemctl reboot || true +REBOOTED=false +for attempt in $(seq 1 120); do + kill -0 "$QEMU_PID" 2>/dev/null || { echo 'FAIL guest process exited during reboot'; exit 1; } + CURRENT_BOOT="$("${SSH[@]}" cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)" + if [[ "$CURRENT_BOOT" =~ ^[a-f0-9-]{36}$ ]] && [ "$CURRENT_BOOT" != "$FIRST_BOOT" ]; then + REBOOTED=true; break + fi + sleep 3 +done +[ "$REBOOTED" = true ] || { echo 'FAIL guest OS boot ID did not change'; exit 1; } +printf 'guest_boot_after=%s\n' "$CURRENT_BOOT" +"${SSH[@]}" sudo bash -s <<'VERIFY' +set -euo pipefail +trap 'printf "FAIL post-reboot verification line=%s\n" "$LINENO"' ERR +[ "$(cat /proc/sys/kernel/random/boot_id)" != "$(cat /var/tmp/reboot-first-boot-id)" ] +# Do not start/restart the service here: HTTP must return after automatic boot startup. +NEW_INSTANCE="$(node /var/tmp/reboot-health.mjs "$(cat /var/tmp/reboot-first-instance)")" +[ "$NEW_INSTANCE" != "$(cat /var/tmp/reboot-first-instance)" ] +systemctl is-enabled --quiet channelgate.service +systemctl is-active --quiet channelgate.service +[ "$(systemctl show channelgate.service -p User --value)" = cg-reboot ] +[ "$(loginctl show-user cg-reboot -p Linger --value)" = yes ] +/var/tmp/reboot-as-service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true +printf 'daemon_instance_before=%s\ndaemon_instance_after=%s\n' "$(cat /var/tmp/reboot-first-instance)" "$NEW_INSTANCE" +echo 'PASS actual OS reboot, enabled active service autostart, new HTTP instance and rootless runtime' +/var/tmp/reboot-as-service podman run --rm --userns=keep-id --cap-drop=all --security-opt=no-new-privileges \ + --network=none -v reboot-proof:/proof channelgate/runtime:latest node -e \ + 'const a=require("node:assert/strict"); a.notEqual(process.getuid(),0); a.equal(require("node:fs").readFileSync("/proof/marker","utf8"),"persisted-through-os-reboot")' +cat > /var/tmp/reboot-db-check.mjs <<'NODE' +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +const db = new DatabaseSync('/var/lib/channelgate-reboot/gateway.db', { readOnly: true }); +assert.equal(db.prepare('SELECT value FROM reboot_proof').get().value, 'persisted-through-os-reboot'); +assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check, 'ok'); +db.close(); +NODE +chmod 644 /var/tmp/reboot-db-check.mjs +/var/tmp/reboot-as-service node /var/tmp/reboot-db-check.mjs +echo 'PASS database integrity and rootless named-volume marker survived guest OS reboot' +bash /opt/channelgate-reboot/scripts/uninstall-systemd.sh --system +[ ! -e /etc/systemd/system/channelgate.service ] +if systemctl is-active --quiet channelgate.service; then echo 'FAIL service still active after uninstall'; exit 1; fi +id cg-reboot >/dev/null +/var/tmp/reboot-as-service node /var/tmp/reboot-db-check.mjs +echo 'PASS actual uninstall stops/removes service and preserves service identity and database marker' +VERIFY +echo 'PASS completed real guest OS reboot acceptance; no authenticated engines or external chat services used' diff --git a/scripts/check-update-rollback-hosted.mjs b/scripts/check-update-rollback-hosted.mjs new file mode 100644 index 0000000..ab75a54 --- /dev/null +++ b/scripts/check-update-rollback-hosted.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// Real CLI updater + Git + systemd rollback in the lifecycle VM. Engine smoke and npm test +// commands are controlled in LOCAL fixture commits; this is not authenticated engine acceptance. +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const root = "/var/lib/channelgate-lifecycle"; +assert.equal(repo, "/opt/channelgate-lifecycle", "only the disposable lifecycle checkout is permitted"); +assert.equal(process.env.CHANNELGATE_DIR, root); +assert.equal(process.env.CG_DISPOSABLE_LIFECYCLE, "1"); +assert.notEqual(process.getuid(), 0); +assert.equal(statSync(root).uid, process.getuid()); +assert.equal(JSON.parse(readFileSync(`${root}/config/lifecycle-proof.json`)).value, "before-backup"); +const run = (command, args) => execFileSync(command, args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +const git = (...args) => run("git", args); +const original = git("rev-parse", "HEAD"); +const upstream = `${root}/update-fixture.git`; +assert.equal(existsSync(upstream), false); +git("config", "user.name", "Lifecycle Fixture"); +git("config", "user.email", "fixture@example.invalid"); +const appFile = `${repo}/src/web/app.js`; +const packageFile = `${repo}/package.json`; +const settingsFile = `${root}/config/settings.json`; +const settings = existsSync(settingsFile) ? JSON.parse(readFileSync(settingsFile)) : {}; +writeFileSync(settingsFile, `${JSON.stringify({ ...settings, whisperEnabled: false, driveSyncEnabled: false }, null, 2)}\n`); +const appSource = readFileSync(appFile, "utf8"); +assert.equal(appSource.split("updateSmoke = runUpdateSmoke,").length, 2); +writeFileSync(appFile, appSource.replace("updateSmoke = runUpdateSmoke,", + 'updateSmoke = async () => ({ ok: true, engines: [{ engine: "fixture-only", ok: true }] }),')); +const manifest = JSON.parse(readFileSync(packageFile)); +manifest.scripts.test = 'node -e "console.log(\'controlled fixture suite passed\')"'; +delete manifest.scripts.pretest; +writeFileSync(packageFile, `${JSON.stringify(manifest, null, 2)}\n`); +git("switch", "-c", "lifecycle-update-fixture"); +git("add", "src/web/app.js", "package.json"); +git("commit", "-m", "fixture: deterministic unauthenticated smoke for updater operations"); +const baseline = git("rev-parse", "HEAD"); +git("clone", "--bare", repo, upstream); +git("remote", "set-url", "origin", upstream); +git("fetch", "origin"); +git("branch", "--set-upstream-to=origin/lifecycle-update-fixture"); + +async function health(expectedRevision, previous = "") { + for (let attempt = 0; attempt < 90; attempt += 1) { + try { + const auth = JSON.parse(readFileSync(`${root}/config/internal-auth.json`)); + const response = await fetch(`http://127.0.0.1:${auth.port}/api/health`, { + headers: { "x-cg-secret": auth.secret }, signal: AbortSignal.timeout(2000), + }); + assert.equal(response.status, 200); + const value = await response.json(); + assert.equal(value.ok, true); + assert.equal(value.revision, expectedRevision); + assert.ok(value.instanceId && value.instanceId !== previous); + return value; + } catch { await new Promise((resolve) => setTimeout(resolve, 1000)); } + } + throw new Error(`Expected a new healthy instance on ${expectedRevision}`); +} +function restart() { + const pid = Number(run("systemctl", ["show", "--property", "MainPID", "--value", "channelgate.service"])); + assert.ok(Number.isInteger(pid) && pid > 1); + process.kill(pid, "SIGUSR2"); +} + +restart(); +let before = await health(baseline); +for (const failure of ["test", "readiness"]) { + if (failure === "test") { + const candidateManifest = { ...manifest, scripts: { ...manifest.scripts, test: 'node -e "process.exit(42)"' } }; + writeFileSync(packageFile, `${JSON.stringify(candidateManifest, null, 2)}\n`); + git("add", "package.json"); + } else { + const source = readFileSync(appFile, "utf8"); + assert.equal(source.split("revision: runningRevision,").length, 2); + writeFileSync(appFile, source.replace("revision: runningRevision,", 'revision: "lifecycle-intentionally-unready",')); + git("add", "src/web/app.js"); + } + git("commit", "-m", `fixture: intentional ${failure} failure`); + const candidate = git("rev-parse", "HEAD"); + // Only a new local fixture bare repository is written; no public remote is ever pushed. + git("push", "--force", "origin", "HEAD:lifecycle-update-fixture"); + git("reset", "--hard", baseline); + const result = spawnSync(process.execPath, ["scripts/update-runner.mjs"], { + cwd: repo, env: process.env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + timeout: 12 * 60_000, maxBuffer: 4_000_000, + }); + const state = JSON.parse(readFileSync(`${root}/update-state.json`)); + // Only the fixed fixture/status fields are evidence; full authenticated health and runtime + // snapshots remain private inside the disposable VM. + console.log(JSON.stringify({ failure, updaterExit: result.status, result: state.result, + phase: state.phase, oldRevision: state.oldRevision, targetRevision: state.targetRevision, + runningRevision: state.runningRevision, candidateError: state.candidateError, rollbackError: state.rollbackError })); + assert.equal(result.error, undefined); + assert.equal(result.status, 2); + assert.equal(state.result, "rolled_back"); + assert.equal(state.oldRevision, baseline); + assert.equal(state.targetRevision, candidate); + assert.equal(state.runningRevision, baseline); + assert.equal(git("rev-parse", "HEAD"), baseline); + if (failure === "test") assert.match(state.candidateError, /npm test.*42/); + else assert.ok(state.candidateError.includes( + `gateway is running revision lifecycle-intentionally-unready instead of ${candidate}`), + "readiness failure must prove the candidate actually answered with the intended wrong revision"); + assert.equal(existsSync(`${root}/update-backups/${state.id}/gateway.db`), true); + assert.equal(JSON.parse(readFileSync(`${root}/update-backups/${state.id}/config/lifecycle-proof.json`)).value, "before-backup"); + const snapshot = new DatabaseSync(`${root}/update-backups/${state.id}/gateway.db`, { readOnly: true }); + assert.equal(snapshot.prepare("SELECT value FROM lifecycle_proof").get().value, "before-backup"); + assert.equal(snapshot.prepare("PRAGMA integrity_check").get().integrity_check, "ok"); + snapshot.close(); + assert.equal(existsSync(`${root}/update.lock`), false); + const db = new DatabaseSync(`${root}/gateway.db`, { readOnly: true }); + assert.equal(db.prepare("SELECT value FROM lifecycle_proof").get().value, "before-backup"); + db.close(); + before = await health(baseline, before.instanceId); + console.log(`PASS real ${failure} failure rolls back Git and restarts systemd; engine smoke is a controlled fixture`); +} +git("checkout", "--detach", original); +restart(); +await health(original, before.instanceId); +console.log("PASS original candidate restored after isolated updater fixture checks"); diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh index 419384d..b1e9b41 100755 --- a/scripts/install-systemd.sh +++ b/scripts/install-systemd.sh @@ -117,11 +117,16 @@ fi # Probe and build in the final daemon identity's own rootless store. The installer's store and # image UID cannot be reused by a different service account. -run_as_service() { - runuser -u "$SERVICE_USER" -- env HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ +run_as_service() ( + # Match the unit's explicit environment. Inherited operator XDG/container-storage settings + # can point Podman at another user's private store even after HOME changes. + # Podman also re-enters cwd after its user-namespace transition; the invoking shell may be + # in an operator-private directory even when the installer itself lives under /opt. + cd "$APP_DIR" + runuser -u "$SERVICE_USER" -- env -i HOME="$SERVICE_HOME" CHANNELGATE_DIR="$SERVICE_HOME" \ XDG_RUNTIME_DIR="$SERVICE_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="unix:path=$SERVICE_RUNTIME_DIR/bus" \ PATH="$SERVICE_PATH" "$@" -} +) run_as_service podman info --format '{{.Host.Security.Rootless}}' | grep -qx true || { echo "Rootless Podman is not usable as $SERVICE_USER"; exit 1; } diff --git a/scripts/reviewed-artifact-fixtures.json b/scripts/reviewed-artifact-fixtures.json new file mode 100644 index 0000000..ce0a013 --- /dev/null +++ b/scripts/reviewed-artifact-fixtures.json @@ -0,0 +1,147 @@ +[ + { + "pattern": "Private key block", + "sha256": "fb278edfcf8e17967dd3a1ac5bfb5467e24c366a94e7efcee521dc750effa9ef", + "description": "Public Supabase local Kong development TLS key", + "sources": [ + "https://github.com/supabase/cli/blob/v2.116.0/apps/cli-go/pkg/config/templates/certs/kong.local.key", + "https://github.com/supabase/cli/blob/v2.116.0/apps/cli/src/legacy/commands/start/templates/kong-local-tls.ts" + ] + }, + { + "pattern": "Private key block", + "sha256": "fed7079dd4491609e4c996e232f366ac434fa4cc9df19df363391d079d470964", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "5a09eb5df3674472eda0077d83cbccef72692c3d052ab393368ac57295439c58", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "a4d138d7ef9748464117b44fb9c0a4b5b85a1599a127d02690abaa96d03c16e6", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "fa0b06a72461ec0a963dcfccb8d5b61bd88a6074fc7271573bff68ab86b8c1af", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "ef237ea8db4f2ae9ee100e8ced96d29b5dceb0e6a948443e6b8a00b1791f9ec9", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "91ea1699ff6b1a34b4a1d500a9c75a808441e47b9ea68da6fb0195e01ce1dc61", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "7c4c63ee462e0e700cd9e29c8e0f730b3f1b484c4abdd83f1e69fcd477c061fa", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "Private key block", + "sha256": "d039c8119a029ab9f9c83c04d67002d887b6bc6026c4264402ab27cdf24cf138", + "description": "Public GnuTLS cryptographic self-test key", + "sources": [ + "https://github.com/gnutls/gnutls/blob/12919dc7db736467987a3254a452b1d10e20b79e/lib/crypto-selftests-pk.c" + ] + }, + { + "pattern": "GitHub token", + "sha256": "1f06658ad88821bce08bd420b28bd928ca24fa71f6906c00905fbba0b24877c3", + "description": "Compiler-adjacent string data following a four-byte token-prefix constant; not a complete credential constant", + "sources": [ + "https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/network-proxy/src/credential_broker/providers/github.rs#L14", + "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-x64.tgz" + ], + "upstreamArchiveSha256": "54818cb9fce3360cc6e44cfc5a96952cd5c1243efb43cbe488e11dda84663e08", + "upstreamMember": "vendor/x86_64-unknown-linux-musl/bin/codex", + "upstreamMemberSha256": "56ef98ab4032d317ab26e9b5e5a175650717351edb16ed9cde0cb6d1734d62da" + }, + { + "pattern": "GitHub token", + "sha256": "902a57265300171f27289680dac688d3c2354f0893c50f89ec0522e0e123c3fe", + "description": "Compiler-adjacent string data following a four-byte token-prefix constant; not a complete credential constant", + "sources": [ + "https://github.com/cli/cli/blob/v2.100.0/pkg/cmd/auth/status/status.go#L339", + "https://github.com/cli/cli/releases/download/v2.100.0/gh_2.100.0_linux_amd64.tar.gz" + ], + "upstreamArchiveSha256": "e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be", + "upstreamMember": "bin/gh", + "upstreamMemberSha256": "553949e2efa12842771efe6012aa4de21f1d591530ec17fc435f610f10e017ee" + }, + { + "pattern": "Google API key", + "sha256": "1b3835891a6cecc54d6d28a1b3e1513cc001ad7bddbb1c1521c36f7763e1ffc5", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + }, + { + "pattern": "Google API key", + "sha256": "34d464f64a851dff50696150faadf8b8f09acc1699317da6a4985119be6d11dc", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + }, + { + "pattern": "Google API key", + "sha256": "98c0108cb5187b9e0d2fe2b058fff45e3af45ddfe72b3e6df59ab1fafa8bd722", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + }, + { + "pattern": "Google API key", + "sha256": "76d0a2783815b7cc504688d7507d922871d8bd77ed7fdaa9af78340b8549ecf1", + "description": "Vendor-distributed Chrome for Testing 153.0.8010.12 browser API configuration; not a deployment credential or a dummy key", + "sources": [ + "https://storage.googleapis.com/chrome-for-testing-public/153.0.8010.12/linux64/chrome-linux64.zip", + "https://chromium.googlesource.com/chromium/src/+/main/docs/api_keys.md" + ], + "upstreamArchiveSha256": "8aac35011c18f6e2d10696154af89a5728ac2ddd6dc6fad24ffdf243c3fcfd5a", + "upstreamMember": "chrome-linux64/chrome", + "upstreamMemberSha256": "8c599d43aec53f2460a31ae2f4af6bd863f8258b34ff519564bc5d4726bfaa1e" + } +] diff --git a/scripts/secret-scan.mjs b/scripts/secret-scan.mjs index ce77997..5bb0047 100644 --- a/scripts/secret-scan.mjs +++ b/scripts/secret-scan.mjs @@ -5,8 +5,9 @@ // like `xoxb-…`, `sk-ant-x`, or test fixtures ("xoxb-test") never trip it — a noisy scanner gets // ignored, a quiet one gets trusted. import { execFileSync, spawn } from "node:child_process"; -import { createReadStream, lstatSync, readdirSync, readlinkSync } from "node:fs"; +import { createReadStream, lstatSync, readdirSync, readlinkSync, readFileSync } from "node:fs"; import { createGunzip } from "node:zlib"; +import { createHash } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -32,6 +33,14 @@ const PATTERNS = [ { name: "Private key block", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----\s+[A-Za-z0-9+/=]{60,}/g }, ]; +// Exceptions identify complete reviewed public values, never paths, key prefixes or patterns. +// They apply ONLY to generated artifacts; repository files and history always stay strict. +const reviewedFixtures = JSON.parse(readFileSync(new URL("./reviewed-artifact-fixtures.json", import.meta.url), "utf8")); +const reviewedHashes = new Set(reviewedFixtures.map(({ pattern, sha256 }) => `${pattern}:${sha256}`)); +const LOOKAHEAD = 64 * 1024; // Larger/truncated PEMs remain findings; never waive a partial key. +const COMPLETE_PEM = /^-----BEGIN ([A-Z ]*PRIVATE KEY)-----[ \t]*\r?\n(?:[A-Za-z0-9+/=]+[ \t]*\r?\n)+-----END \1-----/; +const digest = (value) => createHash("sha256").update(value, "latin1").digest("hex"); + // Never exempt lockfiles, assets or large blobs: credentials can appear in any of them. // History means commits reachable from this release candidate, not the private archive remote. const args = process.argv.slice(2); @@ -40,32 +49,56 @@ const artifactDir = artifactsAt >= 0 ? args[artifactsAt + 1] : null; if (artifactsAt >= 0 && !artifactDir) throw new Error("--artifacts requires a directory"); let findings = 0; let checked = 0; -function scan(content, label, overlap = 0) { +let reviewed = 0; +function scan(content, label, limit = content.length, artifact = false, start = 0) { for (const { name, re } of PATTERNS) { re.lastIndex = 0; let match; while ((match = re.exec(content)) !== null) { - if (match.index + match[0].length <= overlap) continue; + if (match.index >= limit) break; + if (match.index < start) continue; + // A PEM prefix matches the detection pattern, but only a COMPLETE key can be reviewed. + // The same first base64 line with a different body must always remain a finding. + const complete = name === "Private key block" + ? COMPLETE_PEM.exec(content.slice(match.index, match.index + LOOKAHEAD))?.[0] + : match[0]; + const sha256 = digest(complete || match[0]); + if (artifact && complete && reviewedHashes.has(`${name}:${sha256}`)) { + reviewed += 1; + continue; + } findings += 1; - if (findings <= 100) console.error(`SECRET? ${safeLabel(label)} matches "${name}"`); + if (findings <= 100) console.error(`SECRET? ${safeLabel(label)} matches "${name}" sha256=${sha256}`); } } } +async function scanStream(stream, label, artifact = false) { + // Keep forward context, not just an overlap of already-scanned bytes: a complete public PEM + // must be available before deciding whether to waive its header, including at chunk edges. + let pending = ""; + let start = 0; + for await (const chunk of stream) { + pending += chunk.toString("latin1"); + const limit = Math.max(0, pending.length - LOOKAHEAD); + if (limit) { + scan(pending, label, limit, artifact, start); + // Retain one already-scanned byte so word-boundary patterns see their real left context. + pending = pending.slice(limit - 1); + start = 1; + } + } + scan(pending, label, pending.length, artifact, start); +} function safeLabel(label) { let value = String(label); for (const { re } of PATTERNS) value = value.replace(new RegExp(re.source, re.flags), "[redacted]"); return value; } -async function scanFile(file, label) { +async function scanFile(file, label, artifact = false) { // Streaming keeps image archives and other large release assets bounded in memory. let stream = createReadStream(file); if (file.endsWith(".gz")) stream = stream.pipe(createGunzip()); - let tail = ""; - for await (const chunk of stream) { - const content = tail + chunk.toString("latin1"); - scan(content, label, tail.length); - tail = content.slice(-2048); - } + await scanStream(stream, label, artifact); checked += 1; } const files = execFileSync("git", ["ls-files", "-z"], { encoding: "utf8", cwd: repoRoot }).split("\0").filter(Boolean); @@ -95,8 +128,7 @@ if (args.includes("--history")) { if (!["blob", "commit"].includes(type)) continue; const child = spawn("git", ["cat-file", type, id], { cwd: repoRoot, stdio: ["ignore", "pipe", "inherit"] }); const completion = new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`git cat-file failed (${code})`))); }); - let tail = ""; - for await (const chunk of child.stdout) { const content = tail + chunk.toString("latin1"); scan(content, `history:${id.slice(0, 12)}:${names.get(id)}`, tail.length); tail = content.slice(-2048); } + await scanStream(child.stdout, `history:${id.slice(0, 12)}:${names.get(id)}`); await completion; checked += 1; } @@ -105,7 +137,7 @@ async function walk(dir) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const file = path.join(dir, entry.name); if (entry.isDirectory()) await walk(file); - else if (entry.isFile()) await scanFile(file, `artifact:${path.relative(repoRoot, file)}`); + else if (entry.isFile()) await scanFile(file, `artifact:${path.relative(repoRoot, file)}`, true); else if (entry.isSymbolicLink()) throw new Error(`Release artifact must not be a symlink: ${entry.name}`); } } @@ -113,4 +145,4 @@ if (artifactDir) await walk(path.resolve(repoRoot, artifactDir)); if (findings) { console.error(`Secret scan failed: ${findings} finding(s). Values are never printed. Review the reported objects before publication.`); process.exitCode = 1; -} else console.log(`Secret scan clean (${checked} files/blobs checked${args.includes("--history") ? ", including candidate history" : ""}).`); +} else console.log(`Secret scan clean (${checked} files/blobs checked${args.includes("--history") ? ", including candidate history" : ""}; ${reviewed} exact reviewed public artifact fixtures).`); diff --git a/test/operations-readiness.test.js b/test/operations-readiness.test.js index 9d311c0..85bf1fd 100644 --- a/test/operations-readiness.test.js +++ b/test/operations-readiness.test.js @@ -138,6 +138,10 @@ test("service packages pin dedicated identities and hardened runtime boundaries" assert.match(systemd, /Delegate=yes/); assert.match(systemd, /--add-subids-for-system/); assert.match(systemd, /run_as_service "\$NODE_BIN"/); + assert.match(systemd, /runuser -u "\$SERVICE_USER" -- env -i HOME=/, + "image provisioning must not inherit an operator's XDG/container storage configuration"); + assert.match(systemd, /run_as_service\(\) \([\s\S]*?cd "\$APP_DIR"[\s\S]*?runuser/, + "Podman must start from the service-owned checkout, not the caller's private cwd"); assert.match(systemd, /Requires=user@\$SERVICE_UID\.service/); assert.match(systemd, /After=network-online.target user@\$SERVICE_UID\.service/); assert.ok(systemd.indexOf('scripts/service-path-preflight.mjs') < systemd.indexOf('useradd --system')); diff --git a/test/release-artifact-fixtures.test.js b/test/release-artifact-fixtures.test.js new file mode 100644 index 0000000..946950a --- /dev/null +++ b/test/release-artifact-fixtures.test.js @@ -0,0 +1,87 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { gzipSync } from "node:zlib"; +import { execFileSync, spawnSync } from "node:child_process"; +import path from "node:path"; +import { tempDir } from "./helpers.js"; + +const sha256 = (value) => createHash("sha256").update(value).digest("hex"); +const token = "gh" + "p_" + "A".repeat(36); +const key = ["-----BEGIN PRIVATE KEY-----", "A".repeat(64), "B".repeat(64), "-----END PRIVATE KEY-----"].join("\n"); +function fixture() { + const root = tempDir("cg-artifact-fixtures-"); + mkdirSync(path.join(root, "scripts")); + mkdirSync(path.join(root, "artifacts")); + copyFileSync(new URL("../scripts/secret-scan.mjs", import.meta.url), path.join(root, "scripts/secret-scan.mjs")); + writeFileSync(path.join(root, "scripts/reviewed-artifact-fixtures.json"), JSON.stringify([ + { pattern: "GitHub token", sha256: sha256(token) }, + { pattern: "Private key block", sha256: sha256(key) }, + ])); + const git = (...args) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", ...args], { cwd: root, stdio: "pipe" }); + git("init"); git("add", "scripts"); git("commit", "-m", "safe scanner fixture"); + const run = (...args) => spawnSync(process.execPath, ["scripts/secret-scan.mjs", ...args], { cwd: root, encoding: "utf8" }); + return { root, git, run }; +} + +test("reviewed public fixtures are waived only in artifacts, never tracked files or history", () => { + const { root, git, run } = fixture(); + const contents = `${token}\n${key}\n`; + writeFileSync(path.join(root, "artifacts/public.bin"), contents); + let result = run("--artifacts", "artifacts"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /2 exact reviewed public artifact fixtures/); + writeFileSync(path.join(root, "source.txt"), contents); git("add", "source.txt"); + result = run("--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.match(result.stderr, /source.txt/); + assert.ok(!result.stderr.includes(token)); + assert.ok(!result.stderr.includes("A".repeat(64))); + git("commit", "-m", "fixture containing credential-shaped source"); + unlinkSync(path.join(root, "source.txt")); git("add", "source.txt"); git("commit", "-m", "remove fixture"); + result = run("--history", "--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.match(result.stderr, /history:/); + assert.equal((result.stderr.match(/SECRET\?/g) || []).length, 2); +}); + +test("same-prefix changed PEM, incomplete PEM and unknown token cannot inherit a public waiver", () => { + const { root, run } = fixture(); + const changed = key.replace("B".repeat(64), "C".repeat(64)); + const incomplete = key.slice(0, key.indexOf("-----END")); + const unknownToken = token.slice(0, -1) + "Z"; + writeFileSync(path.join(root, "artifacts/unknown.bin"), `${changed}\n${incomplete}\n${unknownToken}\n`); + const result = run("--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.equal((result.stderr.match(/SECRET\?/g) || []).length, 3, result.stderr); + assert.ok(!result.stderr.includes(unknownToken)); + assert.ok(!result.stderr.includes("C".repeat(64))); +}); + +test("gzip streams preserve full-key decisions and one finding across read/lookahead boundaries", () => { + const { root, run } = fixture(); + // The token crosses the 64 KiB read boundary. The PEM's known first line is in a previous + // chunk to its changed body/end: waiving its prefix before reading that body would be unsafe. + const changed = key.replace("B".repeat(64), "D".repeat(64)); + const contents = " ".repeat(65536 - 10) + token + "\n" + " ".repeat(65536 - token.length - 40) + changed + "\n" + key; + writeFileSync(path.join(root, "artifacts/boundary.bin.gz"), gzipSync(contents)); + let result = run("--artifacts", "artifacts"); + assert.equal(result.status, 1); + assert.equal((result.stderr.match(/SECRET\?/g) || []).length, 1, result.stderr); + writeFileSync(path.join(root, "artifacts/boundary.bin.gz"), gzipSync(contents.replace(changed, key))); + result = run("--artifacts", "artifacts"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /3 exact reviewed public artifact fixtures/); +}); + + +test("word-boundary detection retains the byte before a stream cut", () => { + const { root, run } = fixture(); + const googleShaped = "AI" + "za" + "A".repeat(35); + // An adjoining word character means this is not a Google-key pattern, including when the + // scanner advances its retained window immediately before the key-shaped substring. + writeFileSync(path.join(root, "artifacts/word-boundary.bin"), " ".repeat(65535) + "x" + googleShaped + " ".repeat(131072)); + const result = run("--artifacts", "artifacts"); + assert.equal(result.status, 0, result.stderr); +}); diff --git a/test/release-secret-history.test.js b/test/release-secret-history.test.js index f22c54b..6c934c0 100644 --- a/test/release-secret-history.test.js +++ b/test/release-secret-history.test.js @@ -9,6 +9,7 @@ test("release scanning covers commit and candidate tag messages without echoing const root = tempDir("cg-scan-history-"); mkdirSync(path.join(root, "scripts")); copyFileSync(new URL("../scripts/secret-scan.mjs", import.meta.url), path.join(root, "scripts/secret-scan.mjs")); + copyFileSync(new URL("../scripts/reviewed-artifact-fixtures.json", import.meta.url), path.join(root, "scripts/reviewed-artifact-fixtures.json")); const git = (...args) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", ...args], { cwd: root, stdio: "pipe" }); git("init"); writeFileSync(path.join(root, "file.txt"), "safe fixture\n"); git("add", "file.txt"); const fake = "gh" + "p_" + "A".repeat(36);