Skip to content

Commit 3cb5e3e

Browse files
SecAI-Hubclaude
andcommitted
Add continuous model integrity monitoring with pre-inference verification (M12)
Every 15 minutes, a systemd timer verifies SHA256 hashes of all promoted models. Tampered models are auto-quarantined and workers restarted. All chat endpoints now verify model integrity before allowing inference, blocking requests if the active model fails hash verification. Includes security hardening roadmap (M12-M24). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9ad2669 commit 3cb5e3e

11 files changed

Lines changed: 1131 additions & 6 deletions

File tree

docs/roadmap-security-hardening.md

Lines changed: 456 additions & 0 deletions
Large diffs are not rendered by default.

files/system/etc/secure-ai/config/appliance.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ session:
6060
# sensitive mode: aggressive worker recycling after each task
6161
# offline-only: hard-block all network even if airlock is enabled
6262

63+
monitoring:
64+
# Model integrity check interval (minutes). The integrity timer verifies
65+
# SHA256 hashes of all promoted models against the registry manifest.
66+
# On mismatch: model is quarantined, removed from manifest, workers restarted.
67+
integrity_interval: 15 # minutes (5, 15, 30, or 60)
68+
6369
logging:
6470
level: "info"
6571
store_raw_prompts: false
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
[Unit]
2+
Description=Secure AI Model Integrity Check (verify all model hashes)
3+
After=secure-ai-registry.service
4+
Wants=secure-ai-registry.service
5+
6+
[Service]
7+
Type=oneshot
8+
ExecStart=/usr/libexec/secure-ai/integrity-check.sh
9+
Environment=REGISTRY_URL=http://127.0.0.1:8470
10+
Environment=REGISTRY_DIR=/var/lib/secure-ai/registry
11+
12+
# Filesystem access
13+
ReadOnlyPaths=/usr/libexec/secure-ai
14+
ReadWritePaths=/var/lib/secure-ai/logs
15+
ReadWritePaths=/var/lib/secure-ai/quarantine
16+
PrivateTmp=yes
17+
ProtectSystem=strict
18+
ProtectHome=yes
19+
20+
# Kernel protection
21+
ProtectKernelTunables=yes
22+
ProtectKernelModules=yes
23+
ProtectKernelLogs=yes
24+
ProtectControlGroups=yes
25+
ProtectClock=yes
26+
ProtectHostname=yes
27+
28+
# Privilege restriction
29+
NoNewPrivileges=yes
30+
RestrictSUIDSGID=yes
31+
LockPersonality=yes
32+
RestrictRealtime=yes
33+
34+
# Needs network to talk to registry on localhost
35+
RestrictAddressFamilies=AF_INET AF_UNIX
36+
37+
# Resource limits
38+
MemoryMax=256M
39+
CPUQuota=25%
40+
TasksMax=16
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[Unit]
2+
Description=Secure AI Model Integrity Check Timer (default: every 15 minutes)
3+
4+
[Timer]
5+
OnBootSec=2min
6+
OnUnitActiveSec=15min
7+
RandomizedDelaySec=30
8+
Persistent=true
9+
10+
[Install]
11+
WantedBy=timers.target
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Secure AI Appliance — Continuous Model Integrity Monitor
4+
#
5+
# Verifies SHA256 hashes of all promoted models against the registry manifest.
6+
# On mismatch: quarantines the tampered model, removes it from the manifest,
7+
# kills any inference process using it, and logs a CRITICAL alert.
8+
#
9+
# Run via secure-ai-integrity.timer (default: every 15 minutes).
10+
11+
set -euo pipefail
12+
13+
SECURE_AI_ROOT="/var/lib/secure-ai"
14+
REGISTRY_URL="${REGISTRY_URL:-http://127.0.0.1:8470}"
15+
REGISTRY_DIR="${REGISTRY_DIR:-/var/lib/secure-ai/registry}"
16+
TAMPERED_DIR="${SECURE_AI_ROOT}/quarantine/tampered"
17+
INTEGRITY_LOG="${SECURE_AI_ROOT}/logs/integrity.jsonl"
18+
RESULT_FILE="${SECURE_AI_ROOT}/logs/integrity-last.json"
19+
20+
log() {
21+
echo "[integrity-check] $*"
22+
logger -t secure-ai-integrity "$*"
23+
}
24+
25+
log_json() {
26+
local status="$1" model="$2" detail="$3"
27+
local ts
28+
ts=$(date -Iseconds)
29+
printf '{"timestamp":"%s","status":"%s","model":"%s","detail":"%s"}\n' \
30+
"$ts" "$status" "$model" "$detail" >> "$INTEGRITY_LOG"
31+
}
32+
33+
mkdir -p "$(dirname "$INTEGRITY_LOG")" "$TAMPERED_DIR"
34+
35+
# Fetch the manifest from the registry
36+
manifest=$(curl -sf "${REGISTRY_URL}/v1/models" 2>/dev/null) || {
37+
log "ERROR: cannot reach registry at ${REGISTRY_URL}"
38+
log_json "error" "" "registry unreachable"
39+
echo '{"status":"error","detail":"registry unreachable","checked_at":"'"$(date -Iseconds)"'"}' > "$RESULT_FILE"
40+
exit 1
41+
}
42+
43+
model_count=$(echo "$manifest" | jq 'length')
44+
if [ "$model_count" -eq 0 ]; then
45+
log "No models in registry. Nothing to verify."
46+
echo '{"status":"ok","models_checked":0,"failures":0,"checked_at":"'"$(date -Iseconds)"'"}' > "$RESULT_FILE"
47+
exit 0
48+
fi
49+
50+
log "Verifying ${model_count} model(s)..."
51+
52+
failures=0
53+
checked=0
54+
55+
for i in $(seq 0 $((model_count - 1))); do
56+
name=$(echo "$manifest" | jq -r ".[$i].name")
57+
filename=$(echo "$manifest" | jq -r ".[$i].filename")
58+
expected=$(echo "$manifest" | jq -r ".[$i].sha256")
59+
filepath="${REGISTRY_DIR}/${filename}"
60+
61+
if [ ! -f "$filepath" ]; then
62+
log "CRITICAL: model file missing: ${filename} (${name})"
63+
log_json "missing" "$name" "file not found: ${filename}"
64+
failures=$((failures + 1))
65+
continue
66+
fi
67+
68+
actual=$(sha256sum "$filepath" | awk '{print $1}')
69+
checked=$((checked + 1))
70+
71+
if [ "$actual" = "$expected" ]; then
72+
log "OK: ${name} (${expected:0:16}...)"
73+
log_json "ok" "$name" "hash verified"
74+
else
75+
log "CRITICAL: HASH MISMATCH for ${name}!"
76+
log " Expected: ${expected}"
77+
log " Actual: ${actual}"
78+
log_json "tampered" "$name" "expected=${expected} actual=${actual}"
79+
failures=$((failures + 1))
80+
81+
# Quarantine the tampered model
82+
log "Quarantining tampered model: ${filename}"
83+
mv "$filepath" "${TAMPERED_DIR}/${filename}.tampered.$(date +%s)" 2>/dev/null || true
84+
85+
# Remove from registry manifest via API
86+
log "Removing ${name} from registry..."
87+
curl -sf -X DELETE "${REGISTRY_URL}/v1/model/delete?name=${name}" >/dev/null 2>&1 || {
88+
log "WARNING: could not remove ${name} from registry via API"
89+
}
90+
91+
# Kill any inference process that might be using this model
92+
# The inference worker loads models by path — killing it forces a clean reload
93+
if systemctl is-active --quiet secure-ai-inference.service 2>/dev/null; then
94+
log "Restarting inference worker to drop potentially poisoned model..."
95+
systemctl restart secure-ai-inference.service 2>/dev/null || true
96+
fi
97+
if systemctl is-active --quiet secure-ai-diffusion.service 2>/dev/null; then
98+
log "Restarting diffusion worker to drop potentially poisoned model..."
99+
systemctl restart secure-ai-diffusion.service 2>/dev/null || true
100+
fi
101+
fi
102+
done
103+
104+
ts=$(date -Iseconds)
105+
status="ok"
106+
if [ "$failures" -gt 0 ]; then
107+
status="failed"
108+
log "INTEGRITY CHECK FAILED: ${failures} model(s) tampered or missing out of ${checked} checked"
109+
else
110+
log "Integrity check passed: ${checked} model(s) verified OK"
111+
fi
112+
113+
# Write summary for the status API
114+
cat > "$RESULT_FILE" <<EOF
115+
{
116+
"status": "${status}",
117+
"models_checked": ${checked},
118+
"failures": ${failures},
119+
"checked_at": "${ts}"
120+
}
121+
EOF
122+
123+
exit 0

