Skip to content

fix(ci): sandbox the DoR build agents from untrusted issue input and tokens; tidy CI permissions and deps (SEC-2026-09 H-05, L-19, I-01) - #1194

Open
WimvandenHeijkant wants to merge 6 commits into
mainfrom
bugfixes/sec-ci-dor-supply-chain
Open

fix(ci): sandbox the DoR build agents from untrusted issue input and tokens; tidy CI permissions and deps (SEC-2026-09 H-05, L-19, I-01)#1194
WimvandenHeijkant wants to merge 6 commits into
mainfrom
bugfixes/sec-ci-dor-supply-chain

Conversation

@WimvandenHeijkant

@WimvandenHeijkant WimvandenHeijkant commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Security fixes from the SEC-2026-09 review for the CI and "Definition of Ready" (DoR) build automation, plus dependency tidying:

  • H-05: the DoR build agent (an LLM with a shell on self-hosted sidekicks) no longer reads comments from untrusted accounts. Its process gets no GitHub credentials, and anything it produces that changes .github/ is stopped before push.
  • L-19 (CI part): pr.yml and pr-integration.yml now default GITHUB_TOKEN to contents: read. The unused deploy-site.yml is removed.
  • I-01: uuid is overridden to a patched version for exceljs, and the unused patch-package and pg-copy-streams dependencies are removed.

Findings addressed

ID Severity What changed
H-05 (untrusted input) High New .github/scripts/dor_trusted_spec.sh builds spec.json from trusted authors only: the requestor of record, org members (checked with a members:read app token, the same grant dor-authorize uses), and the pipeline bot identities (REST type == "Bot" plus an exact [bot] login). All other comments are dropped and counted in a trust block. If the author is untrusted (a vouched external request), the issue title and body are withheld too. Membership lookup errors fail closed. The value-gate policy job reads the repro contract through the same filter, so a look-alike contract from an outside account cannot skip the human gate. The approval notice only links a bot-authored certified spec. Acceptance FEEDBACK was already gated on the commenter's org membership (actor: github.actor); I verified that and left it unchanged.
H-05 (token exposure) High run_claude starts the CLI through agent_exec, which strips GH_*, GITHUB_TOKEN, BOARD_TOKEN, ACTIONS_* and *_TOKEN/_SECRET/_PASSWORD/_PRIVATE_KEY/_API_KEY/_PAT from its environment. CLAUDE_CODE_OAUTH_TOKEN is kept because the CLI needs it. The build and feedback workflows now stage GH_TOKEN/BOARD_TOKEN as 0600 files that the flow loads and deletes before any agent runs, so the tokens are also absent from the flow process's own environment (which a child could read from /proc). use_bot_remote no longer writes the app token into the origin URL: origin stays anonymous (the repo is public) and push_as_app passes the token per command. After every agent run the flow restores .git/config from an in-memory snapshot and clears .git/hooks. Its own git ignores global/system config and hooks (GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM, core.hooksPath/core.fsmonitor overridden via GIT_CONFIG_COUNT). actions/checkout already used persist-credentials: false, and drop_checkout_credentials is kept.
H-05 (output guard) High guard_protected_paths runs before every push (initial build, CI auto-fix, feedback adjustment, usage-limit pause). It re-fetches main and bails to Exceptions when origin/main...HEAD touches .github/. That includes commits the agent made itself, which the old restore-from-HEAD could not undo. push_as_app refuses the same case as a backstop, and fails closed when there is no main to compare with. Dockerfile, compose and package.json dependency/override/install-script changes are flagged but not blocked: in the PR body when the PR opens, and as a PR comment if the set changes later.
L-19 (CI part) Low Top-level permissions: contents: read in pr.yml and pr-integration.yml. The filter jobs add pull-requests: read because dorny/paths-filter lists PR files through the API. I read every job in both files: none posts comments, check annotations or SARIF, and artifacts plus the buildx gha cache use the runner's runtime token. deploy-site.yml is deleted. Nothing referenced it except site/README.md, which is updated.
I-01 Info "uuid": "^11.1.1" added to overrides in app/api and app/ui; both lockfiles now resolve uuid@11.1.1. patch-package postinstall and devDependency removed from app/api (there is no patches/ dir). pg-copy-streams removed: nothing imports it, and engine.js already documented why COPY is not used. The SBOM page, About page and an engine comment still named it and are updated. npm audit --omit=dev: 0 vulnerabilities in both packages.

