feat(agent-team): P3-flip Phase 1 — expand denylist vectors (§4.2) + runner-trust assertion (§4.1)

First controls of the P3-live-flip Phase-1 CI hardening (workflow stays INERT;
this only tightens the trust boundary). Whole Phase-1 surface is gated by
/sh-security-review + GPT-4.1 cross-review before any flip.

§4.2 — expand the trust-control denylist with direct code-execution / supply-chain
vectors, kept byte-identical across all three copies (ci_gate.DENYLIST_GLOBS + the
guard + post-build inline DENY_GLOBS), drift-guarded:
  .gitmodules, .husky/**, .githooks/**, .gitattributes, .npmrc, and generated/build
  artifacts (__generated__, *.generated.*, dist/**, build/**, *.min.js).
Deliberate: lockfiles are NOT wholesale denied — lockfile-postinstall RCE is already
contained by the credential-less egress-blocked build sandbox, and the Tier-3 dep-CVE
fixer rewrites lockfiles to produce its draft PRs; a blanket deny would make it
un-shippable. Flagged in-code for the security gate. Direct code-execution config
(hooks/filters/npmrc/submodules) is the actual §4.2 RCE surface.

§4.1 — runner-trust: assert no job (esp. the privileged gate-and-pr) can run on a
self-hosted/user-provided runner; all must be GitHub-hosted.

998 tests pass, ruff clean.
This commit is contained in:
Adam Moussa 2026-06-22 17:33:51 -04:00
parent 276b65ba72
commit e77ec6514f
4 changed files with 112 additions and 0 deletions

View file

@ -129,6 +129,38 @@ DENYLIST_GLOBS: tuple[str, ...] = (
"**/*policy*.json",
"**/*.pem",
"**/*.key",
# --- P3-flip §4.2: direct code-execution / supply-chain vectors ---
# Submodule pointers / config — a changed submodule pulls in arbitrary
# external code at the pinned commit.
".gitmodules",
"**/.gitmodules",
# Git hook directories / hook-path redirection — code that runs on git ops.
# (`.git/hooks` itself is never in a checkout; `.husky` / `.githooks` are the
# in-tree hook dirs a repo points `core.hooksPath` at.)
".husky/**",
"**/.husky/**",
".githooks/**",
"**/.githooks/**",
# .gitattributes — clean/smudge filters execute arbitrary processes on checkout.
".gitattributes",
"**/.gitattributes",
# npm/registry config — can inject install scripts, a hostile registry, or auth.
".npmrc",
"**/.npmrc",
# Generated / build artifacts — codegen output is not reviewable as source, so
# a diff that writes into it is escalated to a human rather than auto-built.
"**/__generated__/**",
"**/*.generated.*",
"**/dist/**",
"**/build/**",
"**/*.min.js",
# NOTE (deliberate, for the security gate): lockfiles are NOT wholesale denied
# here. Lockfile-postinstall RCE is already contained by the credential-less,
# egress-blocked build-test sandbox, and the Tier-3 dep-CVE fixer legitimately
# rewrites lockfiles to produce its draft PRs — a wholesale lockfile deny would
# make the fixer un-shippable. The direct code-execution config above (hooks,
# filters, .npmrc, submodules) is the actual §4.2 RCE surface. Revisit if the
# fixer's lockfile writes ever need a scoped allow vs a general deny.
)
# Authenticated GitHub run conclusions that count as a recognised failure (the

View file

@ -190,6 +190,21 @@ jobs:
"**/*policy*.json",
"**/*.pem",
"**/*.key",
".gitmodules",
"**/.gitmodules",
".husky/**",
"**/.husky/**",
".githooks/**",
"**/.githooks/**",
".gitattributes",
"**/.gitattributes",
".npmrc",
"**/.npmrc",
"**/__generated__/**",
"**/*.generated.*",
"**/dist/**",
"**/build/**",
"**/*.min.js",
)
def canonical(path: str) -> str:
@ -629,6 +644,21 @@ jobs:
"**/*policy*.json",
"**/*.pem",
"**/*.key",
".gitmodules",
"**/.gitmodules",
".husky/**",
"**/.husky/**",
".githooks/**",
"**/.githooks/**",
".gitattributes",
"**/.gitattributes",
".npmrc",
"**/.npmrc",
"**/__generated__/**",
"**/*.generated.*",
"**/dist/**",
"**/build/**",
"**/*.min.js",
)
_GLOB_META = set("*?[]")

View file

@ -320,6 +320,31 @@ def test_three_trust_control_denylists_are_identical() -> None:
)
# --------------------------------------------------------------------------- #
# §4.1 runner-trust: no job may run on a self-hosted / user-provided runner
# --------------------------------------------------------------------------- #
def test_no_job_uses_a_self_hosted_runner() -> None:
"""§4.1: every job — ESPECIALLY the privileged gate-and-pr — must run on a
GitHub-hosted runner. A self-hosted/user-provided runner can be
attacker-influenced and must never be able to pick up the privileged job."""
github_hosted_prefixes = ("ubuntu-", "windows-", "macos-")
jobs = _doc()["jobs"]
assert jobs, "workflow defines no jobs"
for name, job in jobs.items():
runs_on = job.get("runs-on")
labels = [runs_on] if isinstance(runs_on, str) else list(runs_on or [])
assert labels, f"job {name!r} has no runs-on"
assert "self-hosted" not in labels, (
f"job {name!r} must not run on a self-hosted runner"
)
assert all(
any(label.startswith(p) for p in github_hosted_prefixes)
for label in labels
), f"job {name!r} runs-on must be GitHub-hosted, got {labels!r}"
# --------------------------------------------------------------------------- #
# FIX-2 / INJ-02: robust NUL-delimited post-build denied-path parsing
# --------------------------------------------------------------------------- #

View file

@ -93,6 +93,21 @@ def test_clean_diff_has_no_violations() -> None:
"deploy/iam/role.json",
"stacks/policies/admin.json",
"modules/main.tf",
# P3-flip §4.2 — direct code-execution / supply-chain vectors.
".gitmodules",
"vendor/.gitmodules",
".husky/pre-commit",
"frontend/.husky/commit-msg",
".githooks/pre-push",
".gitattributes",
"pkg/.gitattributes",
".npmrc",
"web/.npmrc",
"src/__generated__/schema.ts",
"api/types.generated.ts",
"web/dist/bundle.js",
"service/build/output.o",
"assets/app.min.js",
],
)
def test_denylisted_paths_flagged(path: str) -> None:
@ -100,6 +115,16 @@ def test_denylisted_paths_flagged(path: str) -> None:
assert violations, f"expected {path!r} to be denylisted"
def test_lockfiles_are_NOT_denylisted_so_the_tier3_fixer_can_ship() -> None:
"""Deliberate (§4.2 note): lockfile RCE is contained by the credential-less
egress-blocked build sandbox, and the dep-CVE fixer rewrites lockfiles to
produce draft PRs — so lockfiles are intentionally NOT on the denylist."""
for lock in ("package-lock.json", "web/yarn.lock", "poetry.lock", "Cargo.lock"):
assert denylist_violations(_diff_for(lock)) == [], (
f"{lock!r} must not be denylisted (would break the Tier-3 fixer)"
)
def test_rename_into_workflow_is_flagged() -> None:
diff = (
"diff --git a/safe.txt b/.github/workflows/evil.yml\n"