Compare commits

...

3 Commits

Author SHA1 Message Date
17f8036bdc Harden release console token bootstrap
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 14s
Security Audit / security-audit (push) Failing after 13s
2026-07-21 03:16:57 +02:00
fcc5885dcf Validate manifest module imports 2026-07-21 03:16:57 +02:00
0b1506f860 Cover live worktrees in security audits 2026-07-21 03:16:57 +02:00
6 changed files with 85 additions and 8 deletions

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from fastapi.testclient import TestClient
META_ROOT = Path(__file__).resolve().parents[1]
RELEASE_ROOT = META_ROOT / "tools" / "release"
if str(RELEASE_ROOT) not in sys.path:
sys.path.insert(0, str(RELEASE_ROOT))
from server.app import create_app # noqa: E402
class ReleaseConsoleSecurityTests(unittest.TestCase):
def test_api_token_is_accepted_only_from_header(self) -> None:
with TestClient(create_app(workspace_root=META_ROOT, token="test-console-token")) as client:
query_response = client.get("/api/health?token=test-console-token")
header_response = client.get(
"/api/health",
headers={"X-Release-Console-Token": "test-console-token"},
)
self.assertEqual(query_response.status_code, 401)
self.assertEqual(header_response.status_code, 200)
def test_bootstrap_token_uses_and_clears_url_fragment(self) -> None:
launcher = (RELEASE_ROOT / "release-console.py").read_text(encoding="utf-8")
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
self.assertIn("#token={token}", launcher)
self.assertNotIn("?token={token}", launcher)
self.assertIn("window.location.hash.slice(1)", webui)
self.assertIn("history.replaceState", webui)
if __name__ == "__main__":
unittest.main()

View File

@@ -6,12 +6,16 @@ from __future__ import annotations
import argparse import argparse
import importlib import importlib
import json import json
import re
import sys import sys
import tomllib import tomllib
from pathlib import Path from pathlib import Path
META_ROOT = Path(__file__).resolve().parents[2] META_ROOT = Path(__file__).resolve().parents[2]
MODULE_NAME_PATTERN = re.compile(
r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
)
def main() -> int: def main() -> int:
@@ -56,8 +60,15 @@ def main() -> int:
errors: list[str] = [] errors: list[str] = []
for repository_name, source_root, manifest_path in manifest_sources: for repository_name, source_root, manifest_path in manifest_sources:
module_name = ".".join(manifest_path.relative_to(source_root).with_suffix("").parts) module_name = ".".join(manifest_path.relative_to(source_root).with_suffix("").parts)
if MODULE_NAME_PATTERN.fullmatch(module_name) is None:
errors.append(
f"{repository_name}: manifest path does not map to a safe Python module name: {module_name!r}"
)
continue
try: try:
loaded_module = importlib.import_module(module_name) # Module names are derived from repository-owned manifest paths and
# constrained to canonical Python identifiers immediately above.
loaded_module = importlib.import_module(module_name) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
get_manifest = getattr(loaded_module, "get_manifest") get_manifest = getattr(loaded_module, "get_manifest")
manifest = get_manifest() manifest = get_manifest()
except Exception as exc: # pragma: no cover - rendered as a check failure except Exception as exc: # pragma: no cover - rendered as a check failure

View File

