hardening: verify immutable supply chain references
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ACTION_SHA_RE = re.compile(
|
||||
r"^\s*(?:-\s*)?uses:\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?)@([0-9a-f]{40})\s*(?:#.*)?$"
|
||||
)
|
||||
ACTION_USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(\S+)")
|
||||
DOCKER_FROM_RE = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)", re.IGNORECASE)
|
||||
COMPOSE_IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\"'\s]+)")
|
||||
|
||||
|
||||
def _workflow_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for root in (ROOT / ".gitea/workflows", ROOT / ".github/workflows"):
|
||||
if root.is_dir():
|
||||
files.extend(sorted(root.glob("*.yml")))
|
||||
files.extend(sorted(root.glob("*.yaml")))
|
||||
return files
|
||||
|
||||
|
||||
def _check_workflows(violations: list[str]) -> None:
|
||||
for path in _workflow_files():
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
match = ACTION_USES_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
value = match.group(1)
|
||||
if value.startswith("./"):
|
||||
continue
|
||||
if value.startswith("docker://"):
|
||||
image = value.removeprefix("docker://")
|
||||
if "@sha256:" not in image:
|
||||
violations.append(
|
||||
f"{path.relative_to(ROOT)}:{number}: docker action is not digest-pinned"
|
||||
)
|
||||
continue
|
||||
if not ACTION_SHA_RE.match(line):
|
||||
violations.append(
|
||||
f"{path.relative_to(ROOT)}:{number}: action must use a full 40-char commit SHA: {value}"
|
||||
)
|
||||
|
||||
|
||||
def _check_dockerfiles(violations: list[str]) -> None:
|
||||
dockerfiles = (
|
||||
ROOT / "backend/Dockerfile",
|
||||
ROOT / "frontend/Dockerfile",
|
||||
ROOT / "deploy/unraid/Dockerfile.all-in-one",
|
||||
)
|
||||
for path in dockerfiles:
|
||||
if not path.is_file():
|
||||
continue
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
match = DOCKER_FROM_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
image = match.group(1)
|
||||
if image == "scratch":
|
||||
continue
|
||||
if "${" in image:
|
||||
violations.append(
|
||||
f"{path.relative_to(ROOT)}:{number}: dynamic external base image is not allowed"
|
||||
)
|
||||
continue
|
||||
if "@sha256:" not in image:
|
||||
violations.append(
|
||||
f"{path.relative_to(ROOT)}:{number}: base image must be digest-pinned: {image}"
|
||||
)
|
||||
|
||||
|
||||
def _check_compose(violations: list[str]) -> None:
|
||||
for relative in ("docker-compose.yml", "docker-compose.unraid.yml"):
|
||||
path = ROOT / relative
|
||||
if not path.is_file():
|
||||
continue
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
match = COMPOSE_IMAGE_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
image = match.group(1)
|
||||
if image.startswith(("geointel-", "${")):
|
||||
continue
|
||||
if "@sha256:" not in image:
|
||||
violations.append(
|
||||
f"{relative}:{number}: external image must be digest-pinned: {image}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
violations: list[str] = []
|
||||
_check_workflows(violations)
|
||||
_check_dockerfiles(violations)
|
||||
_check_compose(violations)
|
||||
if violations:
|
||||
print("Supply-chain pin violations:", file=sys.stderr)
|
||||
for violation in violations:
|
||||
print(f" - {violation}", file=sys.stderr)
|
||||
return 1
|
||||
print("Supply-chain pins passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user