Files
AegisGitea-MCP/tests/security/test_ci_workflows.py
T

327 lines
11 KiB
Python

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