#!/usr/bin/env python3 """ in-toto Supply Chain Verification Tool Verifies container image supply chain integrity by checking in-toto link metadata against the defined layout policy. """ import json import subprocess import sys import argparse import hashlib from pathlib import Path from datetime import datetime def compute_file_hash(file_path: str, algorithm: str = "sha256") -> str: """Compute the hash of a file.""" h = hashlib.new(algorithm) with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest() def find_link_files(link_dir: str) -> list[dict]: """Find and parse all in-toto link metadata files.""" link_path = Path(link_dir) if not link_path.exists(): print(f"[ERROR] Link directory not found: {link_dir}") return [] links = [] for link_file in link_path.glob("*.link"): try: with open(link_file) as f: data = json.load(f) links.append({ "file": str(link_file), "step_name": data.get("signed", {}).get("name", "unknown"), "materials": data.get("signed", {}).get("materials", {}), "products": data.get("signed", {}).get("products", {}), "command": data.get("signed", {}).get("command", []), "byproducts": data.get("signed", {}).get("byproducts", {}), "signatures": data.get("signatures", []), }) except (json.JSONDecodeError, KeyError) as e: print(f"[WARN] Failed to parse {link_file}: {e}") return links def verify_artifact_chain(links: list[dict]) -> list[dict]: """Verify that artifact hashes chain correctly between steps.""" findings = [] products_by_step = {} for link in sorted(links, key=lambda x: x["step_name"]): products_by_step[link["step_name"]] = link["products"] for link in links: for material_path, material_hashes in link["materials"].items(): found = False for step_name, products in products_by_step.items(): if step_name == link["step_name"]: continue if material_path in products: product_hashes = products[material_path] for algo, expected_hash in material_hashes.items(): actual_hash = product_hashes.get(algo, "") if actual_hash and actual_hash != expected_hash: findings.append({ "severity": "CRITICAL", "type": "hash_mismatch", "step": link["step_name"], "artifact": material_path, "expected": expected_hash[:16] + "...", "actual": actual_hash[:16] + "...", "description": f"Artifact {material_path} hash mismatch between steps" }) elif actual_hash: found = True if not found and link["materials"]: findings.append({ "severity": "WARNING", "type": "untracked_material", "step": link["step_name"], "artifact": material_path, "description": f"Material {material_path} not found in any prior step products" }) return findings def verify_signatures(links: list[dict], trusted_keys: list[str]) -> list[dict]: """Verify that all link signatures come from trusted keys.""" findings = [] for link in links: if not link["signatures"]: findings.append({ "severity": "CRITICAL", "type": "missing_signature", "step": link["step_name"], "description": f"Step {link['step_name']} has no signatures" }) continue for sig in link["signatures"]: keyid = sig.get("keyid", "") if trusted_keys and keyid not in trusted_keys: findings.append({ "severity": "HIGH", "type": "untrusted_key", "step": link["step_name"], "keyid": keyid[:16] + "...", "description": f"Step {link['step_name']} signed by untrusted key" }) return findings def check_required_steps(links: list[dict], required_steps: list[str]) -> list[dict]: """Check that all required pipeline steps have link metadata.""" findings = [] observed_steps = {link["step_name"] for link in links} for step in required_steps: if step not in observed_steps: findings.append({ "severity": "CRITICAL", "type": "missing_step", "step": step, "description": f"Required step '{step}' has no link metadata" }) return findings def run_in_toto_verify(layout_path: str, layout_key: str, link_dir: str) -> dict: """Run the official in-toto-verify command.""" try: result = subprocess.run( [ "in-toto-verify", "--layout", layout_path, "--layout-key", layout_key, "--link-dir", link_dir, ], capture_output=True, text=True, timeout=60 ) return { "success": result.returncode == 0, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "returncode": result.returncode } except FileNotFoundError: return {"success": False, "error": "in-toto-verify not found in PATH"} except subprocess.TimeoutExpired: return {"success": False, "error": "Verification timed out"} def generate_report(links: list[dict], chain_findings: list[dict], sig_findings: list[dict], step_findings: list[dict], verify_result: dict, output_format: str = "text") -> str: """Generate a comprehensive verification report.""" all_findings = chain_findings + sig_findings + step_findings critical_count = sum(1 for f in all_findings if f["severity"] == "CRITICAL") high_count = sum(1 for f in all_findings if f["severity"] == "HIGH") if output_format == "json": report = { "timestamp": datetime.utcnow().isoformat(), "verification_passed": verify_result.get("success", False) and critical_count == 0, "steps_found": len(links), "findings": { "critical": critical_count, "high": high_count, "total": len(all_findings), "details": all_findings }, "in_toto_verify": verify_result, "steps": [{"name": l["step_name"], "command": l["command"], "materials": len(l["materials"]), "products": len(l["products"])} for l in links] } return json.dumps(report, indent=2) lines = [] lines.append("=" * 70) lines.append("IN-TOTO SUPPLY CHAIN VERIFICATION REPORT") lines.append(f"Generated: {datetime.utcnow().isoformat()}") lines.append("=" * 70) passed = verify_result.get("success", False) and critical_count == 0 lines.append(f"\nVerification Result: {'PASSED' if passed else 'FAILED'}") lines.append(f"Steps Found: {len(links)}") lines.append("\n## Pipeline Steps") for link in sorted(links, key=lambda x: x["step_name"]): lines.append(f" Step: {link['step_name']}") lines.append(f" Command: {' '.join(link['command'])}") lines.append(f" Materials: {len(link['materials'])} | Products: {len(link['products'])}") lines.append(f" Signatures: {len(link['signatures'])}") if all_findings: lines.append(f"\n## Findings ({len(all_findings)} total)") for f in sorted(all_findings, key=lambda x: x["severity"]): lines.append(f" [{f['severity']}] {f['description']}") if verify_result.get("error"): lines.append(f"\n## in-toto-verify Error") lines.append(f" {verify_result['error']}") lines.append("\n" + "=" * 70) return "\n".join(lines) def main(): parser = argparse.ArgumentParser(description="in-toto Supply Chain Verification Tool") parser.add_argument("--layout", help="Path to in-toto layout file") parser.add_argument("--layout-key", help="Path to layout signing public key") parser.add_argument("--link-dir", required=True, help="Directory containing link metadata") parser.add_argument("--required-steps", nargs="+", default=["checkout", "build", "scan"], help="Required pipeline steps") parser.add_argument("--trusted-keys", nargs="+", default=[], help="Trusted key IDs") parser.add_argument("--format", choices=["text", "json"], default="text") args = parser.parse_args() links = find_link_files(args.link_dir) if not links: print("[ERROR] No link metadata found") sys.exit(1) chain_findings = verify_artifact_chain(links) sig_findings = verify_signatures(links, args.trusted_keys) step_findings = check_required_steps(links, args.required_steps) verify_result = {} if args.layout and args.layout_key: verify_result = run_in_toto_verify(args.layout, args.layout_key, args.link_dir) else: verify_result = {"success": None, "note": "Layout verification skipped (no --layout provided)"} report = generate_report(links, chain_findings, sig_findings, step_findings, verify_result, args.format) print(report) critical_count = sum(1 for f in chain_findings + sig_findings + step_findings if f["severity"] == "CRITICAL") sys.exit(1 if critical_count > 0 else 0) if __name__ == "__main__": main()