Skip to content

Commit d34e1ed

Browse files
jrx-codeclaude
andcommitted
feat: v0.16.0 — diff/delta API for fingerprint comparison between scans
- GET /api/diff — list all domains/repos with fingerprint changes - GET /api/diff/{domain} — detailed diff: imports, HA APIs, network domains, score delta - Leverages existing L.1 fingerprint + scan_history data - Shows added/removed imports, APIs, domains + score/findings delta Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 34da839 commit d34e1ed

2 files changed

Lines changed: 106 additions & 1 deletion

File tree

ha-sandbox/app/main.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,111 @@ async def api_reputation_all():
392392
return JSONResponse(content=get_all_reputations(conn))
393393

394394

395+
# --- Diff/Delta API ---
396+
397+
@app.get("/api/diff/{domain}")
398+
async def api_diff_domain(domain: str):
399+
"""Compare the last two scans for a domain, showing what changed."""
400+
from app.learning.fingerprint import fingerprint_diff
401+
conn = storage.get_conn()
402+
rows = conn.execute(
403+
"SELECT * FROM component_fingerprints WHERE domain = ? ORDER BY created_at DESC LIMIT 2",
404+
(domain,),
405+
).fetchall()
406+
if len(rows) < 2:
407+
return JSONResponse(content={"error": "Need at least 2 scans to compare"}, status_code=404)
408+
409+
new_fp = _fp_from_row(rows[0])
410+
old_fp = _fp_from_row(rows[1])
411+
changes = fingerprint_diff(old_fp, new_fp)
412+
413+
# Also compare findings count from scan_history
414+
history = conn.execute(
415+
"SELECT score, findings_count, scanned_at FROM scan_history WHERE domain = ? ORDER BY scanned_at DESC LIMIT 2",
416+
(domain,),
417+
).fetchall()
418+
score_delta = None
419+
if len(history) >= 2:
420+
score_delta = {
421+
"old_score": history[1]["score"],
422+
"new_score": history[0]["score"],
423+
"old_findings": history[1]["findings_count"],
424+
"new_findings": history[0]["findings_count"],
425+
"old_date": history[1]["scanned_at"],
426+
"new_date": history[0]["scanned_at"],
427+
}
428+
429+
return JSONResponse(content={
430+
"domain": domain,
431+
"fingerprint_changes": changes,
432+
"score_delta": score_delta,
433+
"old_hash": old_fp.get("fingerprint_hash", ""),
434+
"new_hash": new_fp.get("fingerprint_hash", ""),
435+
})
436+
437+
438+
@app.get("/api/diff")
439+
async def api_diff_all():
440+
"""List all domains/repos that have fingerprint changes between last 2 scans."""
441+
conn = storage.get_conn()
442+
domains = conn.execute(
443+
"SELECT DISTINCT domain FROM component_fingerprints WHERE domain != '' ORDER BY domain"
444+
).fetchall()
445+
repos = conn.execute(
446+
"SELECT DISTINCT repo_url FROM component_fingerprints WHERE domain = '' AND repo_url != '' ORDER BY repo_url"
447+
).fetchall()
448+
449+
changed = []
450+
from app.learning.fingerprint import fingerprint_diff
451+
452+
for row in domains:
453+
d = row["domain"]
454+
fps = conn.execute(
455+
"SELECT * FROM component_fingerprints WHERE domain = ? ORDER BY created_at DESC LIMIT 2",
456+
(d,),
457+
).fetchall()
458+
if len(fps) < 2:
459+
continue
460+
new_fp = _fp_from_row(fps[0])
461+
old_fp = _fp_from_row(fps[1])
462+
if new_fp.get("fingerprint_hash") != old_fp.get("fingerprint_hash"):
463+
changes = fingerprint_diff(old_fp, new_fp)
464+
changed.append({"domain": d, "changes": changes})
465+
466+
for row in repos:
467+
url = row["repo_url"]
468+
fps = conn.execute(
469+
"SELECT * FROM component_fingerprints WHERE repo_url = ? AND domain = '' ORDER BY created_at DESC LIMIT 2",
470+
(url,),
471+
).fetchall()
472+
if len(fps) < 2:
473+
continue
474+
new_fp = _fp_from_row(fps[0])
475+
old_fp = _fp_from_row(fps[1])
476+
if new_fp.get("fingerprint_hash") != old_fp.get("fingerprint_hash"):
477+
changes = fingerprint_diff(old_fp, new_fp)
478+
changed.append({"repo_url": url, "changes": changes})
479+
480+
return JSONResponse(content={"changed": changed, "total_tracked": len(domains) + len(repos)})
481+
482+
483+
def _fp_from_row(row) -> dict:
484+
"""Convert a SQLite row to a fingerprint dict."""
485+
import json
486+
return {
487+
"domain": row["domain"],
488+
"repo_url": row["repo_url"],
489+
"fingerprint_hash": row["fingerprint_hash"],
490+
"imports": json.loads(row["imports"]),
491+
"ha_apis": json.loads(row["ha_apis"]),
492+
"network_domains": json.loads(row["network_domains"]),
493+
"file_types": json.loads(row["file_types"]),
494+
"py_files": row["py_files"],
495+
"js_files": row["js_files"],
496+
"total_lines": row["total_lines"],
497+
}
498+
499+
395500
# --- Scheduler API ---
396501

397502
@app.get("/api/scheduler")

ha-sandbox/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: "HA Security Sandbox"
2-
version: "0.15.0"
2+
version: "0.16.0"
33
slug: ha_security_sandbox
44
description: "Security scanner for Home Assistant custom components — static analysis + AI review"
55
url: "https://github.com/jrx-code/ha-security-sandbox"

0 commit comments

Comments
 (0)