@@ -180,13 +180,17 @@ run_semgrep() {
fi fi
semgrep scan \ semgrep scan \
--metrics=off \ --metrics=off \
--no-git-ignore \
--sarif \ --sarif \
--output "$report" \ --output "$report" \
--exclude .git \
--exclude node_modules \ --exclude node_modules \
--exclude .venv \ --exclude .venv \
--exclude dist \ --exclude dist \
--exclude build \ --exclude build \
--exclude runtime \ --exclude runtime \
--exclude audit-reports \
--exclude '**/.*-test-build/**' \
"${config_args[@]}" \ "${config_args[@]}" \
"${REPOS[@]}" "${REPOS[@]}"
} }
@@ -222,7 +226,16 @@ run_gitleaks() {
gitleaks git \ gitleaks git \
--config "$ROOT/.gitleaks.toml" \ --config "$ROOT/.gitleaks.toml" \
--report-format json \ --report-format json \
--report-path "$REPORTS_DIR/gitleaks-$name.json" \ --report-path "$REPORTS_DIR/gitleaks-history-$name.json" \
--no-banner \
"$repo" || status=1
# History scanning does not include new or modified working-tree files.
# Scan the directory as well so pre-commit audits cover the exact code
# under review, while retaining the history scan above.
gitleaks dir \
--config "$ROOT/.gitleaks.toml" \
--report-format json \
--report-path "$REPORTS_DIR/gitleaks-worktree-$name.json" \
--no-banner \ --no-banner \
"$repo" || status=1 "$repo" || status=1
else else
@@ -261,7 +274,9 @@ run_pip_audit_manifests() {
[[ -f "$repo/requirements.txt" ]] || continue [[ -f "$repo/requirements.txt" ]] || continue
local name local name
name="$(safe_name "$repo")" name="$(safe_name "$repo")"
pip-audit -r "$repo/requirements.txt" --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json" || status=1 # Requirements may contain project-relative entries such as `.[server]`.
# Resolve them from the owning repository instead of the meta-repository.
(cd "$repo" && pip-audit -r requirements.txt --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json") || status=1
done done
return "$status" return "$status"
} }
@@ -283,7 +298,11 @@ run_osv_scanner() {
local name local name
name="$(safe_name "$repo")" name="$(safe_name "$repo")"
if osv-scanner scan --help >/dev/null 2>&1; then if osv-scanner scan --help >/dev/null 2>&1; then
osv-scanner scan -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1 osv-scanner scan -r \
--allow-no-lockfiles \
--format json \
--output-file "$REPORTS_DIR/osv-scanner-$name.json" \
"$repo" || status=1
else else
osv-scanner -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1 osv-scanner -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1
fi fi

View File

@@ -32,7 +32,9 @@ def main() -> int:
app = create_app(workspace_root=workspace_root, token=token) app = create_app(workspace_root=workspace_root, token=token)
url = f"http://{args.host}:{args.port}/" url = f"http://{args.host}:{args.port}/"
if token: if token:
url = f"{url}?token={token}" # URL fragments are never sent in HTTP requests. The WebUI transfers
# this one-time bootstrap token to sessionStorage and clears the hash.
url = f"{url}#token={token}"
print("GovOPlaN release console") print("GovOPlaN release console")
print(f" workspace: {workspace_root}") print(f" workspace: {workspace_root}")
print(f" url: {url}") print(f" url: {url}")

View File

@@ -85,7 +85,7 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
@app.middleware("http") @app.middleware("http")
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def] async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
if token and request.url.path.startswith("/api/"): if token and request.url.path.startswith("/api/"):
provided = request.headers.get("x-release-console-token") or request.query_params.get("token") provided = request.headers.get("x-release-console-token")
if provided != token: if provided != token:
return JSONResponse({"detail": "release console token required"}, status_code=401) return JSONResponse({"detail": "release console token required"}, status_code=401)
return await call_next(request) return await call_next(request)

View File

@@ -768,11 +768,15 @@
</div> </div>
<script> <script>
const params = new URLSearchParams(window.location.search); const fragmentParams = new URLSearchParams(window.location.hash.slice(1));
const token = params.get("token") || sessionStorage.getItem("releaseConsoleToken") || ""; const fragmentToken = fragmentParams.get("token") || "";
const token = fragmentToken || sessionStorage.getItem("releaseConsoleToken") || "";
if (token) { if (token) {
sessionStorage.setItem("releaseConsoleToken", token); sessionStorage.setItem("releaseConsoleToken", token);
} }
if (fragmentToken) {
history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}`);
}
const elements = { const elements = {
channel: document.getElementById("channel"), channel: document.getElementById("channel"),