Harden audit tooling and release helpers
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 1m14s
Security Audit / security-audit (push) Failing after 31s

This commit is contained in:
2026-07-11 18:37:51 +02:00
parent 54ab1945ff
commit 342582645b
3 changed files with 87 additions and 12 deletions

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MODE="${SECURITY_AUDIT_MODE:-ci}"
SCOPE="${SECURITY_AUDIT_SCOPE:-current}"
REPORTS_DIR="${SECURITY_AUDIT_REPORTS_DIR:-$ROOT/audit-reports}"
@@ -95,6 +95,14 @@ if [[ "${#REPOS[@]}" -eq 0 ]]; then
exit 1
fi
echo "Security audit mode: $MODE"
echo "Security audit scope: $SCOPE"
echo "Security audit reports: $REPORTS_DIR"
echo "Security audit repositories: ${#REPOS[@]}"
if [[ "${#REPOS[@]}" -le 12 ]]; then
printf ' %s\n' "${REPOS[@]}"
fi
declare -a PY_ROOTS=()
for repo in "${REPOS[@]}"; do
[[ -d "$repo/src" ]] && PY_ROOTS+=("$repo/src")

View File

@@ -31,7 +31,7 @@ class RepoTarget:
@property
def api_base(self) -> str:
return f"{self.base_url.rstrip('/')}/api/v1"
return f"{validated_http_base_url(self.base_url)}/api/v1"
def repo_root(start: pathlib.Path) -> pathlib.Path:
@@ -80,6 +80,15 @@ def quote_path(value: str) -> str:
return urllib.parse.quote(value, safe="")
def validated_http_base_url(value: str) -> str:
parsed = urllib.parse.urlparse(value.rstrip("/"))
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise GiteaError("Gitea URL must use http:// or https:// with a hostname.")
if parsed.username or parsed.password:
raise GiteaError("Gitea URL must not include embedded credentials.")
return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", ""))
class GiteaClient:
def __init__(self, target: RepoTarget, token: str | None) -> None:
self.target = target
@@ -110,7 +119,7 @@ class GiteaClient:
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=30) as response:
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - validated Gitea http(s) API URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
payload = response.read().decode("utf-8")
if not payload:
return None

View File

