357 lines
12 KiB
Python
357 lines
12 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 = "aegis-ci-4242-build:0123456789abcdef"
|
|
shared = [
|
|
f"{registry}:sha-0123456789ab",
|
|
f"{registry}:latest",
|
|
f"{registry}:main",
|
|
f"{registry}:dev",
|
|
]
|
|
|
|
calls = run_cleanup(tmp_path, *shared, per_run)
|
|
|
|
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 len(queried) == 1 and per_run in queried[0]
|
|
# Registry tags (the immutable sha tag included) are only untagged.
|
|
for ref in shared:
|
|
assert f"image rm {ref}" in calls
|
|
assert f"rmi {ref}" not in calls
|
|
assert f"rmi {per_run}" 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
|
|
|
|
|
|
def test_build_and_push_builds_under_a_per_run_tag_and_gates_on_every_test_job() -> None:
|
|
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
|
|
build = jobs["build-and-push"]
|
|
|
|
assert {"lint", "test-unit", "test-integration", "test-mcp", "security"} <= set(build.needs)
|
|
text = build.text
|
|
assert 'BUILD_TAG="aegis-ci-${GITHUB_RUN_ID}-build:${GITHUB_SHA}"' in text
|
|
# Nothing is built, scanned or smoke-tested under a registry tag.
|
|
assert '-t "${{ steps.meta.outputs.build_tag }}"' in text
|
|
assert 'scan_image.sh "${{ steps.meta.outputs.build_tag }}"' in text
|
|
assert '-t "${{ steps.meta.outputs.sha_tag }}"' not in text
|
|
# Registry tags are applied only in the push step, and cleanup removes the build tag last.
|
|
assert text.index("docker build") < text.index("docker tag") < text.index("docker push")
|
|
assert text.count("docker tag") == 1
|
|
cleanup = text[text.index("scripts/ci/cleanup_job_resources.sh") :]
|
|
assert cleanup.index("sha_tag") < cleanup.index("build_tag")
|
|
|
|
|
|
def test_the_integration_job_requires_the_actions_runner() -> None:
|
|
jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")}
|
|
|
|
assert 'AEGIS_IT_REQUIRE_RUNNER: "1"' in jobs["test-integration"].text
|