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:
parent
276b65ba72
commit
e77ec6514f
4 changed files with 112 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("*?[]")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Reference in a new issue