This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/scripts/assert_no_write_token.py

319 lines
13 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""assert_no_write_token.py — fail closed if a GitHub *write* token is on the box.
The agent-team apply/verify path mints its ``pull-requests: write`` GitHub App
installation token **inside the CI runner** (via SHA-pinned
``actions/create-github-app-token``), from the ``AGENT_APPLY_APP_ID`` /
``AGENT_APPLY_APP_PRIVATE_KEY`` **Actions secrets**. By design the always-on
R720 box holds **no standing write credential**: it triggers CI with the
operator's host ``gh`` auth and reads results with a *read-only* token
(``AGENT_TEAM_CI_READ_TOKEN`` / ``GITHUB_TOKEN``). See ``ci/README.md`` and
``docs/P3-PHASE0-DESIGN.md`` ("the box holds no standing write token").
This audit asserts that invariant. It is runnable both on the box (as a
provisioning/runtime self-check) and in CI (as a regression guard). It:
1. scans the live process environment (``os.environ``) and the coordinator's
environment-derived config for token-shaped variables that would grant
``pull-requests: write`` / ``contents: write``;
2. greps the box's local credential files (``~/secrev.env`` and
``~/orchestrator/.env`` by default; paths are configurable) for the App id
and for any PEM private-key header (RSA / EC / OPENSSH / PKCS#8);
and **exits non-zero with a clear message** if any are found, or exits ``0``
with a short summary otherwise.
Usage::
python -m scripts.assert_no_write_token
python scripts/assert_no_write_token.py --env-file ~/secrev.env --env-file ~/x.env
Exit codes:
0 no write-shaped token / App secret / private key found
1 at least one finding (the box is mis-provisioned — remediate before deploy)
"""
from __future__ import annotations
import argparse
import os
import re
import sys
from collections.abc import Mapping, Sequence
from pathlib import Path
# --------------------------------------------------------------------------- #
# What "must never live on the box" looks like.
# --------------------------------------------------------------------------- #
# The GitHub App that holds ``pull-requests: write`` lives ONLY as Actions
# secrets. Its id and private key must never appear on the box (env or files).
APP_ID_ENV = "AGENT_APPLY_APP_ID"
APP_PRIVATE_KEY_ENV = "AGENT_APPLY_APP_PRIVATE_KEY"
# Env vars that are explicitly the App's write credentials.
_FORBIDDEN_ENV_VARS: frozenset[str] = frozenset(
{
APP_ID_ENV,
APP_PRIVATE_KEY_ENV,
}
)
# Env vars that are KNOWN-GOOD read-only / non-write and must NOT be flagged by
# the heuristic name match below (they are tokens, but read-only by contract).
_ALLOWED_TOKEN_ENV_VARS: frozenset[str] = frozenset(
{
"AGENT_TEAM_CI_READ_TOKEN", # read-only CI-result fetcher token
"AGENT_TEAM_API_TOKEN", # read-only dashboard/API bearer
"SLACK_APP_TOKEN", # Slack socket-mode app-level token (not GitHub)
"SLACK_BOT_TOKEN", # Slack bot token (not GitHub)
"CLAUDE_CODE_OAUTH_TOKEN", # Anthropic subscription OAuth (not GitHub)
"ANTHROPIC_API_KEY", # model API key (not GitHub)
}
)
# A name-shaped heuristic for "this looks like a GitHub *write* token". We
# deliberately scope to GitHub-write shapes so the generic read-only
# ``GITHUB_TOKEN`` fallback (a runtime read token, not a standing write secret)
# is not a false positive while the App's write material always is.
_WRITE_TOKEN_NAME_RE = re.compile(
r"(?:^|_)(?:GH|GITHUB)_(?:APP|PAT|WRITE|APPLY)_?(?:TOKEN|KEY|PRIVATE_KEY)?",
re.IGNORECASE,
)
# Value shapes that indicate a GitHub write-capable credential regardless of the
# var's name. ``ghp_`` (classic PAT) and ``github_pat_`` (fine-grained PAT) can
# both carry write scopes; an installation token (``ghs_``) is write-capable; a
# user-to-server token (``ghu_``) acts with the user's write access; and a refresh
# token (``ghr_``) mints fresh write-capable user-to-server tokens. All are
# write-risk material that must not live on the box.
_WRITE_TOKEN_VALUE_RE = re.compile(
r"\b(?:ghp_|ghs_|ghu_|ghr_|github_pat_)[A-Za-z0-9_]{20,}\b"
)
# Any PEM private-key header (RSA / EC / OPENSSH / generic PKCS#8). The App's
# private key is a PEM block; finding ANY private key in a box credential file
# is a finding.
_PRIVATE_KEY_RE = re.compile(r"-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----")
# Default box credential files to grep. Configurable via --env-file / the
# ``ASSERT_NO_WRITE_TOKEN_ENV_FILES`` env var so tests use temp files.
_DEFAULT_ENV_FILES: tuple[str, ...] = ("~/secrev.env", "~/orchestrator/.env")
# --------------------------------------------------------------------------- #
# Scanners. Each returns a list of human-readable finding strings.
# --------------------------------------------------------------------------- #
fix(agent-team): remediate C1 security-review BLOCK (2 HIGH + MED/LOW) High-recall /sh-security-review fan-out + proof-or-kill verifier found two confirmed HIGH; both now closed (verified empirically against the working tree): - LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead (verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so the max_build_loops park never fired -> a perpetually-failing task looped BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round). Threaded build_loops through durable PipelineState/TaskRecord; verifier reads state.get('build_loops',0), writes the incremented count back on each FAIL, and PARKS at max_build_loops. Parks after exactly N failures, never unbounded. - SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live App JWT to stdout in default dry-run and passed it as a gh argv literal. Added redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences. - SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the configured App-ID to env/config VALUES (not just files) — an App private key under a benign env name is caught. - SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback / value-scan; staged-only WARN on the live workflow revert. Suite: 1382 passed, ruff clean. Branch only; not merged/deployed. NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was uncommitted working-tree state); independently confirmed closed empirically.
2026-06-23 19:40:59 -04:00
def scan_environ(environ: Mapping[str, str], *, app_id: str | None = None) -> list[str]:
"""Scan a process environment for write-shaped GitHub token material.
Flags (a) the explicit App-credential env vars, (b) any var whose *name*
fix(agent-team): remediate C1 security-review BLOCK (2 HIGH + MED/LOW) High-recall /sh-security-review fan-out + proof-or-kill verifier found two confirmed HIGH; both now closed (verified empirically against the working tree): - LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead (verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so the max_build_loops park never fired -> a perpetually-failing task looped BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round). Threaded build_loops through durable PipelineState/TaskRecord; verifier reads state.get('build_loops',0), writes the incremented count back on each FAIL, and PARKS at max_build_loops. Parks after exactly N failures, never unbounded. - SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live App JWT to stdout in default dry-run and passed it as a gh argv literal. Added redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences. - SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the configured App-ID to env/config VALUES (not just files) — an App private key under a benign env name is caught. - SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback / value-scan; staged-only WARN on the live workflow revert. Suite: 1382 passed, ruff clean. Branch only; not merged/deployed. NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was uncommitted working-tree state); independently confirmed closed empirically.
2026-06-23 19:40:59 -04:00
matches the GitHub-write heuristic, (c) any var whose *value* carries a
write-capable GitHub token prefix, (d) any var whose *value* is a PEM
private-key block (the App key exported under a benign name), and (e) any var
whose *value* contains the configured App id. Known read-only tokens are
exempt from the *name* and write-token *value* heuristics, but the PEM and
App-id value checks below apply to EVERY var (including the allowlisted ones
and ``GITHUB_TOKEN``) — a private key or the App id can never legitimately
sit in any env var, so those checks are never name-exempted. This mirrors
:func:`scan_env_file`, which greps file contents for the same App-id /
private-key material regardless of the var name carrying it.
"""
findings: list[str] = []
for name, value in environ.items():
fix(agent-team): remediate C1 security-review BLOCK (2 HIGH + MED/LOW) High-recall /sh-security-review fan-out + proof-or-kill verifier found two confirmed HIGH; both now closed (verified empirically against the working tree): - LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead (verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so the max_build_loops park never fired -> a perpetually-failing task looped BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round). Threaded build_loops through durable PipelineState/TaskRecord; verifier reads state.get('build_loops',0), writes the incremented count back on each FAIL, and PARKS at max_build_loops. Parks after exactly N failures, never unbounded. - SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live App JWT to stdout in default dry-run and passed it as a gh argv literal. Added redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences. - SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the configured App-ID to env/config VALUES (not just files) — an App private key under a benign env name is caught. - SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback / value-scan; staged-only WARN on the live workflow revert. Suite: 1382 passed, ruff clean. Branch only; not merged/deployed. NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was uncommitted working-tree state); independently confirmed closed empirically.
2026-06-23 19:40:59 -04:00
# The PEM private-key VALUE check and the configured App-id VALUE check
# are NOT name-exempt: the App's private key exported under a benign name
# (e.g. GH_APP_KEY set to a "BEGIN ... PRIVATE KEY" PEM block) and the
# configured App id parked in any var are both forbidden material, even in an
fix(agent-team): remediate C1 security-review BLOCK (2 HIGH + MED/LOW) High-recall /sh-security-review fan-out + proof-or-kill verifier found two confirmed HIGH; both now closed (verified empirically against the working tree): - LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead (verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so the max_build_loops park never fired -> a perpetually-failing task looped BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round). Threaded build_loops through durable PipelineState/TaskRecord; verifier reads state.get('build_loops',0), writes the incremented count back on each FAIL, and PARKS at max_build_loops. Parks after exactly N failures, never unbounded. - SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live App JWT to stdout in default dry-run and passed it as a gh argv literal. Added redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences. - SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the configured App-ID to env/config VALUES (not just files) — an App private key under a benign env name is caught. - SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback / value-scan; staged-only WARN on the live workflow revert. Suite: 1382 passed, ruff clean. Branch only; not merged/deployed. NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was uncommitted working-tree state); independently confirmed closed empirically.
2026-06-23 19:40:59 -04:00
# otherwise read-only/allowlisted var. Check them up front, before the
# allowlist short-circuits the name/token-prefix heuristics.
if value and _PRIVATE_KEY_RE.search(value):
findings.append(
f"environment variable {name!r} holds a PEM private-key block "
"(-----BEGIN ... PRIVATE KEY-----); no private key may live on "
"the box (the App key must live ONLY as an Actions secret)"
)
if app_id and value and app_id in value:
findings.append(
f"environment variable {name!r} contains the configured App id "
"value; the App id must not be present on the box"
)
if name in _ALLOWED_TOKEN_ENV_VARS:
continue
if name in _FORBIDDEN_ENV_VARS:
findings.append(
f"environment variable {name!r} is set — the App write "
"credential must live ONLY as an Actions secret, never on the box"
)
continue
if _WRITE_TOKEN_NAME_RE.search(name):
findings.append(
f"environment variable {name!r} has a GitHub write-token-shaped "
"name; the box must hold no standing write token"
)
continue
if value and _WRITE_TOKEN_VALUE_RE.search(value):
findings.append(
f"environment variable {name!r} holds a write-capable GitHub "
"token value (ghp_/ghs_/ghu_/ghr_/github_pat_ prefix)"
)
return findings
def scan_config(config: Mapping[str, object] | None) -> list[str]:
"""Scan a coordinator config mapping for write-shaped token material.
The coordinator is environment-driven, so this is normally a thin pass over
whatever config dict a caller hands in (string values only). It applies the
same name/value heuristics as :func:`scan_environ`.
"""
if not config:
return []
findings: list[str] = []
for key, raw in config.items():
name = str(key)
if name in _ALLOWED_TOKEN_ENV_VARS:
continue
if name in _FORBIDDEN_ENV_VARS or _WRITE_TOKEN_NAME_RE.search(name):
findings.append(
f"coordinator config key {name!r} is a GitHub write-token-shaped "
"key; the box config must hold no standing write token"
)
continue
if isinstance(raw, str) and _WRITE_TOKEN_VALUE_RE.search(raw):
findings.append(
f"coordinator config key {name!r} holds a write-capable GitHub "
"token value (ghp_/ghs_/ghu_/ghr_/github_pat_ prefix)"
)
return findings
def scan_env_file(path: Path, *, app_id: str | None = None) -> list[str]:
"""Grep one credential file for the App id and any PEM private-key header.
A non-existent file is **not** a finding (the box legitimately may not have
every file). An unreadable-but-present file is reported as a finding so a
permissions mistake can't silently mask leaked material.
When ``app_id`` is given (the configured ``AGENT_APPLY_APP_ID`` value), the
grep also flags that literal id appearing in the file. The
``AGENT_APPLY_APP_ID`` / ``AGENT_APPLY_APP_PRIVATE_KEY`` *names* are always
flagged regardless.
"""
findings: list[str] = []
if not path.exists():
return findings
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc: # present but unreadable — fail loud, not silent
return [
f"could not read credential file {path} ({exc}); cannot prove it is clean"
]
for lineno, line in enumerate(text.splitlines(), start=1):
if APP_ID_ENV in line or APP_PRIVATE_KEY_ENV in line:
findings.append(
f"{path}:{lineno}: references the App credential "
f"({APP_ID_ENV}/{APP_PRIVATE_KEY_ENV}); it must live ONLY as an "
"Actions secret"
)
if app_id and app_id in line:
findings.append(
f"{path}:{lineno}: contains the configured App id value; the App "
"id must not be present on the box"
)
if _PRIVATE_KEY_RE.search(text):
findings.append(
f"{path}: contains a PEM private-key block "
"(-----BEGIN ... PRIVATE KEY-----); no private key may live on the box"
)
return findings
# --------------------------------------------------------------------------- #
# Orchestration.
# --------------------------------------------------------------------------- #
def _resolve_env_files(
cli_files: Sequence[str] | None, environ: Mapping[str, str]
) -> list[Path]:
"""Resolve the credential files to grep (CLI > env var > defaults)."""
if cli_files:
raw = list(cli_files)
elif environ.get("ASSERT_NO_WRITE_TOKEN_ENV_FILES"):
raw = [
p.strip()
for p in environ["ASSERT_NO_WRITE_TOKEN_ENV_FILES"].split(os.pathsep)
if p.strip()
]
else:
raw = list(_DEFAULT_ENV_FILES)
return [Path(p).expanduser() for p in raw]
def audit(
*,
environ: Mapping[str, str] | None = None,
config: Mapping[str, object] | None = None,
env_files: Sequence[str] | None = None,
) -> list[str]:
"""Run every scanner and return the combined list of findings (empty == clean)."""
environ = os.environ if environ is None else environ
fix(agent-team): remediate C1 security-review BLOCK (2 HIGH + MED/LOW) High-recall /sh-security-review fan-out + proof-or-kill verifier found two confirmed HIGH; both now closed (verified empirically against the working tree): - LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead (verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so the max_build_loops park never fired -> a perpetually-failing task looped BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round). Threaded build_loops through durable PipelineState/TaskRecord; verifier reads state.get('build_loops',0), writes the incremented count back on each FAIL, and PARKS at max_build_loops. Parks after exactly N failures, never unbounded. - SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live App JWT to stdout in default dry-run and passed it as a gh argv literal. Added redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences. - SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the configured App-ID to env/config VALUES (not just files) — an App private key under a benign env name is caught. - SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback / value-scan; staged-only WARN on the live workflow revert. Suite: 1382 passed, ruff clean. Branch only; not merged/deployed. NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was uncommitted working-tree state); independently confirmed closed empirically.
2026-06-23 19:40:59 -04:00
app_id = environ.get(APP_ID_ENV) or None
findings: list[str] = []
fix(agent-team): remediate C1 security-review BLOCK (2 HIGH + MED/LOW) High-recall /sh-security-review fan-out + proof-or-kill verifier found two confirmed HIGH; both now closed (verified empirically against the working tree): - LOGIC-RACE-01 (HIGH, CWE-835): the build-loop budget was structurally dead (verifier read a shared wiring-time VerifierConfig.build_loops, always 0, so the max_build_loops park never fired -> a perpetually-failing task looped BUILD->DISPATCH->VERIFY forever, force-pushing + firing a CI run each round). Threaded build_loops through durable PipelineState/TaskRecord; verifier reads state.get('build_loops',0), writes the incremented count back on each FAIL, and PARKS at max_build_loops. Parks after exactly N failures, never unbounded. - SEC-01 (HIGH, CWE-532) + SEC-02 (MED, CWE-214): p3_rollback.sh echoed the live App JWT to stdout in default dry-run and passed it as a gh argv literal. Added redact_secrets (Bearer/Authorization/ghX_/PEM masking) through run_or_plan; the App uninstall now uses curl -H @<0600 tempfile> (JWT never on argv), shredded after. Empirical: app/incident/all dry-runs leak 0 JWT occurrences. - SEC-03 (MED, CWE-798): assert_no_write_token now applies the PEM regex + the configured App-ID to env/config VALUES (not just files) — an App private key under a benign env name is caught. - SEC-04 (LOW) + P3-IAC-08 (LOW): tightened the box GITHUB_TOKEN fallback / value-scan; staged-only WARN on the live workflow revert. Suite: 1382 passed, ruff clean. Branch only; not merged/deployed. NOTE: re-verifier flagged SEC-01 as open by grepping COMMITTED blobs (the fix was uncommitted working-tree state); independently confirmed closed empirically.
2026-06-23 19:40:59 -04:00
findings += scan_environ(environ, app_id=app_id)
findings += scan_config(config)
for path in _resolve_env_files(env_files, environ):
findings += scan_env_file(path, app_id=app_id)
return findings
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=(
"Assert no pull-requests:write / contents:write GitHub token (or the "
"apply App's id/private key) is present on this box."
)
)
parser.add_argument(
"--env-file",
action="append",
dest="env_files",
metavar="PATH",
help=(
"Credential file to grep for the App id + private keys (repeatable). "
"Defaults to ~/secrev.env and ~/orchestrator/.env, or the os.pathsep-"
"separated ASSERT_NO_WRITE_TOKEN_ENV_FILES env var."
),
)
args = parser.parse_args(argv)
findings = audit(env_files=args.env_files)
if findings:
print(
"FAIL: write-capable GitHub credential material found on the box "
f"({len(findings)} finding(s)). The pull-requests:write App token "
"must only ever live as an Actions secret:",
file=sys.stderr,
)
for f in findings:
print(f" - {f}", file=sys.stderr)
return 1
print(
"OK: no pull-requests:write / contents:write token, App id, or private "
"key found in the environment, coordinator config, or credential files. "
"The box holds no standing write token."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())