fix(ci): enforce fail-closed verification gate and redact JUnit XML secrets

This commit is contained in:
Alexandre Teixeira
2026-10-31 00:23:16 +00:00
parent 84c7eabfce
commit 8257c661ad
6 changed files with 557 additions and 86 deletions
+290 -73
View File
@@ -11,6 +11,8 @@ import argparse
import json
import logging
import os
import re
import shutil
import sys
import tempfile
import xml.etree.ElementTree as ET
@@ -26,9 +28,10 @@ try:
except ImportError:
# If import fails, define fail-closed fallback
def sanitize_trace_archive(zp: str) -> bool:
if os.path.exists(zp):
p = Path(zp)
if p.exists():
try:
os.remove(zp)
p.unlink(missing_ok=True)
except OSError:
pass
return False
@@ -38,8 +41,193 @@ logger = logging.getLogger("verify_sanitized_artifacts")
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
ALLOWED_JUNIT_ROOT_TAGS = frozenset({"testsuites", "testsuite"})
ALLOWED_JUNIT_ELEMENT_TAGS = frozenset({
"testsuites",
"testsuite",
"testcase",
"failure",
"error",
"skipped",
"system-out",
"system-err",
"properties",
"property",
})
def is_valid_disposable_ci_fixture() -> bool:
SENSITIVE_KEY_PATTERN = re.compile(
r"(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
r"private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)"
)
SECRET_REDACTION_PATTERNS = [
# 1. Authorization headers (Bearer / Basic)
re.compile(r"(?i)\b(bearer|basic)\s+([^\s,;\'\"<>]+)"),
# 2. Sensitive key-value pairs (key=value or key: value)
re.compile(
r"(?i)\b([a-z0-9_.-]*(?:password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
r"auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)[a-z0-9_.-]*)"
r"\s*([:=])\s*([^\s,;\'\"<>]+)"
),
# 3. URL credentials (scheme://user:pass@host)
re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://[^:\s]+):([^@\s]+)@"),
# 4. JSON-formatted key-value pairs
re.compile(
r'(?i)("?(?:[a-z0-9_.-]*(?:password|passwd|secret|token|key|auth)[a-z0-9_.-]*)"?\s*:\s*")([^"]+)(")'
),
# 5. Synthetic secret keywords or tokens
re.compile(r"(?i)\b[a-z0-9_.-]*synthetic[-_]?[a-z0-9_.-]*\b"),
]
def safe_remove(filepath: str | Path) -> bool:
"""Safely remove a file or directory fail-closed.
Returns True if the target does not exist or was successfully removed.
Returns False if removal failed and the target still exists on disk.
"""
p = Path(filepath)
if not p.exists():
return True
try:
if p.is_dir():
shutil.rmtree(p, ignore_errors=False)
else:
p.unlink(missing_ok=True)
except OSError as exc:
logger.error("Failed to delete unauthorized file %s: %s", filepath, exc)
return False
if p.exists():
logger.error("Target %s still exists on disk after deletion attempt", filepath)
return False
return True
def sanitize_text_content(text: str) -> tuple[str, bool]:
"""Scrub sensitive credentials, tokens, and synthetic secrets from text."""
if not text or not isinstance(text, str):
return text, False
modified = False
out = text
# 1. Replace Bearer / Basic tokens
def _sub_auth(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)} [REDACTED]"
out = SECRET_REDACTION_PATTERNS[0].sub(_sub_auth, out)
# 2. Replace key-value pairs
def _sub_kv(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}{m.group(2)}[REDACTED]"
out = SECRET_REDACTION_PATTERNS[1].sub(_sub_kv, out)
# 3. Replace URL credentials
def _sub_url(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}:[REDACTED]@"
out = SECRET_REDACTION_PATTERNS[2].sub(_sub_url, out)
# 4. Replace JSON key-value secrets
def _sub_json(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}[REDACTED]{m.group(3)}"
out = SECRET_REDACTION_PATTERNS[3].sub(_sub_json, out)
# 5. Replace synthetic secret markers
def _sub_synthetic(m: re.Match) -> str:
nonlocal modified
modified = True
return "[REDACTED]"
out = SECRET_REDACTION_PATTERNS[4].sub(_sub_synthetic, out)
return out, modified
def sanitize_and_verify_junit_xml(filepath: str | Path) -> bool:
"""Verify that an XML file is an authentic JUnit report and redact credentials.
Returns True if the XML file is a valid recognized JUnit report and was safely sanitized.
Returns False if the file is invalid, unrecognized, corrupted, or failed sanitization.
"""
p = Path(filepath)
name_lower = p.name.lower()
# 1. Filename restriction: must match standard JUnit report naming
if not (name_lower == "junit.xml" or name_lower == "browser-junit.xml" or name_lower.endswith("-junit.xml") or name_lower.endswith(".junit.xml")):
logger.warning("Unrecognized XML report filename %s; not an authorized JUnit report", filepath)
return False
# 2. Parse XML
try:
tree = ET.parse(p)
except Exception as exc:
logger.warning("Corrupted XML report %s: %s", filepath, exc)
return False
root = tree.getroot()
# 3. Root tag restriction
if root.tag.lower() not in ALLOWED_JUNIT_ROOT_TAGS:
logger.warning("XML report %s has invalid root element <%s>; not a JUnit report", filepath, root.tag)
return False
# 4. Check element tags and redact sensitive content
file_modified = False
for elem in tree.iter():
if elem.tag.lower() not in ALLOWED_JUNIT_ELEMENT_TAGS:
logger.warning("XML report %s contains unrecognized element tag <%s>", filepath, elem.tag)
return False
# Sanitize attributes
for k, v in list(elem.attrib.items()):
if SENSITIVE_KEY_PATTERN.search(k.lower()):
elem.attrib[k] = "[REDACTED]"
file_modified = True
elif isinstance(v, str):
new_v, changed = sanitize_text_content(v)
if changed:
elem.attrib[k] = new_v
file_modified = True
# Sanitize text
if elem.text:
new_text, changed = sanitize_text_content(elem.text)
if changed:
elem.text = new_text
file_modified = True
# Sanitize tail
if elem.tail:
new_tail, changed = sanitize_text_content(elem.tail)
if changed:
elem.tail = new_tail
file_modified = True
# 5. If content was sanitized, write back safely
if file_modified:
try:
tree.write(str(p), encoding="utf-8", xml_declaration=True)
# Verify rewritten XML parses cleanly
ET.parse(p)
except Exception as exc:
logger.error("Failed to write sanitized XML report %s: %s", filepath, exc)
return False
return True
def is_valid_disposable_ci_fixture(target_dir: str | Path | None = None) -> bool:
"""Verify that execution ran inside an authorized disposable synthetic fixture."""
is_ci = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
if not is_ci:
@@ -52,82 +240,104 @@ def is_valid_disposable_ci_fixture() -> bool:
if os.environ.get("PHOTO_EDITOR_E2E_AUTH") == "0":
return False
data_dir = os.environ.get("PHOTO_EDITOR_E2E_DATA_DIR")
if not data_dir:
return False
resolved = os.path.abspath(data_dir)
base_name = os.path.basename(resolved)
if not base_name.startswith("odysseus-photo-editor-e2e-"):
return False
allowed_tmp = [os.path.abspath(p) for p in (tempfile.gettempdir(), "/tmp", "/var/tmp")]
if not any(resolved.startswith(b + os.sep) or resolved == b for b in allowed_tmp):
return False
db_path = os.environ.get("PHOTO_EDITOR_E2E_DB_PATH")
if db_path != os.path.join(resolved, "app.db"):
return False
# Check 1: Verify fixture ownership via PHOTO_EDITOR_E2E_DATA_DIR
data_dir = os.environ.get("PHOTO_EDITOR_E2E_DATA_DIR")
if data_dir:
resolved = os.path.abspath(data_dir)
base_name = os.path.basename(resolved)
is_tmp = any(resolved.startswith(b + os.sep) or resolved == b for b in allowed_tmp)
if base_name.startswith("odysseus-photo-editor-e2e-") and is_tmp:
expected_db = os.path.join(resolved, "app.db")
db_path = os.environ.get("PHOTO_EDITOR_E2E_DB_PATH")
if not db_path or db_path == expected_db:
marker_path = os.path.join(resolved, ".disposable-ci-fixture.json")
if os.path.exists(marker_path):
try:
with open(marker_path, "r", encoding="utf-8") as f:
fixture = json.load(f)
if (
fixture.get("synthetic") is True
and fixture.get("authenticated") is True
and fixture.get("database") == expected_db
):
return True
except Exception:
pass
marker_path = os.path.join(resolved, ".disposable-ci-fixture.json")
if not os.path.exists(marker_path):
return False
# Check 2: Verify fixture proof in target_dir if provided
if target_dir:
target_marker = os.path.join(str(target_dir), ".disposable-ci-fixture.json")
if os.path.exists(target_marker):
try:
with open(target_marker, "r", encoding="utf-8") as f:
fixture = json.load(f)
db_in_fixture = fixture.get("database", "")
resolved_db = os.path.abspath(db_in_fixture)
db_dir = os.path.dirname(resolved_db)
db_base = os.path.basename(db_dir)
is_tmp = any(db_dir.startswith(b + os.sep) or db_dir == b for b in allowed_tmp)
if (
fixture.get("synthetic") is True
and fixture.get("authenticated") is True
and db_base.startswith("odysseus-photo-editor-e2e-")
and is_tmp
and os.path.basename(resolved_db) == "app.db"
):
return True
except Exception:
pass
try:
with open(marker_path, "r", encoding="utf-8") as f:
fixture = json.load(f)
return (
fixture.get("synthetic") is True
and fixture.get("authenticated") is True
and fixture.get("database") == os.path.join(resolved, "app.db")
)
except Exception:
return False
return False
def verify_and_sanitize_artifacts(target_dir: str) -> bool:
"""Independently verify and sanitize diagnostics fail-closed, preserving safe JUnit XML.
Returns True if verification succeeded, False if fail-closed purge was required.
Returns True if verification succeeded and artifacts are safe for publication.
Returns False if verification failed or deletion could not be confirmed.
"""
if not os.path.exists(target_dir):
logger.info("Target directory %s does not exist; nothing to sanitize.", target_dir)
return True
disposable_ci = is_valid_disposable_ci_fixture()
disposable_ci = is_valid_disposable_ci_fixture(target_dir)
preserved_xmls = 0
sanitized_archives = 0
purged_files = 0
overall_success = True
try:
for root, _, files in os.walk(target_dir):
for root, dirs, files in os.walk(target_dir, topdown=False):
for filename in files:
filepath = os.path.join(root, filename)
name_lower = filename.lower()
# 1. Safe JUnit reporting: preserve valid XML reports
# Cleanup disposable fixture marker so it is not published
if filename == ".disposable-ci-fixture.json":
if not safe_remove(filepath):
overall_success = False
continue
# 1. XML reports: verify JUnit structure and sanitize credentials
if name_lower.endswith(".xml"):
try:
ET.parse(filepath)
if sanitize_and_verify_junit_xml(filepath):
preserved_xmls += 1
continue
except Exception as exc:
logger.warning("Corrupted XML report %s: %s; purging fail-closed", filepath, exc)
try:
os.remove(filepath)
purged_files += 1
except OSError:
pass
else:
logger.warning("Rejecting invalid/unrecognized XML report %s; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
continue
# 2. Check if diagnostics are authorized (disposable synthetic fixture)
if not disposable_ci:
# In CI without disposable fixture, or local without opt-in: purge all diagnostics
logger.info("Purging diagnostic artifact %s (disposable CI fixture not verified)", filepath)
try:
os.remove(filepath)
purged_files += 1
except OSError:
pass
if not safe_remove(filepath):
overall_success = False
purged_files += 1
continue
# 3. Disposable CI is authorized: enforce strict sanitization
@@ -136,38 +346,49 @@ def verify_and_sanitize_artifacts(target_dir: str) -> bool:
if ok and os.path.exists(filepath):
sanitized_archives += 1
else:
logger.warning("Trace archive %s failed sanitization; purged fail-closed", filepath)
if os.path.exists(filepath):
try:
os.remove(filepath)
except OSError:
pass
logger.warning("Trace archive %s failed sanitization; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
elif name_lower.endswith(".png"):
# Validate PNG magic bytes to ensure not a secret payload disguised as an image
is_png = False
try:
with open(filepath, "rb") as pf:
header = pf.read(8)
if header == PNG_MAGIC:
continue
is_png = True
except Exception:
pass
logger.warning("Invalid or suspicious screenshot %s; purged fail-closed", filepath)
try:
os.remove(filepath)
purged_files += 1
except OSError:
pass
is_png = False
if is_png:
continue
logger.warning("Invalid or suspicious screenshot %s; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
else:
# Purge any .tmp, .har, .db, .sqlite, .log or unrecognized files
logger.info("Purging unauthorized diagnostic file %s", filepath)
try:
os.remove(filepath)
purged_files += 1
except OSError:
pass
if not safe_remove(filepath):
overall_success = False
purged_files += 1
# Remove empty subdirectories
for d in dirs:
dirpath = os.path.join(root, d)
try:
if not os.listdir(dirpath):
os.rmdir(dirpath)
except OSError:
pass
if not overall_success:
logger.error("Verification failed: one or more unauthorized files could not be purged.")
return False
logger.info(
"Artifact verification complete for %s: preserved %d XML reports, sanitized %d archives, purged %d files.",
@@ -183,10 +404,7 @@ def verify_and_sanitize_artifacts(target_dir: str) -> bool:
try:
for p in Path(target_dir).rglob("*"):
if p.is_file() and not p.name.lower().endswith(".xml"):
try:
p.unlink(missing_ok=True)
except OSError:
pass
safe_remove(p)
except Exception:
pass
return False
@@ -198,8 +416,7 @@ def main() -> None:
args = parser.parse_args()
ok = verify_and_sanitize_artifacts(args.target_dir)
# Always exit 0 so upload-artifact can safely publish verified reports
sys.exit(0 if ok else 0)
sys.exit(0 if ok else 1)
if __name__ == "__main__":