#!/usr/bin/env python3 """Build a reviewable, local CI evidence pack from sanitized logs. The pack contains copies of the supplied logs, a deterministic comparison report, a JSON summary, and a manifest of SHA-256 hashes. No network access is used. Inputs must already be sanitized; likely secret markers cause a refusal. """ from __future__ import annotations import argparse import hashlib import json import shutil import sys from datetime import datetime, timezone from pathlib import Path import compare_ci_runs as comparator def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open('rb') as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b''): digest.update(chunk) return digest.hexdigest() def safe_copy(source: Path, destination: Path) -> None: destination.write_bytes(source.read_bytes()) def build_pack(passing_path: Path, failing_path: Path, output_dir: Path, *, limit: int, max_bytes: int) -> dict: if output_dir.exists(): raise ValueError(f'{output_dir}: output directory already exists') passing_text = comparator.read_log(passing_path, max_bytes) failing_text = comparator.read_log(failing_path, max_bytes) findings = { 'passing_log': comparator.secret_findings(passing_text), 'failing_log': comparator.secret_findings(failing_text), } if any(findings.values()): raise ValueError('possible sensitive values detected; sanitize inputs before building a pack') passing_lines = comparator.useful_lines(passing_text) failing_lines = comparator.useful_lines(failing_text) report = comparator.render_markdown( passing_path, failing_path, passing_lines, failing_lines, limit ) delta = comparator.unique_delta(passing_lines, failing_lines, limit * 4) summary = { 'passing_normalized_lines': len(passing_lines), 'failing_normalized_lines': len(failing_lines), 'failing_only_unique_lines_considered': len(delta), 'new_error_like_lines': len(comparator.classify(delta, comparator.ERROR, limit)), 'new_warning_like_lines': len(comparator.classify(delta, comparator.WARNING, limit)), 'environment_difference_lines': len(comparator.classify(delta, comparator.ENVIRONMENT, limit)), 'network_requests': 0, 'root_cause_proven': False, } output_dir.mkdir(parents=True) try: safe_copy(passing_path, output_dir / 'passing.sanitized.log') safe_copy(failing_path, output_dir / 'failing.sanitized.log') (output_dir / 'comparison.md').write_text(report, encoding='utf-8') (output_dir / 'summary.json').write_text( json.dumps(summary, indent=2, sort_keys=True) + '\n', encoding='utf-8' ) readme = ( '# CI evidence pack\n\n' 'This pack was generated locally from inputs represented as sanitized logs.\n' 'Review the logs again before sharing: automated checks cannot guarantee that all sensitive data was removed.\n\n' 'The comparison is textual evidence, not proof of root cause. This pack grants no repository access or deployment authorization and does not establish an order or payment.\n' ) (output_dir / 'README.md').write_text(readme, encoding='utf-8') artifact_names = [ 'README.md', 'passing.sanitized.log', 'failing.sanitized.log', 'comparison.md', 'summary.json', ] manifest = { 'schema': 'constalabs-ci-evidence-pack-v1', 'created_at': datetime.now(timezone.utc).isoformat(), 'limitations': [ 'Textual differences do not prove root cause.', 'Inputs require human review for sensitive information.', 'The pack grants no repository or deployment authorization.', 'The pack does not establish an order or payment.', ], 'artifacts': [ { 'path': name, 'bytes': (output_dir / name).stat().st_size, 'sha256': sha256(output_dir / name), } for name in artifact_names ], } (output_dir / 'manifest.json').write_text( json.dumps(manifest, indent=2, sort_keys=True) + '\n', encoding='utf-8' ) return manifest except Exception: shutil.rmtree(output_dir, ignore_errors=True) raise def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('passing_log', type=Path) parser.add_argument('failing_log', type=Path) parser.add_argument('--output-dir', type=Path, required=True) parser.add_argument('--limit', type=int, default=20) parser.add_argument('--max-bytes', type=int, default=5_000_000) args = parser.parse_args() if not 1 <= args.limit <= 200: parser.error('--limit must be between 1 and 200') if not 1 <= args.max_bytes <= 50_000_000: parser.error('--max-bytes must be between 1 and 50000000') try: manifest = build_pack( args.passing_log, args.failing_log, args.output_dir, limit=args.limit, max_bytes=args.max_bytes, ) except (OSError, ValueError) as exc: print(f'error: {exc}', file=sys.stderr) return 2 print(json.dumps({ 'output_dir': str(args.output_dir), 'artifacts': len(manifest['artifacts']) + 1, 'network_requests': 0, 'root_cause_proven': False, }, sort_keys=True)) return 0 if __name__ == '__main__': raise SystemExit(main())