Not addressed / deferred

  • L-19 Azure part (Key Vault / Storage / App Service network defaults) belongs to PR 7 (bugfixes/sec-deployment-hardening).
  • Spec-side agent input (dor-agent.yml / dor-bug-agent.yml) still reads the whole thread. That is deliberate: the interview flow has to hear external requesters, and that agent is already sandboxed (Read/Grep/Glob plus Edit(.dor/out/**), no shell, no GitHub token). Its output reaches the build agent only as the certified comment, which a human value gate reviews for features. For autonomous bugs, the build-side guards above apply.
  • Same-user execution. The agent still runs as the runner user, which is in the docker group and has open egress. A deliberately malicious process can still act with that user's rights: tamper with ~/.local/bin, read runner files, or start containers. This PR removes every passive route to the tokens and blocks the unauthenticated trigger. The actual boundary needs work on the sidekick itself (see Manual follow-ups). docs/process/dor-sidekick-setup.md now states this plainly.

Compatibility

  • DoR flows keep working as before. dor_build_lib.sh still accepts GH_TOKEN/BOARD_TOKEN from the environment when DOR_CRED_DIR is unset (direct runs, tests). Pushes still authenticate as the app, per command, as push_as_app already did. Fetches were already anonymous-capable, since the repo is public.
  • Pause now uses push_as_app. pause_and_exit previously pushed via the token-bearing origin. The anonymous origin would have broken that, so it now uses push_as_app, same identity.
  • Global git config is ignored by the flow's git. The flow already pins its commit identity in the checkout's local config, so nothing on the sidekicks should depend on global config. Worth watching on the first build after merge.
  • Vouched external requests. If the issue author is not a member, spec.json withholds the title and body and the build works from the certified spec comment. Comments and the certified spec from the requestor of record (the voucher) and other members are unaffected.
  • Old certified specs. A certified spec posted under a maintainer's personal account (older pipeline runs) is still trusted, because that maintainer is an org member. For the policy job, a contract the filter drops just means "no contract", which falls back to the human value gate (fail-safe).
  • Dependencies. Existing deployments are unaffected: uuid 11 keeps the v4() export exceljs uses (CJS and ESM). The two removed dependencies were not loaded at runtime.

Tests

  • New test/ci-scripts/test-dor-trusted-spec.sh (26 assertions), wired into the ci-scripts PR job. Discriminating inputs: a User account named like the bot without [bot], a foreign Bot account, a member whose check must use the Members:read token (the stub refuses any other token), a membership API error, a repeat commenter (looked up once), an outsider-authored issue, and an unreadable comment list (the step must fail).
  • New test/ci-scripts/test-dor-agent-sandbox.sh (41 assertions), wired into ci-scripts, using real git on local repos with stub claude/gh. It checks that:
    • staged token files are loaded and deleted;
    • the agent's environment lacks every GitHub credential but keeps CLAUDE_CODE_OAUTH_TOKEN;
    • origin holds no token;
    • a planted url.insteadOf and hook are undone, and hooks never run under the flow's git;
    • a committed .github/ change is refused by the guard and by push_as_app, including after moving origin/main onto it, and fails closed without main;
    • a pause with a protected change becomes an Exception and leaves no pause marker;
    • Dockerfile, compose, dependency-bump and new-postinstall changes are flagged while a description edit is not, flags never block a push, and an unchanged flag set is not re-posted.
  • Against the old code, these assertions fail: the agent env contains GH_TOKEN/BOARD_TOKEN, origin holds the token, a planted hook runs, and a .github/ commit is pushed.
  • test/ci-scripts/test-dor-push-lease.sh updated: the fixture has a main, and it now asserts that use_bot_remote leaves origin tokenless (19 passed).
  • All 10 ci-scripts self-tests pass locally (Git Bash, jq 1.7.1). shellcheck -S warning: new scripts clean, touched scripts unchanged (7 pre-existing findings before and after). actionlint on the four edited workflows: only the pre-existing custom dor-build runner-label notice.
  • app/api: npx vitest run 233 files / 3025 passed; npm run lint 0 errors (5 pre-existing warnings). Exporter test excelWorkbook.test.js 17 passed. A real exceljs xlsx write succeeds with uuid@11.1.1.
  • app/ui: npx vitest run 199 files / 1890 passed; npm run lint 0 errors (4 pre-existing warnings). Exporter tests (exportToExcel, exportAccessPackagesToExcel, MatrixView.mount) 52 passed.
  • CI flake fixed along the way: the integration suite failed three runs in a row because the OData mock server gave its background job only 4 s to start while every crawler test starts at once. The same failure appeared on another branch that day, and this PR changes no PowerShell. The OData mock now uses the shared Start-MockServerJob helper, which waits up to 30 s and still returns as soon as the server reports. New test/unit/MockServerJob.Tests.ps1 (3 tests) includes a 5 s start-up case that fails against the old window.

Manual follow-ups

  • GitHub ruleset: remove the BOT GitHub App from the main ruleset bypass list. Give bump-version.yml (and any other workflow that pushes main) its own narrowly-scoped identity instead of the app the DoR build agent's flow uses.
  • Sidekicks: run the build agent in a disposable container, or as a separate user without docker group membership, with an egress allow-list (Anthropic API, npm registry, GitHub). Never give that environment the runner's credentials or home directory.
  • Secrets: consider rotating CLAUDE_CODE_OAUTH_TOKEN, since it was reachable by the agent until now and still is by design. Delete the now-unused AZURE_STATIC_WEB_APPS_API_TOKEN repository secret, and the Static Web App deployment token in Azure if one exists.

🤖 Generated with Claude Code

WimvandenHeijkant and others added 4 commits September 13, 2026 15:54
…-09 H-05)

The build agent has a shell, and its spec used to be the whole issue thread
of a public repository. dor_trusted_spec.sh keeps the issue body and comments
by the requestor of record, org members (checked with a Members:read token)
and the pipeline's own bot identities, drops everything else, and records how
much was left out. A membership lookup that errors counts as untrusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guard its output (SEC-2026-09 H-05)

- The claude CLI starts with every GitHub credential removed from its
  environment; CLAUDE_CODE_OAUTH_TOKEN stays because the CLI needs it.
- The workflows stage GH_TOKEN/BOARD_TOKEN as files the flow loads and deletes,
  so they are not in the flow's own process environment either.
- origin is no longer rewritten to a token-bearing URL; pushes pass the app
  token per command only.
- After each agent run the checkout's git config and hooks are restored, and
  the flow's git ignores global/system config and hooks.
- A branch that changes .github/ is refused before any push (Exceptions);
  Dockerfile, compose and package.json dependency changes are flagged on the PR.
- The value-gate policy reads the repro contract through the trusted filter,
  and the approval notice only links a bot-authored certified spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d site deploy workflow (SEC-2026-09 L-19)

pr.yml and pr-integration.yml now default GITHUB_TOKEN to contents: read; only
the path-filter jobs add pull-requests: read. The new DoR self-tests run in the
ci-scripts job. deploy-site.yml was never enabled, used tag-pinned actions and
referenced a deployment secret; the site is self-hosted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-copy-streams (SEC-2026-09 I-01)

- overrides uuid ^11.1.1 in app/api and app/ui (exceljs 4.4.0 pulled uuid 8);
  exporter tests and a real xlsx write pass with it. npm audit --omit=dev: 0.
- remove the vestigial patch-package postinstall and devDependency (there is
  no patches/ directory) and the unused pg-copy-streams dependency, with the
  SBOM page, About page and ingest engine comment that still named it.
- changelog fragment for this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WimvandenHeijkant and others added 2 commits September 13, 2026 16:02
A long thread (a real one: 30 comments) exceeds the kernel's per-argument
limit when handed to jq --argjson, which would have failed the build step.
The filter now works on temp files; a ~300 KB comment is covered by a test
that fails against the argv version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… CI runners

The integration suite starts every crawler's tests at once, and each mock
server is a fresh pwsh job process. The OData mock gave that job 4 s and
failed with an empty output on a loaded runner (three runs in a row on this
PR, and on another branch the same day). The OData mock now uses the shared
Start-MockServerJob helper like the SCIM and midPoint mocks, and the helper
waits up to 30 s; a server that starts still returns as soon as it reports.
A Pester test with a 5 s start-up fails against the old window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant