"""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