add package supply chain scaffold: inventory scanner, drift detector, pipeline spec
refs #159 #165 #168 - pkg_inventory.py: scans all Dockerfiles, pyproject.toml, CI workflows for apt/apk/pypi/npm packages and emits data/pkg-manifest.json - pkg_drift.py: compares manifest against fresh scan to detect phantom or untracked packages - pkg-supply-chain.yml: CI-agnostic pipeline definition covering inventory → mirror-sync → drift-check → vuln-scan → auto-issues
This commit is contained in:
61
dev/pipelines/pkg-supply-chain.yml
Normal file
61
dev/pipelines/pkg-supply-chain.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
# CI-agnostic pipeline definition for package supply chain management.
|
||||
# This is the abstract spec — use gen_config.py to emit concrete workflow
|
||||
# files for Gitea Actions, Woodpecker, and GitHub Actions.
|
||||
#
|
||||
# Pipeline: inventory → mirror-sync → drift-check → vuln-scan → issue-create
|
||||
|
||||
name: Package Supply Chain
|
||||
|
||||
triggers:
|
||||
push:
|
||||
paths:
|
||||
- "**/Dockerfile*"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- "docs/package.json"
|
||||
- "docs/pnpm-lock.yaml"
|
||||
- ".gitea/workflows/*.yml"
|
||||
schedule:
|
||||
cron: "0 6 * * *" # daily at 06:00 UTC
|
||||
|
||||
env:
|
||||
GITEA_URL: "http://gitea:3000"
|
||||
DEVPI_URL: "http://devpi:3141/root/pypi/+simple/"
|
||||
APT_MIRROR_URL: "http://apt-cache:3142"
|
||||
|
||||
steps:
|
||||
- name: inventory
|
||||
description: Scan repo and regenerate package manifest
|
||||
run: uv run python dev/scripts/pkg_inventory.py
|
||||
outputs:
|
||||
- data/pkg-manifest.json
|
||||
|
||||
- name: mirror-sync
|
||||
description: Update local mirrors to match manifest
|
||||
needs: [inventory]
|
||||
run: uv run python dev/scripts/pkg_mirror_sync.py
|
||||
services:
|
||||
- apt-cache
|
||||
- devpi
|
||||
|
||||
- name: drift-check
|
||||
description: Compare mirror contents against manifest
|
||||
needs: [mirror-sync]
|
||||
run: uv run python dev/scripts/pkg_drift.py
|
||||
fail_on: drift
|
||||
|
||||
- name: vuln-scan
|
||||
description: Scan mirrored packages for known CVEs
|
||||
needs: [mirror-sync]
|
||||
run: |
|
||||
trivy fs --scanners vuln --format json --output data/pkg-vulns.json data/pkg-manifest.json
|
||||
uv run python dev/scripts/pkg_vuln_report.py
|
||||
tools:
|
||||
- trivy
|
||||
|
||||
- name: auto-issues
|
||||
description: Create Gitea issues for missing packages and CVEs
|
||||
needs: [drift-check, vuln-scan]
|
||||
run: uv run python dev/scripts/pkg_issues.py
|
||||
env:
|
||||
GITEA_TOKEN: "${GITEA_TOKEN}"
|
||||
138
dev/scripts/pkg_drift.py
Normal file
138
dev/scripts/pkg_drift.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Detect drift between the package manifest and what is actually used.
|
||||
|
||||
Compares data/pkg-manifest.json against the repo source to detect:
|
||||
- packages in manifest but missing from source (phantom)
|
||||
- packages in source but missing from manifest (untracked)
|
||||
|
||||
Usage:
|
||||
uv run python dev/scripts/pkg_drift.py # report + exit code
|
||||
uv run python dev/scripts/pkg_drift.py --json # JSON output
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
if not MANIFEST_PATH.exists():
|
||||
print(f"ERROR: {MANIFEST_PATH} not found. Run pkg_inventory.py first.")
|
||||
raise SystemExit(2)
|
||||
return json.loads(MANIFEST_PATH.read_text())
|
||||
|
||||
|
||||
def scan_fresh() -> dict:
|
||||
"""Run a fresh inventory scan and return the result."""
|
||||
from pkg_inventory import scan
|
||||
|
||||
return scan()
|
||||
|
||||
|
||||
def diff_lists(manifest_items: list, fresh_items: list, key: str = "name") -> dict:
|
||||
"""Compare two lists of dicts by a key field."""
|
||||
if manifest_items and isinstance(manifest_items[0], dict):
|
||||
m_set = {item[key] for item in manifest_items}
|
||||
f_set = {item[key] for item in fresh_items}
|
||||
else:
|
||||
m_set = set(manifest_items)
|
||||
f_set = set(fresh_items)
|
||||
|
||||
return {
|
||||
"phantom": sorted(m_set - f_set),
|
||||
"untracked": sorted(f_set - m_set),
|
||||
}
|
||||
|
||||
|
||||
def check_drift() -> dict:
|
||||
manifest = load_manifest()
|
||||
fresh = scan_fresh()
|
||||
drift: dict = {"apt": {}, "apk": {}, "pypi": {}, "npm": {}, "has_drift": False}
|
||||
|
||||
# apt
|
||||
all_apt_manifest = set()
|
||||
for pkgs in manifest.get("apt", {}).values():
|
||||
all_apt_manifest.update(pkgs)
|
||||
all_apt_fresh = set()
|
||||
for pkgs in fresh.get("apt", {}).values():
|
||||
all_apt_fresh.update(pkgs)
|
||||
apt_diff = {
|
||||
"phantom": sorted(all_apt_manifest - all_apt_fresh),
|
||||
"untracked": sorted(all_apt_fresh - all_apt_manifest),
|
||||
}
|
||||
if apt_diff["phantom"] or apt_diff["untracked"]:
|
||||
drift["apt"] = apt_diff
|
||||
drift["has_drift"] = True
|
||||
|
||||
# apk
|
||||
all_apk_manifest = set()
|
||||
for pkgs in manifest.get("apk", {}).values():
|
||||
all_apk_manifest.update(pkgs)
|
||||
all_apk_fresh = set()
|
||||
for pkgs in fresh.get("apk", {}).values():
|
||||
all_apk_fresh.update(pkgs)
|
||||
apk_diff = {
|
||||
"phantom": sorted(all_apk_manifest - all_apk_fresh),
|
||||
"untracked": sorted(all_apk_fresh - all_apk_manifest),
|
||||
}
|
||||
if apk_diff["phantom"] or apk_diff["untracked"]:
|
||||
drift["apk"] = apk_diff
|
||||
drift["has_drift"] = True
|
||||
|
||||
# pypi (compare all sections)
|
||||
for section in ("project_prod", "project_dev", "notebook", "dockerfile_adhoc"):
|
||||
d = diff_lists(
|
||||
manifest.get("pypi", {}).get(section, []),
|
||||
fresh.get("pypi", {}).get(section, []),
|
||||
)
|
||||
if d["phantom"] or d["untracked"]:
|
||||
drift["pypi"][section] = d
|
||||
drift["has_drift"] = True
|
||||
|
||||
# npm
|
||||
d = diff_lists(
|
||||
manifest.get("npm", []),
|
||||
fresh.get("npm", []),
|
||||
)
|
||||
if d["phantom"] or d["untracked"]:
|
||||
drift["npm"] = d
|
||||
drift["has_drift"] = True
|
||||
|
||||
return drift
|
||||
|
||||
|
||||
def main() -> None:
|
||||
drift = check_drift()
|
||||
|
||||
if "--json" in sys.argv:
|
||||
print(json.dumps(drift, indent=2))
|
||||
else:
|
||||
if not drift["has_drift"]:
|
||||
print("No drift detected — manifest matches repo source.")
|
||||
else:
|
||||
print("DRIFT DETECTED:")
|
||||
for pkg_type in ("apt", "apk", "pypi", "npm"):
|
||||
section = drift[pkg_type]
|
||||
if not section:
|
||||
continue
|
||||
if pkg_type == "pypi":
|
||||
for sub, d in section.items():
|
||||
if d.get("phantom"):
|
||||
print(f" pypi/{sub} phantom (in manifest, not in source): {d['phantom']}")
|
||||
if d.get("untracked"):
|
||||
print(f" pypi/{sub} untracked (in source, not in manifest): {d['untracked']}")
|
||||
else:
|
||||
if section.get("phantom"):
|
||||
print(f" {pkg_type} phantom: {section['phantom']}")
|
||||
if section.get("untracked"):
|
||||
print(f" {pkg_type} untracked: {section['untracked']}")
|
||||
|
||||
raise SystemExit(1 if drift["has_drift"] else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
336
dev/scripts/pkg_inventory.py
Normal file
336
dev/scripts/pkg_inventory.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Scan the repo for all apt, apk, and PyPI packages in use.
|
||||
|
||||
Parses Dockerfiles, pyproject.toml, CI workflows, and shell scripts to
|
||||
produce a canonical JSON manifest at data/pkg-manifest.json.
|
||||
|
||||
Usage:
|
||||
uv run python dev/scripts/pkg_inventory.py
|
||||
uv run python dev/scripts/pkg_inventory.py --check # exit 1 if manifest is stale
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MANIFEST_PATH = ROOT / "data" / "pkg-manifest.json"
|
||||
|
||||
# Directories to skip when scanning Dockerfiles
|
||||
SKIP_DIRS = {"node_modules", ".git", "__pycache__", "plugins"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dockerfile parsers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _join_continuation_lines(text: str) -> str:
|
||||
"""Merge backslash-continued lines into single lines."""
|
||||
return re.sub(r"\\\s*\n\s*", " ", text)
|
||||
|
||||
|
||||
def _parse_apt_packages(text: str) -> list[str]:
|
||||
"""Extract packages from apt-get install commands."""
|
||||
text = _join_continuation_lines(text)
|
||||
pkgs: set[str] = set()
|
||||
for m in re.finditer(
|
||||
r"apt-get\s+install\s+(?:-\S+\s+)*(.+?)(?:&&|;|\n|$)", text
|
||||
):
|
||||
tokens = m.group(1).split()
|
||||
for tok in tokens:
|
||||
tok = tok.strip()
|
||||
if tok and not tok.startswith("-") and not tok.startswith("#"):
|
||||
# strip version pin like =1.2.3
|
||||
name = re.split(r"[=<>]", tok)[0]
|
||||
if name and not name.startswith("/"):
|
||||
pkgs.add(name)
|
||||
return sorted(pkgs)
|
||||
|
||||
|
||||
def _parse_apk_packages(text: str) -> list[str]:
|
||||
"""Extract packages from apk add commands."""
|
||||
text = _join_continuation_lines(text)
|
||||
pkgs: set[str] = set()
|
||||
for m in re.finditer(r"apk\s+add\s+(?:-\S+\s+)*(.+?)(?:&&|;|\n|$)", text):
|
||||
tokens = m.group(1).split()
|
||||
for tok in tokens:
|
||||
tok = tok.strip()
|
||||
if tok and not tok.startswith("-") and not tok.startswith("#"):
|
||||
name = re.split(r"[=<>~]", tok)[0]
|
||||
if name:
|
||||
pkgs.add(name)
|
||||
return sorted(pkgs)
|
||||
|
||||
|
||||
def _parse_uv_add_packages(text: str) -> list[dict]:
|
||||
"""Extract packages from 'uv add' commands in Dockerfiles."""
|
||||
text = _join_continuation_lines(text)
|
||||
pkgs: list[dict] = []
|
||||
for m in re.finditer(r"uv\s+add\s+(.+?)(?:&&|;|\n|$)", text):
|
||||
tokens = m.group(1).split()
|
||||
for tok in tokens:
|
||||
tok = tok.strip().strip('"').strip("'")
|
||||
if tok.startswith("-"):
|
||||
continue
|
||||
if tok.startswith("http"):
|
||||
continue
|
||||
if tok:
|
||||
pkgs.append(_parse_pypi_spec(tok))
|
||||
return pkgs
|
||||
|
||||
|
||||
def _parse_pip_install_packages(text: str) -> list[dict]:
|
||||
"""Extract packages from 'pip install' commands in Dockerfiles."""
|
||||
text = _join_continuation_lines(text)
|
||||
pkgs: list[dict] = []
|
||||
for m in re.finditer(r"pip\s+install\s+(.+?)(?:&&|;|\n|$)", text):
|
||||
tokens = m.group(1).split()
|
||||
for tok in tokens:
|
||||
tok = tok.strip().strip('"').strip("'")
|
||||
if tok.startswith("-"):
|
||||
continue
|
||||
if tok and tok != ".":
|
||||
pkgs.append(_parse_pypi_spec(tok))
|
||||
return pkgs
|
||||
|
||||
|
||||
def _parse_uv_run_with_packages(text: str) -> list[dict]:
|
||||
"""Extract packages from 'uv run --with pkg' commands."""
|
||||
text = _join_continuation_lines(text)
|
||||
pkgs: list[dict] = []
|
||||
for m in re.finditer(r"uv\s+run\s+--with\s+(\S+)", text):
|
||||
tok = m.group(1).strip('"').strip("'")
|
||||
if tok:
|
||||
pkgs.append(_parse_pypi_spec(tok))
|
||||
return pkgs
|
||||
|
||||
|
||||
def _parse_pypi_spec(spec: str) -> dict:
|
||||
"""Parse a PEP 508 spec like 'narwhals>=2.17.0' or 'marimo[recommended]'."""
|
||||
# strip extras
|
||||
extras = ""
|
||||
if "[" in spec:
|
||||
base, rest = spec.split("[", 1)
|
||||
extras = rest.split("]")[0]
|
||||
spec = base + rest.split("]")[-1] if "]" in rest else base
|
||||
m = re.match(r"([a-zA-Z0-9_-]+)(.*)", spec)
|
||||
if not m:
|
||||
return {"name": spec, "version": "", "extras": ""}
|
||||
return {
|
||||
"name": m.group(1).lower().replace("_", "-"),
|
||||
"version": m.group(2).strip(),
|
||||
"extras": extras,
|
||||
}
|
||||
|
||||
|
||||
def _parse_base_images(text: str) -> list[str]:
|
||||
"""Extract FROM base images from Dockerfiles."""
|
||||
images: list[str] = []
|
||||
for m in re.finditer(r"^FROM\s+(\S+)", text, re.MULTILINE):
|
||||
img = m.group(1)
|
||||
if img not in images:
|
||||
images.append(img)
|
||||
return images
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CI workflow parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_ci_curl_tools(text: str) -> list[dict]:
|
||||
"""Extract tools installed via curl in CI workflows."""
|
||||
tools: list[dict] = []
|
||||
patterns = [
|
||||
(r"astral\.sh/uv/install\.sh", "uv", "latest"),
|
||||
(r"go-containerregistry.*crane", "crane", "latest"),
|
||||
(r"aquasecurity/trivy", "trivy", "latest"),
|
||||
]
|
||||
for pattern, name, version in patterns:
|
||||
if re.search(pattern, text):
|
||||
tools.append({"name": name, "version": version})
|
||||
return tools
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pyproject.toml parser (simple, no toml dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_pyproject_deps(text: str) -> tuple[list[dict], list[dict]]:
|
||||
"""Parse dependencies from pyproject.toml without a TOML library."""
|
||||
prod: list[dict] = []
|
||||
dev: list[dict] = []
|
||||
|
||||
# prod deps
|
||||
m = re.search(
|
||||
r"^dependencies\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL
|
||||
)
|
||||
if m:
|
||||
for line in m.group(1).splitlines():
|
||||
line = line.strip().strip(",").strip('"').strip("'")
|
||||
if line and not line.startswith("#"):
|
||||
prod.append(_parse_pypi_spec(line))
|
||||
|
||||
# dev deps
|
||||
m = re.search(r"dev\s*=\s*\[(.*?)\]", text, re.MULTILINE | re.DOTALL)
|
||||
if m:
|
||||
for line in m.group(1).splitlines():
|
||||
line = line.strip().strip(",").strip('"').strip("'")
|
||||
if line and not line.startswith("#"):
|
||||
dev.append(_parse_pypi_spec(line))
|
||||
|
||||
# build-system requires
|
||||
m = re.search(
|
||||
r"\[build-system\].*?requires\s*=\s*\[(.*?)\]",
|
||||
text,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
if m:
|
||||
for line in m.group(1).splitlines():
|
||||
line = line.strip().strip(",").strip('"').strip("'")
|
||||
if line and not line.startswith("#"):
|
||||
prod.append(_parse_pypi_spec(line))
|
||||
|
||||
return prod, dev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pnpm / Node parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_package_json_deps(text: str) -> list[dict]:
|
||||
"""Parse npm dependencies from package.json."""
|
||||
data = json.loads(text)
|
||||
pkgs: list[dict] = []
|
||||
for section in ("dependencies", "devDependencies"):
|
||||
for name, version in data.get(section, {}).items():
|
||||
pkgs.append({"name": name, "version": version})
|
||||
return pkgs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def scan() -> dict:
|
||||
"""Scan repo and return the package manifest."""
|
||||
manifest: dict = {
|
||||
"apt": {},
|
||||
"apk": {},
|
||||
"pypi": {"project_prod": [], "project_dev": [], "notebook": [], "dockerfile_adhoc": []},
|
||||
"npm": [],
|
||||
"ci_tools": [],
|
||||
"base_images": {},
|
||||
}
|
||||
|
||||
# --- Dockerfiles ---
|
||||
dockerfiles = list(ROOT.glob("**/Dockerfile")) + list(
|
||||
ROOT.glob("**/Dockerfile.*")
|
||||
)
|
||||
dockerfiles = [
|
||||
df for df in dockerfiles
|
||||
if not any(skip in df.parts for skip in SKIP_DIRS)
|
||||
]
|
||||
for df in sorted(dockerfiles):
|
||||
rel = str(df.relative_to(ROOT))
|
||||
text = df.read_text()
|
||||
|
||||
apt = _parse_apt_packages(text)
|
||||
if apt:
|
||||
manifest["apt"][rel] = apt
|
||||
|
||||
apk = _parse_apk_packages(text)
|
||||
if apk:
|
||||
manifest["apk"][rel] = apk
|
||||
|
||||
images = _parse_base_images(text)
|
||||
if images:
|
||||
manifest["base_images"][rel] = images
|
||||
|
||||
# uv add in Dockerfiles (notebook pattern)
|
||||
uv_pkgs = _parse_uv_add_packages(text)
|
||||
if uv_pkgs:
|
||||
manifest["pypi"]["notebook"].extend(uv_pkgs)
|
||||
|
||||
# pip install in Dockerfiles
|
||||
pip_pkgs = _parse_pip_install_packages(text)
|
||||
if pip_pkgs:
|
||||
manifest["pypi"]["dockerfile_adhoc"].extend(pip_pkgs)
|
||||
|
||||
# uv run --with
|
||||
with_pkgs = _parse_uv_run_with_packages(text)
|
||||
if with_pkgs:
|
||||
manifest["pypi"]["dockerfile_adhoc"].extend(with_pkgs)
|
||||
|
||||
# --- pyproject.toml ---
|
||||
pyproject = ROOT / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
prod, dev = _parse_pyproject_deps(pyproject.read_text())
|
||||
manifest["pypi"]["project_prod"] = prod
|
||||
manifest["pypi"]["project_dev"] = dev
|
||||
|
||||
# --- package.json (docs) ---
|
||||
pkg_json = ROOT / "docs" / "package.json"
|
||||
if pkg_json.exists():
|
||||
manifest["npm"] = _parse_package_json_deps(pkg_json.read_text())
|
||||
|
||||
# --- CI workflows ---
|
||||
ci_tools_seen: set[str] = set()
|
||||
for wf in sorted((ROOT / ".gitea" / "workflows").glob("*.yml")):
|
||||
text = wf.read_text()
|
||||
for tool in _parse_ci_curl_tools(text):
|
||||
if tool["name"] not in ci_tools_seen:
|
||||
ci_tools_seen.add(tool["name"])
|
||||
manifest["ci_tools"].append(tool)
|
||||
|
||||
# --- Deduplicate & sort ---
|
||||
for section in ("notebook", "dockerfile_adhoc"):
|
||||
seen: set[str] = set()
|
||||
deduped: list[dict] = []
|
||||
for pkg in manifest["pypi"][section]:
|
||||
if pkg["name"] not in seen:
|
||||
seen.add(pkg["name"])
|
||||
deduped.append(pkg)
|
||||
manifest["pypi"][section] = sorted(deduped, key=lambda p: p["name"])
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = scan()
|
||||
output = json.dumps(manifest, indent=2, sort_keys=False) + "\n"
|
||||
|
||||
if "--check" in sys.argv:
|
||||
if MANIFEST_PATH.exists():
|
||||
existing = MANIFEST_PATH.read_text()
|
||||
if existing == output:
|
||||
print("pkg-manifest.json is up to date.")
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
print("pkg-manifest.json is STALE — re-run without --check.")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
print("pkg-manifest.json does not exist — run without --check first.")
|
||||
raise SystemExit(1)
|
||||
|
||||
MANIFEST_PATH.write_text(output)
|
||||
# Summary
|
||||
apt_count = sum(len(v) for v in manifest["apt"].values())
|
||||
apk_count = sum(len(v) for v in manifest["apk"].values())
|
||||
pypi_count = sum(
|
||||
len(manifest["pypi"][k])
|
||||
for k in ("project_prod", "project_dev", "notebook", "dockerfile_adhoc")
|
||||
)
|
||||
npm_count = len(manifest["npm"])
|
||||
ci_count = len(manifest["ci_tools"])
|
||||
img_count = sum(len(v) for v in manifest["base_images"].values())
|
||||
print(f"Wrote {MANIFEST_PATH.relative_to(ROOT)}")
|
||||
print(
|
||||
f" apt: {apt_count} apk: {apk_count} pypi: {pypi_count}"
|
||||
f" npm: {npm_count} ci_tools: {ci_count} base_images: {img_count}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user