ci: pin actions to commit SHAs, guard Docker jobs from forks, scan the image, gate publish on security (SEC-06, SEC-07, SEC-14, SEC-15, SEC-16)
This commit is contained in:
1 parent
6f7a3c0e5b
commit
766968fa42
9 files changed
+679
-60
No files matched your search
@@ -0,0 +1,326 @@
|
||||
"""CI hygiene: pinned actions, fork guards, cleanup scope and the security gates (SEC-06/07/14/15/16).
|
||||
|
||||
The workflow files are simple enough to read line by line, so no YAML library is needed
|
||||
(none is a dependency of the project).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOWS = sorted((ROOT / ".gitea" / "workflows").glob("*.yml"))
|
||||
FORK_GUARD = (
|
||||
"github.event_name != 'pull_request' || "
|
||||
"github.event.pull_request.head.repo.full_name == github.repository"
|
||||
)
|
||||
PINNED = re.compile(r"^[\w.-]+/[\w.-]+@[0-9a-f]{40}$")
|
||||
VERSION_COMMENT = re.compile(r"#\s*v\d+\.\d+\.\d+\s*$")
|
||||
DOCKER_USE = re.compile(
|
||||
r"\bdocker\s+(build|run|push|pull|login|save|tag|rmi|rm|image|ps|network|compose)\b"
|
||||
r"|cleanup_job_resources\.sh|scan_image\.sh|tests/integration|tests/mcp"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
lines: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return "\n".join(self.lines)
|
||||
|
||||
@property
|
||||
def uses(self) -> str | None:
|
||||
for line in self.lines:
|
||||
match = re.match(r"\s*(?:-\s*)?uses:\s*(.*)$", line)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
def with_block(self) -> str:
|
||||
collected: list[str] = []
|
||||
inside = False
|
||||
for line in self.lines:
|
||||
if re.match(r"^\s{8}with:\s*$", line):
|
||||
inside = True
|
||||
continue
|
||||
if inside and re.match(r"^\s{8}\S", line):
|
||||
break
|
||||
if inside:
|
||||
collected.append(line)
|
||||
return "\n".join(collected)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
name: str
|
||||
lines: list[str]
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return "\n".join(self.lines)
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
for line in self.lines:
|
||||
match = re.match(r"^ {4}if:\s*(.*)$", line)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def needs(self) -> list[str]:
|
||||
for line in self.lines:
|
||||
match = re.match(r"^ {4}needs:\s*\[(.*)\]\s*$", line)
|
||||
if match:
|
||||
return [item.strip() for item in match.group(1).split(",")]
|
||||
return []
|
||||
|
||||
def steps(self) -> list[Step]:
|
||||
steps: list[Step] = []
|
||||
in_steps = False
|
||||
for line in self.lines:
|
||||
if re.match(r"^ {4}steps:\s*$", line):
|
||||
in_steps = True
|
||||
continue
|
||||
if not in_steps:
|
||||
continue
|
||||
if re.match(r"^ {6}-\s", line):
|
||||
steps.append(Step())
|
||||
if steps:
|
||||
steps[-1].lines.append(line)
|
||||
return steps
|
||||
|
||||
|
||||
def jobs_of(path: Path) -> list[Job]:
|
||||
jobs: list[Job] = []
|
||||
in_jobs = False
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("jobs:"):
|
||||
in_jobs = True
|
||||
continue
|
||||
if not in_jobs:
|
||||
continue
|
||||
if line.lstrip().startswith("#"):
|
||||
continue # comment lines (the banner of the next job sits at the end of a block)
|
||||
match = re.match(r"^ {2}([\w-]+):\s*$", line)
|
||||
if match:
|
||||
jobs.append(Job(match.group(1), []))
|
||||
if jobs:
|
||||
jobs[-1].lines.append(line)
|
||||
return jobs
|
||||
|
||||
|
||||
def has_pull_request_trigger(path: Path) -> bool:
|
||||
return (
|
||||
re.search(r"^ {2}pull_request:", path.read_text(encoding="utf-8"), re.MULTILINE) is not None
|
||||
)
|
||||
|
||||
|
||||
def test_there_are_workflows_to_check() -> None:
|
||||
assert {path.name for path in WORKFLOWS} >= {"ci.yml", "publish.yml"}
|
||||
for path in WORKFLOWS:
|
||||
assert jobs_of(path), path.name
|
||||
|
||||
|
||||
# SEC-07
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name)
|
||||
def test_every_action_is_pinned_to_a_full_commit_sha_with_a_version_comment(path: Path) -> None:
|
||||
seen = 0
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = re.match(r"^\s*(?:-\s*)?uses:\s*(\S+)(.*)$", line)
|
||||
if match is None or line.lstrip().startswith("#"):
|
||||
continue
|
||||
seen += 1
|
||||
assert PINNED.match(match.group(1)), f"{path.name}: {line.strip()}"
|
||||
assert VERSION_COMMENT.search(match.group(2)), f"{path.name}: no version comment: {line}"
|
||||
assert seen, f"{path.name} uses no action, so the pin check proved nothing"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name)
|
||||
def test_no_secret_is_handed_to_a_third_party_action(path: Path) -> None:
|
||||
for job in jobs_of(path):
|
||||
for step in job.steps():
|
||||
if step.uses is not None:
|
||||
assert "secrets." not in step.with_block(), f"{path.name}/{job.name}"
|
||||
assert "secrets." not in step.text, f"{path.name}/{job.name}: {step.text}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name)
|
||||
def test_checkout_does_not_persist_the_job_token(path: Path) -> None:
|
||||
for job in jobs_of(path):
|
||||
for step in job.steps():
|
||||
if step.uses and step.uses.startswith("actions/checkout@"):
|
||||
assert "persist-credentials: false" in step.with_block(), f"{path.name}/{job.name}"
|
||||
|
||||
|
||||
def test_the_registry_login_uses_the_cli_with_the_token_on_stdin() -> None:
|
||||
text = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert "docker/login-action" not in text
|
||||
assert "--password-stdin" in text
|
||||
assert not re.search(r"docker login[^\n]*--password(?!-stdin)", text)
|
||||
|
||||
|
||||
# SEC-06
|
||||
|
||||
|
||||
def test_docker_and_secret_jobs_do_not_run_for_fork_pull_requests() -> None:
|
||||
path = ROOT / ".gitea" / "workflows" / "ci.yml"
|
||||
assert has_pull_request_trigger(path)
|
||||
guarded = []
|
||||
for job in jobs_of(path):
|
||||
if DOCKER_USE.search(job.text) or "secrets." in job.text:
|
||||
guarded.append(job.name)
|
||||
assert job.condition == FORK_GUARD, f"job {job.name} runs docker or uses secrets"
|
||||
assert {"test-integration", "test-mcp", "build-and-push"} <= set(guarded)
|
||||
|
||||
|
||||
def test_workflows_without_a_pull_request_trigger_cannot_run_fork_code() -> None:
|
||||
for path in WORKFLOWS:
|
||||
if not has_pull_request_trigger(path):
|
||||
assert "pull_request_target" not in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# SEC-15
|
||||
|
||||
|
||||
def test_publish_needs_the_security_job_with_every_gate() -> None:
|
||||
path = ROOT / ".gitea" / "workflows" / "publish.yml"
|
||||
jobs = {job.name: job for job in jobs_of(path)}
|
||||
|
||||
assert "security" in jobs["publish"].needs
|
||||
security = jobs["security"].text
|
||||
for gate in (
|
||||
"scripts/ci/install_gitleaks.sh",
|
||||
"gitleaks git",
|
||||
"tests/security",
|
||||
"scripts/ci/pip_audit.sh",
|
||||
"ruff check --select S",
|
||||
):
|
||||
assert gate in security
|
||||
assert "fetch-depth: 0" in security # the history scan needs it
|
||||
|
||||
|
||||
def test_ci_security_job_runs_the_security_suite() -> None:
|
||||
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
|
||||
|
||||
assert "uv run pytest tests/security --no-cov" in jobs["security"].text
|
||||
|
||||
|
||||
# SEC-14
|
||||
|
||||
|
||||
def test_publish_passes_the_token_through_the_environment_not_the_command_line() -> None:
|
||||
text = (ROOT / ".gitea" / "workflows" / "publish.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert "--password" not in text
|
||||
assert "UV_PUBLISH_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}" in text
|
||||
|
||||
|
||||
def test_the_build_job_logs_out_by_deleting_its_own_credential_directory() -> None:
|
||||
text = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert "docker logout" not in text
|
||||
assert "DOCKER_CONFIG" in text
|
||||
|
||||
|
||||
FAKE_DOCKER = """#!/usr/bin/env bash
|
||||
echo "$*" >> "$DOCKER_LOG"
|
||||
if [ "$1" = "ps" ]; then
|
||||
# one container per ancestor filter value
|
||||
for arg in "$@"; do
|
||||
case "$arg" in ancestor=*) echo "cid-of-${arg#ancestor=}" ;; esac
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
"""
|
||||
|
||||
|
||||
def run_cleanup(tmp_path: Path, *refs: str) -> list[str]:
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
fake = bin_dir / "docker"
|
||||
fake.write_text(FAKE_DOCKER)
|
||||
fake.chmod(fake.stat().st_mode | stat.S_IXUSR)
|
||||
log = tmp_path / "docker.log"
|
||||
log.write_text("")
|
||||
env = {
|
||||
"PATH": f"{bin_dir}:/usr/bin:/bin",
|
||||
"DOCKER_LOG": str(log),
|
||||
"HOME": str(tmp_path),
|
||||
}
|
||||
work = tmp_path / "work"
|
||||
work.mkdir()
|
||||
subprocess.run( # noqa: S603 (fixed script path, fake docker on PATH)
|
||||
["bash", str(ROOT / "scripts" / "ci" / "cleanup_job_resources.sh"), *refs], # noqa: S607
|
||||
cwd=work,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
return log.read_text().splitlines()
|
||||
|
||||
|
||||
def test_cleanup_removes_containers_only_for_per_run_refs(tmp_path: Path) -> None:
|
||||
registry = "git.hiddenden.cafe/hiddenden/aegisgitea-mcp"
|
||||
per_run = f"{registry}:sha-0123456789ab"
|
||||
shared = [f"{registry}:latest", f"{registry}:main", f"{registry}:dev"]
|
||||
|
||||
calls = run_cleanup(tmp_path, per_run, *shared)
|
||||
|
||||
removed = [call for call in calls if call.startswith("rm -f")]
|
||||
assert removed == [f"rm -f -v cid-of-{per_run}"]
|
||||
queried = [call for call in calls if call.startswith("ps ")]
|
||||
assert all("sha-0123456789ab" in call for call in queried) and len(queried) == 1
|
||||
# Shared tags are only untagged.
|
||||
for ref in shared:
|
||||
assert f"image rm {ref}" in calls
|
||||
assert f"rmi {ref}" not in calls
|
||||
|
||||
|
||||
def test_cleanup_treats_names_with_the_run_prefix_as_per_run(tmp_path: Path) -> None:
|
||||
calls = run_cleanup(tmp_path, "aegis-ci-4242-mcp:test")
|
||||
|
||||
assert "rm -f -v cid-of-aegis-ci-4242-mcp:test" in calls
|
||||
assert "rmi aegis-ci-4242-mcp:test" in calls
|
||||
|
||||
|
||||
def test_cleanup_without_arguments_touches_nothing(tmp_path: Path) -> None:
|
||||
assert not any(call.startswith(("rm", "rmi", "image rm")) for call in run_cleanup(tmp_path))
|
||||
|
||||
|
||||
# SEC-16
|
||||
|
||||
|
||||
def test_the_image_scan_is_pinned_verified_and_gates_the_push() -> None:
|
||||
install = (ROOT / "scripts" / "ci" / "install_trivy.sh").read_text(encoding="utf-8")
|
||||
scan = (ROOT / "scripts" / "ci" / "scan_image.sh").read_text(encoding="utf-8")
|
||||
ci = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||
|
||||
assert re.search(r'^TRIVY_VERSION="\d+\.\d+\.\d+"$', install, re.MULTILINE)
|
||||
assert len(re.findall(r'^SHA256_\w+="[0-9a-f]{64}"$', install, re.MULTILINE)) == 2
|
||||
assert "sha256sum --check" in install
|
||||
assert "--severity HIGH,CRITICAL" in scan and "--exit-code 1" in scan
|
||||
assert "--ignore-unfixed" in scan
|
||||
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
|
||||
build = jobs["build-and-push"].text
|
||||
assert build.index("scripts/ci/scan_image.sh") < build.index("docker push")
|
||||
assert "scripts/ci/install_trivy.sh" in ci
|
||||
|
||||
|
||||
def test_scripts_are_executable_and_parse() -> None:
|
||||
for script in (ROOT / "scripts" / "ci").glob("*.sh"):
|
||||
assert os.access(script, os.X_OK), script.name
|
||||
subprocess.run(["bash", "-n", str(script)], check=True, timeout=30) # noqa: S603, S607
|
||||
Reference in new issue
Block a user