recipes/recipe.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ modules:
8181
- secure-ai-quarantine-watcher.service
8282
- secure-ai-inference.service
8383
- secure-ai-diffusion.service
84+
- secure-ai-integrity.timer
8485
- nftables.service
8586
- secure-ai-firstboot.service
8687
- secure-ai-tmpdir.mount

services/registry/main.go

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,83 @@ func handleDelete(w http.ResponseWriter, r *http.Request) {
311311
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "name": name})
312312
}
313313

314-
func handleVerify(w http.ResponseWriter, r *http.Request) {
314+
func handleVerifyAll(w http.ResponseWriter, r *http.Request) {
315+
if r.Method != http.MethodPost {
316+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
317+
return
318+
}
319+
320+
manifestMu.RLock()
321+
models := make([]Artifact, len(manifest.Models))
322+
copy(models, manifest.Models)
323+
manifestMu.RUnlock()
324+
325+
results := make([]map[string]string, 0, len(models))
326+
allOk := true
327+
328+
for _, m := range models {
329+
filePath := filepath.Join(registryDir, m.Filename)
330+
actual, err := verifyFileHash(filePath, m.SHA256)
331+
if err != nil {
332+
allOk = false
333+
results = append(results, map[string]string{
334+
"name": m.Name,
335+
"status": "failed",
336+
"expected": m.SHA256,
337+
"actual": actual,
338+
"error": err.Error(),
339+
})
340+
} else {
341+
results = append(results, map[string]string{
342+
"name": m.Name,
343+
"status": "verified",
344+
"sha256": actual,
345+
})
346+
}
347+
}
348+
349+
status := "ok"
350+
if !allOk {
351+
status = "failed"
352+
}
353+
354+
w.Header().Set("Content-Type", "application/json")
355+
if !allOk {
356+
w.WriteHeader(http.StatusConflict)
357+
}
358+
json.NewEncoder(w).Encode(map[string]interface{}{
359+
"status": status,
360+
"models": results,
361+
"checked": len(results),
362+
})
363+
}
364+
365+
func handleIntegrityStatus(w http.ResponseWriter, r *http.Request) {
366+
if r.Method != http.MethodGet {
367+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
368+
return
369+
}
370+
371+
resultPath := os.Getenv("INTEGRITY_RESULT_PATH")
372+
if resultPath == "" {
373+
resultPath = "/var/lib/secure-ai/logs/integrity-last.json"
374+
}
375+
376+
data, err := os.ReadFile(resultPath)
377+
if err != nil {
378+
w.Header().Set("Content-Type", "application/json")
379+
json.NewEncoder(w).Encode(map[string]interface{}{
380+
"status": "unknown",
381+
"detail": "no integrity check has run yet",
382+
})
383+
return
384+
}
385+
386+
w.Header().Set("Content-Type", "application/json")
387+
w.Write(data)
388+
}
389+
390+
func handleVerifyModel(w http.ResponseWriter, r *http.Request) {
315391
if r.Method != http.MethodPost {
316392
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
317393
return
@@ -339,14 +415,16 @@ func handleVerify(w http.ResponseWriter, r *http.Request) {
339415
"expected": m.SHA256,
340416
"actual": actual,
341417
"error": err.Error(),
418+
"safe_to_use": "false",
342419
})
343420
return
344421
}
345422
w.Header().Set("Content-Type", "application/json")
346423
json.NewEncoder(w).Encode(map[string]string{
347-
"status": "verified",
348-
"name": name,
349-
"sha256": actual,
424+
"status": "verified",
425+
"name": name,
426+
"sha256": actual,
427+
"safe_to_use": "true",
350428
})
351429
return
352430
}
@@ -391,7 +469,9 @@ func main() {
391469
mux.HandleFunc("/v1/model/path", handleModelPath)
392470
mux.HandleFunc("/v1/model/promote", handlePromote)
393471
mux.HandleFunc("/v1/model/delete", handleDelete)
394-
mux.HandleFunc("/v1/model/verify", handleVerify)
472+
mux.HandleFunc("/v1/model/verify", handleVerifyModel)
473+
mux.HandleFunc("/v1/models/verify-all", handleVerifyAll)
474+
mux.HandleFunc("/v1/integrity/status", handleIntegrityStatus)
395475

396476
log.Printf("secure-ai-registry listening on %s", bind)
397477
if err := http.ListenAndServe(bind, mux); err != nil {

0 commit comments

Comments
 (0)