@@ -16,6 +16,7 @@ import sys
import tomllib
from typing import Any
from urllib.error import URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
@@ -32,6 +33,7 @@ class SuggestedCommand:
title: str
command: str
cwd: str
argv: tuple[str, ...] = ()
mutating: bool = False
reason: str = ""
@@ -897,7 +899,12 @@ def run_suggested_command(item: SuggestedCommand, *, assume_yes: bool = False) -
if confirmation not in {"y", "yes"}:
print("Skipped.")
return None
result = subprocess.run(item.command, shell=True, cwd=item.cwd) # noqa: S602 - only user-confirmed doctor commands are executed.
try:
argv = command_argv(item)
except ValueError as exc:
print(f"Cannot run command safely: {exc}")
return 2
result = subprocess.run(argv, cwd=item.cwd, check=False)
print(f"Command exited with {result.returncode}.")
return result.returncode
@@ -950,7 +957,7 @@ def run_safe_directory_workflow(report: DoctorReport) -> None:
return
for repo in repos:
assert repo.safe_directory_command is not None
result = subprocess.run(repo.safe_directory_command, shell=True, cwd=ROOT) # noqa: S602 - generated git config command, user-confirmed.
result = subprocess.run(safe_directory_argv(Path(repo.path)), cwd=ROOT, check=False)
print(f"{repo.name}: command exited with {result.returncode}.")
@@ -1020,9 +1027,8 @@ def commit_and_push_dirty_repositories(report: DoctorReport) -> None:
def page_text(text: str) -> None:
if sys.stdout.isatty():
pager = os.environ.get("PAGER", "less -R")
try:
result = subprocess.run(pager, input=text, shell=True, text=True, check=False) # noqa: S602 - user-configured pager.
result = subprocess.run(["less", "-R"], input=text, text=True, check=False)
if result.returncode != 127:
return
except OSError:
@@ -1034,28 +1040,61 @@ def report_to_dict(report: DoctorReport) -> dict[str, Any]:
return asdict(report)
def command(cwd: Path, title: str, value: str, *, mutating: bool = False, reason: str = "") -> SuggestedCommand:
return SuggestedCommand(title=title, command=value, cwd=str(cwd), mutating=mutating, reason=reason)
def command(
cwd: Path,
title: str,
value: str,
*,
mutating: bool = False,
reason: str = "",
argv: tuple[str, ...] = (),
) -> SuggestedCommand:
return SuggestedCommand(title=title, command=value, cwd=str(cwd), argv=argv, mutating=mutating, reason=reason)
def command_argv(item: SuggestedCommand) -> tuple[str, ...]:
if item.argv:
return item.argv
argv = tuple(shlex.split(item.command))
if not argv:
raise ValueError("empty command")
if any(_looks_like_shell_syntax(part) for part in argv):
raise ValueError("command contains shell syntax and needs an explicit argv definition")
return argv
def _looks_like_shell_syntax(value: str) -> bool:
return any(marker in value for marker in ("|", "&&", "||", ";", "$(", "`", ">", "<"))
def release_tag_command(report: DoctorReport, *, dry_run: bool) -> SuggestedCommand:
assert report.target_version is not None
suffix = " --dry-run" if dry_run else ""
argv = (
"tools/release/push-release-tag.sh",
"--version",
report.target_version,
"--strict-migration-audit",
) + (("--dry-run",) if dry_run else ())
return command(
META_ROOT,
"Preview release tagging workflow" if dry_run else "Run release tagging workflow",
f"tools/release/push-release-tag.sh --version {shlex.quote(report.target_version)} --strict-migration-audit{suffix}",
mutating=not dry_run,
argv=argv,
)
def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand:
extra = " --strict" if strict else ""
return command(META_ROOT, f"Run {track} migration audit", f"{python_bin()} tools/release/release-migration-audit.py --track {track}{extra}")
argv = (python_bin_path(), "tools/release/release-migration-audit.py", "--track", track) + (("--strict",) if strict else ())
return command(META_ROOT, f"Run {track} migration audit", f"{python_bin()} tools/release/release-migration-audit.py --track {track}{extra}", argv=argv)
def publish_catalog_command(target_version: str | None) -> SuggestedCommand:
version = shlex.quote(target_version or "<x.y.z>")
display_version = target_version or "<x.y.z>"
key_dir = Path.home() / ".config" / "govoplan" / "release-keys"
return command(
META_ROOT,
"Publish signed release catalog",
@@ -1063,12 +1102,24 @@ def publish_catalog_command(target_version: str | None) -> SuggestedCommand:
f"tools/release/publish-release-catalog.sh --version {version} "
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web",
mutating=True,
argv=(
"tools/release/publish-release-catalog.sh",
"--version",
display_version,
"--catalog-signing-key",
f"release-key-1={key_dir / 'release-key-1.pem'}",
"--build-web",
),
)
def python_bin() -> str:
return shlex.quote(python_bin_path())
def python_bin_path() -> str:
local = META_ROOT / ".venv" / "bin" / "python"
return shlex.quote(str(local if local.exists() else Path(sys.executable)))
return str(local if local.exists() else Path(sys.executable))
def normalize_version(value: str) -> str:
@@ -1152,10 +1203,17 @@ def safe_directory_command(path: Path) -> str:
return "git config --global --add safe.directory " + shlex.quote(str(path.resolve()))
def safe_directory_argv(path: Path) -> list[str]:
return ["git", "config", "--global", "--add", "safe.directory", str(path.resolve())]
def check_url(url: str) -> dict[str, Any]:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return {"ok": False, "error": "URL must use http:// or https:// with a hostname", "url": url}
request = Request(url, method="HEAD")
try:
with urlopen(request, timeout=8) as response: # noqa: S310 - operator-requested release availability check.
with urlopen(request, timeout=8) as response: # noqa: S310 - validated operator-requested release availability check. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return {"ok": 200 <= response.status < 400, "status": response.status, "url": url}
except URLError as exc:
return {"ok": False, "error": str(exc), "url": url}