#!/usr/bin/env python3 """Create a bounded, sanitized CI diagnostic context bundle. The collector uses only Python's standard library, performs no network requests, does not execute project code, and never reads environment files or credentials. Review the resulting JSON before sharing it. """ from __future__ import annotations import argparse import hashlib import json import os import platform import re import subprocess import sys from pathlib import Path SCHEMA = 'offline-ci-context-v1' MAX_FILE_BYTES = 200_000 MAX_TOTAL_BYTES = 1_000_000 ALLOWED_NAMES = { 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'pyproject.toml', 'poetry.lock', 'requirements.txt', 'Pipfile.lock', 'go.mod', 'go.sum', 'Cargo.toml', 'Cargo.lock', 'Gemfile', 'Gemfile.lock', 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'Makefile', } ALLOWED_SUFFIXES = {'.yml', '.yaml'} DENIED_PARTS = { '.env', '.git', '.ssh', '.aws', '.npmrc', '.pypirc', '.netrc', 'secrets', 'credentials', 'private', 'id_rsa', 'id_ed25519', } SECRET_PATTERNS = [ re.compile(r'gh[pousr]_[A-Za-z0-9_]{20,}', re.I), re.compile(r'AKIA[0-9A-Z]{16}'), re.compile(r'(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*)[^\s,}"]+'), re.compile(r'(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s]+'), re.compile(r'-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----', re.S), ] def denied(path: Path) -> bool: lowered = {part.lower() for part in path.parts} return any(item.lower() in lowered for item in DENIED_PARTS) def eligible(root: Path, path: Path) -> bool: try: relative = path.relative_to(root) except ValueError: return False if denied(relative) or path.is_symlink() or not path.is_file(): return False if path.name in ALLOWED_NAMES: return True return relative.parts[:2] == ('.github', 'workflows') and path.suffix.lower() in ALLOWED_SUFFIXES def sanitize(text: str) -> tuple[str, int]: count = 0 for pattern in SECRET_PATTERNS: if pattern.groups: text, replaced = pattern.subn(lambda match: match.group(1) + '[possible secret removed]', text) else: text, replaced = pattern.subn('[possible secret removed]', text) count += replaced return text, count def safe_version(command: list[str]) -> str | None: try: result = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=3, check=False, env={'PATH': os.environ.get('PATH', '')}, ) except (OSError, subprocess.SubprocessError): return None first = result.stdout.strip().splitlines()[:1] return first[0][:300] if first else None def collect(root: Path, include_content: bool) -> dict: root = root.resolve() if not root.is_dir(): raise ValueError(f'not a directory: {root}') candidates: list[Path] = [] for name in sorted(ALLOWED_NAMES): path = root / name if eligible(root, path): candidates.append(path) workflow_dir = root / '.github' / 'workflows' if workflow_dir.is_dir() and not workflow_dir.is_symlink(): for path in sorted(workflow_dir.iterdir()): if eligible(root, path): candidates.append(path) files = [] total = 0 total_redactions = 0 warnings = [] for path in candidates: size = path.stat().st_size relative = path.relative_to(root).as_posix() if size > MAX_FILE_BYTES: warnings.append(f'skipped oversized file: {relative}') continue if total + size > MAX_TOTAL_BYTES: warnings.append('total content limit reached; remaining files skipped') break raw = path.read_bytes() total += len(raw) digest = hashlib.sha256(raw).hexdigest() item = {'path': relative, 'bytes': len(raw), 'sha256': digest} if include_content: text = raw.decode('utf-8', errors='replace') safe, redactions = sanitize(text) item['sanitized_content'] = safe item['redactions'] = redactions total_redactions += redactions files.append(item) tools = {} for name, command in { 'git': ['git', '--version'], 'node': ['node', '--version'], 'npm': ['npm', '--version'], 'python': [sys.executable, '--version'], 'go': ['go', 'version'], 'rustc': ['rustc', '--version'], 'docker': ['docker', '--version'], }.items(): version = safe_version(command) if version: tools[name] = version return { 'schema': SCHEMA, 'privacy_notice': 'Review before sharing. Secret detection is best-effort, not a guarantee.', 'project_name': root.name, 'platform': {'system': platform.system(), 'machine': platform.machine()}, 'tool_versions': tools, 'files': files, 'summary': { 'files_included': len(files), 'source_bytes_read': total, 'redactions': total_redactions, 'content_included': include_content, }, 'warnings': warnings, } def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('project', nargs='?', default='.', type=Path) parser.add_argument('--metadata-only', action='store_true', help='Include hashes and sizes but not file content') parser.add_argument('-o', '--output', type=Path, help='Write JSON to this file instead of stdout') args = parser.parse_args() try: report = collect(args.project, include_content=not args.metadata_only) except (OSError, ValueError) as exc: raise SystemExit(str(exc)) from exc payload = json.dumps(report, ensure_ascii=False, indent=2) + '\n' if args.output: args.output.write_text(payload, encoding='utf-8') print(f'wrote {args.output}', file=sys.stderr) else: print(payload, end='') if __name__ == '__main__': main()