|
45 | 45 | os.getenv("SOURCES_ALLOWLIST_PATH", "/etc/secure-ai/policy/sources.allowlist.yaml") |
46 | 46 | ) |
47 | 47 | LLAMA_SERVER_BIN = os.getenv("LLAMA_SERVER_BIN", "/usr/bin/llama-server") |
| 48 | +GGUF_GUARD_BIN = os.getenv("GGUF_GUARD_BIN", "/usr/local/bin/gguf-guard") |
48 | 49 | SMOKE_TEST_TIMEOUT = int(os.getenv("SMOKE_TEST_TIMEOUT", "120")) |
49 | 50 |
|
50 | 51 |
|
@@ -961,8 +962,130 @@ def _check_weight_anomalies(tensor_name: str, stats: dict) -> list: |
961 | 962 | return issues |
962 | 963 |
|
963 | 964 |
|
| 965 | +def _run_gguf_guard_scan(artifact_path: Path, policy: dict | None = None, |
| 966 | + reference_path: str | None = None) -> dict: |
| 967 | + """Run gguf-guard static analysis on a GGUF model file. |
| 968 | +
|
| 969 | + gguf-guard provides deep weight-level anomaly detection including: |
| 970 | + - Layered anomaly scoring (tensor-local, cross-layer, model-global, reference) |
| 971 | + - Quant-format-aware block analysis (scale entropy, repeated blocks, saturation) |
| 972 | + - Robust statistics (median/MAD, trimmed mean, Tukey fences) |
| 973 | + - Structural policy validation (offsets, overlaps, metadata, tensor shapes) |
| 974 | + - Model family identification (llama, mistral, mixtral, qwen2, gemma, phi) |
| 975 | +
|
| 976 | + Returns scan result with score, anomalies, and pass/fail verdict. |
| 977 | + """ |
| 978 | + if artifact_path.suffix.lower() != ".gguf": |
| 979 | + return {"passed": True, "scanner": "gguf-guard", "note": "not a GGUF file, skipped"} |
| 980 | + |
| 981 | + if policy is None: |
| 982 | + policy = {} |
| 983 | + gguf_guard_policy = policy.get("gguf_guard", {}) |
| 984 | + |
| 985 | + try: |
| 986 | + cmd = [GGUF_GUARD_BIN, "scan", "--quiet"] |
| 987 | + if reference_path: |
| 988 | + cmd.extend(["--reference", reference_path]) |
| 989 | + cmd.append(str(artifact_path)) |
| 990 | + |
| 991 | + result = subprocess.run( |
| 992 | + cmd, capture_output=True, text=True, timeout=600, |
| 993 | + ) |
| 994 | + |
| 995 | + output = result.stdout.strip() |
| 996 | + |
| 997 | + if result.returncode == 0: |
| 998 | + # PASS |
| 999 | + return { |
| 1000 | + "passed": True, |
| 1001 | + "scanner": "gguf-guard", |
| 1002 | + "output": output, |
| 1003 | + "exit_code": 0, |
| 1004 | + } |
| 1005 | + elif result.returncode == 2: |
| 1006 | + # FAIL — score exceeded threshold |
| 1007 | + return { |
| 1008 | + "passed": False, |
| 1009 | + "scanner": "gguf-guard", |
| 1010 | + "reason": f"gguf-guard scan failed: {output}", |
| 1011 | + "output": output, |
| 1012 | + "exit_code": 2, |
| 1013 | + } |
| 1014 | + else: |
| 1015 | + # Error |
| 1016 | + log.warning("gguf-guard error (exit %d): %s", result.returncode, result.stderr[:500]) |
| 1017 | + return { |
| 1018 | + "passed": True, |
| 1019 | + "scanner": "gguf-guard", |
| 1020 | + "note": f"gguf-guard error (exit {result.returncode}), non-fatal", |
| 1021 | + "exit_code": result.returncode, |
| 1022 | + } |
| 1023 | + |
| 1024 | + except FileNotFoundError: |
| 1025 | + require = gguf_guard_policy.get("required", False) |
| 1026 | + if require: |
| 1027 | + return {"passed": False, "scanner": "gguf-guard", "reason": "gguf-guard required but not installed"} |
| 1028 | + log.info("gguf-guard not installed; skipping GGUF integrity scan") |
| 1029 | + return {"passed": True, "scanner": "gguf-guard", "note": "not installed, skipped"} |
| 1030 | + except subprocess.TimeoutExpired: |
| 1031 | + log.warning("gguf-guard timed out after 600s") |
| 1032 | + return {"passed": False, "scanner": "gguf-guard", "reason": "gguf-guard scan timed out"} |
| 1033 | + except Exception as e: |
| 1034 | + log.warning("gguf-guard error: %s", e) |
| 1035 | + return {"passed": True, "scanner": "gguf-guard", "note": f"error (non-fatal): {e}"} |
| 1036 | + |
| 1037 | + |
| 1038 | +def _run_gguf_guard_manifest(artifact_path: Path, output_path: Path) -> dict: |
| 1039 | + """Generate a gguf-guard per-tensor integrity manifest for a GGUF file. |
| 1040 | +
|
| 1041 | + The manifest contains SHA-256 hashes for each tensor and a Merkle tree root, |
| 1042 | + enabling fine-grained integrity verification at any time. |
| 1043 | + """ |
| 1044 | + if artifact_path.suffix.lower() != ".gguf": |
| 1045 | + return {"generated": False, "note": "not a GGUF file"} |
| 1046 | + |
| 1047 | + try: |
| 1048 | + result = subprocess.run( |
| 1049 | + [GGUF_GUARD_BIN, "manifest", "--output", str(output_path), str(artifact_path)], |
| 1050 | + capture_output=True, text=True, timeout=600, |
| 1051 | + ) |
| 1052 | + if result.returncode == 0: |
| 1053 | + return {"generated": True, "manifest_path": str(output_path)} |
| 1054 | + else: |
| 1055 | + log.warning("gguf-guard manifest generation failed: %s", result.stderr[:500]) |
| 1056 | + return {"generated": False, "error": result.stderr[:200]} |
| 1057 | + except FileNotFoundError: |
| 1058 | + return {"generated": False, "note": "gguf-guard not installed"} |
| 1059 | + except Exception as e: |
| 1060 | + log.warning("gguf-guard manifest error: %s", e) |
| 1061 | + return {"generated": False, "error": str(e)} |
| 1062 | + |
| 1063 | + |
| 1064 | +def _run_gguf_guard_fingerprint(artifact_path: Path) -> dict | None: |
| 1065 | + """Generate a gguf-guard structural fingerprint for a GGUF file. |
| 1066 | +
|
| 1067 | + Returns fingerprint dict (file_hash, structure_hash, quant_type, etc.) or None. |
| 1068 | + """ |
| 1069 | + if artifact_path.suffix.lower() != ".gguf": |
| 1070 | + return None |
| 1071 | + |
| 1072 | + try: |
| 1073 | + result = subprocess.run( |
| 1074 | + [GGUF_GUARD_BIN, "fingerprint", str(artifact_path)], |
| 1075 | + capture_output=True, text=True, timeout=120, |
| 1076 | + ) |
| 1077 | + if result.returncode == 0: |
| 1078 | + return json.loads(result.stdout) |
| 1079 | + return None |
| 1080 | + except (FileNotFoundError, json.JSONDecodeError, subprocess.TimeoutExpired): |
| 1081 | + return None |
| 1082 | + except Exception as e: |
| 1083 | + log.warning("gguf-guard fingerprint error: %s", e) |
| 1084 | + return None |
| 1085 | + |
| 1086 | + |
964 | 1087 | def check_static_scan(artifact_path: Path, policy: dict | None = None) -> dict: |
965 | | - """Stage 5: Run modelscan + fickling + modelaudit + entropy + weight analysis.""" |
| 1088 | + """Stage 5: Run modelscan + fickling + modelaudit + entropy + weight analysis + gguf-guard.""" |
966 | 1089 | if policy is None: |
967 | 1090 | policy = {} |
968 | 1091 | results = {} |
@@ -991,6 +1114,10 @@ def check_static_scan(artifact_path: Path, policy: dict | None = None) -> dict: |
991 | 1114 | weight_result = _analyze_weight_distribution(artifact_path) |
992 | 1115 | results["weight_stats"] = weight_result |
993 | 1116 |
|
| 1117 | + # 7. gguf-guard deep integrity scan (GGUF files only) |
| 1118 | + gguf_guard_result = _run_gguf_guard_scan(artifact_path, policy=policy) |
| 1119 | + results["gguf_guard"] = gguf_guard_result |
| 1120 | + |
994 | 1121 | # Overall: fail if ANY scanner fails |
995 | 1122 | failed = [k for k, v in results.items() if not v.get("passed", True)] |
996 | 1123 | if failed: |
@@ -1704,6 +1831,18 @@ def run_pipeline(artifact_path: Path, file_hash: str, policy: dict, |
1704 | 1831 | else: |
1705 | 1832 | details["smoke_test"] = {"passed": True, "note": "not applicable for safetensors"} |
1706 | 1833 |
|
| 1834 | + # Post-scan: generate gguf-guard artifacts for promotion metadata |
| 1835 | + if artifact_path.suffix.lower() == ".gguf": |
| 1836 | + # Structural fingerprint (stored in promotion metadata) |
| 1837 | + fp = _run_gguf_guard_fingerprint(artifact_path) |
| 1838 | + if fp: |
| 1839 | + details["gguf_guard_fingerprint"] = fp |
| 1840 | + |
| 1841 | + # Per-tensor integrity manifest (stored alongside model in registry) |
| 1842 | + manifest_path = artifact_path.with_suffix(".gguf.manifest.json") |
| 1843 | + manifest_result = _run_gguf_guard_manifest(artifact_path, manifest_path) |
| 1844 | + details["gguf_guard_manifest"] = manifest_result |
| 1845 | + |
1707 | 1846 | return {"passed": True, "reason": "all_checks_passed", "details": details} |
1708 | 1847 |
|
1709 | 1848 |
|
|
0 commit comments