Harden audit tooling and release helpers
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
MODE="${SECURITY_AUDIT_MODE:-ci}"
|
MODE="${SECURITY_AUDIT_MODE:-ci}"
|
||||||
SCOPE="${SECURITY_AUDIT_SCOPE:-current}"
|
SCOPE="${SECURITY_AUDIT_SCOPE:-current}"
|
||||||
REPORTS_DIR="${SECURITY_AUDIT_REPORTS_DIR:-$ROOT/audit-reports}"
|
REPORTS_DIR="${SECURITY_AUDIT_REPORTS_DIR:-$ROOT/audit-reports}"
|
||||||
@@ -95,6 +95,14 @@ if [[ "${#REPOS[@]}" -eq 0 ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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=()
|
declare -a PY_ROOTS=()
|
||||||
for repo in "${REPOS[@]}"; do
|
for repo in "${REPOS[@]}"; do
|
||||||
[[ -d "$repo/src" ]] && PY_ROOTS+=("$repo/src")
|
[[ -d "$repo/src" ]] && PY_ROOTS+=("$repo/src")
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class RepoTarget:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def api_base(self) -> str:
|
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:
|
def repo_root(start: pathlib.Path) -> pathlib.Path:
|
||||||
@@ -80,6 +80,15 @@ def quote_path(value: str) -> str:
|
|||||||
return urllib.parse.quote(value, safe="")
|
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:
|
class GiteaClient:
|
||||||
def __init__(self, target: RepoTarget, token: str | None) -> None:
|
def __init__(self, target: RepoTarget, token: str | None) -> None:
|
||||||
self.target = target
|
self.target = target
|
||||||
@@ -110,7 +119,7 @@ class GiteaClient:
|
|||||||
|
|
||||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
try:
|
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")
|
payload = response.read().decode("utf-8")
|
||||||
if not payload:
|
if not payload:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import sys
|
|||||||
import tomllib
|
import tomllib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import URLError
|
from urllib.error import URLError
|
||||||
|
from urllib.parse import urlparse
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class SuggestedCommand:
|
|||||||
title: str
|
title: str
|
||||||
command: str
|
command: str
|
||||||
cwd: str
|
cwd: str
|
||||||
|
argv: tuple[str, ...] = ()
|
||||||
mutating: bool = False
|
mutating: bool = False
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
@@ -897,7 +899,12 @@ def run_suggested_command(item: SuggestedCommand, *, assume_yes: bool = False) -
|
|||||||
if confirmation not in {"y", "yes"}:
|
if confirmation not in {"y", "yes"}:
|
||||||
print("Skipped.")
|
print("Skipped.")
|
||||||
return None
|
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}.")
|
print(f"Command exited with {result.returncode}.")
|
||||||
return result.returncode
|
return result.returncode
|
||||||
|
|
||||||
@@ -950,7 +957,7 @@ def run_safe_directory_workflow(report: DoctorReport) -> None:
|
|||||||
return
|
return
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
assert repo.safe_directory_command is not None
|
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}.")
|
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:
|
def page_text(text: str) -> None:
|
||||||
if sys.stdout.isatty():
|
if sys.stdout.isatty():
|
||||||
pager = os.environ.get("PAGER", "less -R")
|
|
||||||
try:
|
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:
|
if result.returncode != 127:
|
||||||
return
|
return
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1034,28 +1040,61 @@ def report_to_dict(report: DoctorReport) -> dict[str, Any]:
|
|||||||
return asdict(report)
|
return asdict(report)
|
||||||
|
|
||||||
|
|
||||||
def command(cwd: Path, title: str, value: str, *, mutating: bool = False, reason: str = "") -> SuggestedCommand:
|
def command(
|
||||||
return SuggestedCommand(title=title, command=value, cwd=str(cwd), mutating=mutating, reason=reason)
|
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:
|
def release_tag_command(report: DoctorReport, *, dry_run: bool) -> SuggestedCommand:
|
||||||
assert report.target_version is not None
|
assert report.target_version is not None
|
||||||
suffix = " --dry-run" if dry_run else ""
|
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(
|
return command(
|
||||||
META_ROOT,
|
META_ROOT,
|
||||||
"Preview release tagging workflow" if dry_run else "Run release tagging workflow",
|
"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}",
|
f"tools/release/push-release-tag.sh --version {shlex.quote(report.target_version)} --strict-migration-audit{suffix}",
|
||||||
mutating=not dry_run,
|
mutating=not dry_run,
|
||||||
|
argv=argv,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand:
|
def migration_audit_command(track: str, *, strict: bool = False) -> SuggestedCommand:
|
||||||
extra = " --strict" if strict else ""
|
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:
|
def publish_catalog_command(target_version: str | None) -> SuggestedCommand:
|
||||||
version = shlex.quote(target_version or "<x.y.z>")
|
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(
|
return command(
|
||||||
META_ROOT,
|
META_ROOT,
|
||||||
"Publish signed release catalog",
|
"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} "
|
f"tools/release/publish-release-catalog.sh --version {version} "
|
||||||
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web",
|
"--catalog-signing-key \"release-key-1=$KEY_DIR/release-key-1.pem\" --build-web",
|
||||||
mutating=True,
|
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:
|
def python_bin() -> str:
|
||||||
|
return shlex.quote(python_bin_path())
|
||||||
|
|
||||||
|
|
||||||
|
def python_bin_path() -> str:
|
||||||
local = META_ROOT / ".venv" / "bin" / "python"
|
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:
|
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()))
|
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]:
|
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")
|
request = Request(url, method="HEAD")
|
||||||
try:
|
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}
|
return {"ok": 200 <= response.status < 400, "status": response.status, "url": url}
|
||||||
except URLError as exc:
|
except URLError as exc:
|
||||||
return {"ok": False, "error": str(exc), "url": url}
|
return {"ok": False, "error": str(exc), "url": url}
|
||||||
|
|||||||
Reference in New Issue
Block a user