Phase A (safety): - scripts/p3_rollback.sh (+test): restore all privileged P3 surfaces from a recorded baseline; --dry-run default, --apply gated. Correct App-uninstall (App JWT) model; per-task env-reviewer restore by numeric id; real protection post-restore assert (normalize reads argv, fails loud, divergent state exits non-zero — regression-tested). KNOWN-LIMITATIONS header flags the branch-protection GET->PUT transform + live-validation for the C1 gate. - scripts/assert_no_write_token.py (+test): box/CI audit that no write token (incl. ghu_/ghr_ prefixes + App PEM) lives on the box. - draft_pr_monitor.py (+test): runaway (>3/15min) + stale (7d) draft-PR sweep, wired into tick() and bound a read-only provider in serve. Phase B (wiring): systemd EnvironmentFile P3 vars + verification; new-draft-PR lifecycle notice. Phase E (docs): P3-LIVE-FLIP-PLAN/README/ci-README reflect CI-live-since-6/22 + box-integration; runbook consolidated (rollback Incident 7 + box-env wiring); removed a stray duplicate runbook. Suite: 1360 passed, ruff clean. Branch only; not merged/deployed. REMAINING HUMAN GATES: C1 /sh-security-review + GPT-4.1 cross-review on the enabled workflow + rollback script; D box deploy + smoke + merge.
292 lines
12 KiB
Python
292 lines
12 KiB
Python
#!/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.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def scan_environ(environ: Mapping[str, str]) -> 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*
|
|
matches the GitHub-write heuristic, and (c) any var whose *value* carries a
|
|
write-capable GitHub token prefix. Known read-only tokens are exempt.
|
|
"""
|
|
findings: list[str] = []
|
|
for name, value in environ.items():
|
|
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
|
|
findings: list[str] = []
|
|
findings += scan_environ(environ)
|
|
findings += scan_config(config)
|
|
app_id = environ.get(APP_ID_ENV) or None
|
|
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())
|