feat(risk): Phase 12 — Risk Intelligence [FASE-12]
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

- TechniqueRiskProfile model: per-technique risk scoring (0-100)
- 4-factor weighted scoring: detection_gap(35%) + threat_actors(30%) + osint(20%) + test_failures(15%)
- Risk levels: critical(≥75) / high(≥50) / medium(≥25) / low(≥10) / info
- Detailed scoring_breakdown (JSONB) + actionable recommendations per technique
- Router /api/v1/risk: compute-all, compute-one, list, matrix, summary, recommendations, top
- Alembic migration b038risk (raw SQL, idempotent)
- QA script: 60+ tests across all endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-20 15:31:38 +02:00
parent 0febbc67f1
commit 362a17aa1b
8 changed files with 1049 additions and 0 deletions

301
scripts/qa_phase12.py Normal file
View File

@@ -0,0 +1,301 @@
"""
QA script for Phase 12 — Risk Intelligence.
Run with: python -X utf8 scripts/qa_phase12.py
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import requests
BASE = "http://localhost:8000/api/v1"
PASS = "\033[92m✓\033[0m"
FAIL = "\033[91m✗\033[0m"
passed = 0
failed = 0
def check(label: str, condition: bool, detail: str = ""):
global passed, failed
if condition:
passed += 1
print(f" {PASS} {label}")
else:
failed += 1
msg = f" {FAIL} {label}"
if detail:
msg += f"{detail}"
print(msg)
def get_token(username="administrator", password="admin123"):
r = requests.post(f"{BASE}/auth/login",
data={"username": username, "password": password})
if r.status_code == 200:
return r.json().get("access_token") or r.json().get("token")
raise RuntimeError(f"Login failed: {r.status_code} {r.text[:200]}")
def auth(token):
return {"Authorization": f"Bearer {token}"}
def get_first_technique(headers):
r = requests.get(f"{BASE}/techniques", headers=headers, params={"limit": 5})
items = r.json()
if isinstance(items, dict):
items = items.get("items", [])
if not items:
raise RuntimeError("No techniques found")
return items
# ─────────────────────────────────────────────────────────────────────────────
def main():
print("\n====== Phase 12 QA — Risk Intelligence ======\n")
token = get_token()
h = auth(token)
techniques = get_first_technique(h)
tid = techniques[0]["id"]
print(f" Using technique_id: {tid}\n")
# ── Block 1: Compute single technique ────────────────────────────────────
print("── Block 1: Compute single technique ──")
r = requests.post(f"{BASE}/risk/profiles/{tid}/compute", headers=h)
check("POST /risk/profiles/{id}/compute → 200", r.status_code == 200,
r.text[:150])
profile = r.json() if r.status_code == 200 else {}
check("Profile has technique_id", profile.get("technique_id") == tid)
check("risk_score is float 0-100",
isinstance(profile.get("risk_score"), (int, float))
and 0 <= profile.get("risk_score", -1) <= 100)
check("risk_level is valid",
profile.get("risk_level") in ("critical", "high", "medium", "low", "info"))
check("detection_gap in 0-1",
0 <= profile.get("detection_gap", -1) <= 1)
check("is_stale = False", profile.get("is_stale") == False)
check("scoring_breakdown present", bool(profile.get("scoring_breakdown")))
check("recommendations is list", isinstance(profile.get("recommendations"), list))
# Compute a second technique
if len(techniques) > 1:
tid2 = techniques[1]["id"]
r = requests.post(f"{BASE}/risk/profiles/{tid2}/compute", headers=h)
check("POST compute second technique → 200", r.status_code == 200,
r.text[:120])
# Compute non-existent technique → 404
r = requests.post(
f"{BASE}/risk/profiles/00000000-0000-0000-0000-000000000001/compute",
headers=h)
check("POST compute non-existent technique → 404", r.status_code == 404)
print()
# ── Block 2: Compute all techniques ──────────────────────────────────────
print("── Block 2: Compute ALL techniques ──")
r = requests.post(f"{BASE}/risk/compute", headers=h)
check("POST /risk/compute → 202", r.status_code == 202, r.text[:150])
result = r.json() if r.status_code == 202 else {}
check("computed > 0", result.get("computed", 0) > 0,
f"computed={result.get('computed')}")
check("errors == 0", result.get("errors", 99) == 0,
f"errors={result.get('errors')}")
check("duration_seconds present", "duration_seconds" in result)
print(f" computed={result.get('computed')} techniques in "
f"{result.get('duration_seconds', '?')}s")
print()
# ── Block 3: List profiles ────────────────────────────────────────────────
print("── Block 3: List risk profiles ──")
r = requests.get(f"{BASE}/risk/profiles", headers=h)
check("GET /risk/profiles → 200", r.status_code == 200)
profiles = r.json() if r.status_code == 200 else []
check("Profiles list not empty", len(profiles) > 0)
check("Each profile has risk_score",
all("risk_score" in p for p in profiles[:5]))
check("Sorted by risk_score desc",
all(profiles[i]["risk_score"] >= profiles[i+1]["risk_score"]
for i in range(min(len(profiles)-1, 5))))
# Filter by risk_level
for level in ("critical", "high", "medium", "low", "info"):
r2 = requests.get(f"{BASE}/risk/profiles", headers=h,
params={"risk_level": level})
if r2.status_code == 200 and r2.json():
check(f"Filter risk_level={level} → only that level",
all(p["risk_level"] == level for p in r2.json()))
break
else:
# Just verify the filter works without crashing
r2 = requests.get(f"{BASE}/risk/profiles", headers=h,
params={"risk_level": "info"})
check("Filter risk_level=info → 200", r2.status_code == 200)
# Filter by min_score
r = requests.get(f"{BASE}/risk/profiles", headers=h,
params={"min_score": 0.0, "max_score": 100.0})
check("Filter min/max_score → 200", r.status_code == 200)
# limit + offset
r = requests.get(f"{BASE}/risk/profiles", headers=h,
params={"limit": 2, "offset": 0})
check("GET ?limit=2 returns ≤2 profiles", r.status_code == 200
and len(r.json()) <= 2)
print()
# ── Block 4: Get single profile ───────────────────────────────────────────
print("── Block 4: Get single risk profile ──")
r = requests.get(f"{BASE}/risk/profiles/{tid}", headers=h)
check("GET /risk/profiles/{technique_id} → 200", r.status_code == 200)
p = r.json() if r.status_code == 200 else {}
check("Correct technique_id", p.get("technique_id") == tid)
check("scoring_breakdown has detection_gap key",
"detection_gap" in (p.get("scoring_breakdown") or {}))
check("scoring_breakdown has threat_actor key",
"threat_actor" in (p.get("scoring_breakdown") or {}))
check("scoring_breakdown has osint key",
"osint" in (p.get("scoring_breakdown") or {}))
check("scoring_breakdown has test_failures key",
"test_failures" in (p.get("scoring_breakdown") or {}))
# Not found → 404
r = requests.get(
f"{BASE}/risk/profiles/00000000-0000-0000-0000-000000000001",
headers=h)
check("GET non-existent profile → 404", r.status_code == 404)
print()
# ── Block 5: Risk matrix ──────────────────────────────────────────────────
print("── Block 5: Risk matrix ──")
r = requests.get(f"{BASE}/risk/matrix", headers=h)
check("GET /risk/matrix → 200", r.status_code == 200)
matrix = r.json() if r.status_code == 200 else []
check("Matrix not empty", len(matrix) > 0)
if matrix:
entry = matrix[0]
check("Matrix entry has technique_name", "technique_name" in entry)
check("Matrix entry has likelihood", "likelihood" in entry)
check("Matrix entry has impact", "impact" in entry)
check("Matrix entry has risk_level", "risk_level" in entry)
print()
# ── Block 6: Risk summary ─────────────────────────────────────────────────
print("── Block 6: Risk summary ──")
r = requests.get(f"{BASE}/risk/summary", headers=h)
check("GET /risk/summary → 200", r.status_code == 200)
summary = r.json() if r.status_code == 200 else {}
check("total_techniques > 0", summary.get("total_techniques", 0) > 0)
check("scored_techniques > 0", summary.get("scored_techniques", 0) > 0)
check("by_level has all levels",
all(k in summary.get("by_level", {})
for k in ("critical", "high", "medium", "low", "info")))
check("avg_risk_score is float",
isinstance(summary.get("avg_risk_score"), (int, float)))
check("top_risks is list", isinstance(summary.get("top_risks"), list))
if summary.get("top_risks"):
top = summary["top_risks"][0]
check("Top risk has technique_name", "technique_name" in top)
check("Top risk has risk_score", "risk_score" in top)
print()
# ── Block 7: Recommendations ──────────────────────────────────────────────
print("── Block 7: Recommendations ──")
r = requests.get(f"{BASE}/risk/recommendations", headers=h)
check("GET /risk/recommendations → 200", r.status_code == 200)
recs = r.json() if r.status_code == 200 else []
check("Recommendations list not empty", len(recs) > 0)
if recs:
rec = recs[0]
check("Recommendation has priority", "priority" in rec)
check("Recommendation has recommendations list",
isinstance(rec.get("recommendations"), list))
check("Recommendations sorted by priority",
all(recs[i]["priority"] <= recs[i+1]["priority"]
for i in range(min(len(recs)-1, 4))))
# Custom limit
r = requests.get(f"{BASE}/risk/recommendations", headers=h,
params={"limit": 3})
check("GET ?limit=3 returns ≤3 recommendations",
r.status_code == 200 and len(r.json()) <= 3)
print()
# ── Block 8: Top risks ────────────────────────────────────────────────────
print("── Block 8: Top risks ──")
r = requests.get(f"{BASE}/risk/top", headers=h)
check("GET /risk/top → 200", r.status_code == 200)
top = r.json() if r.status_code == 200 else []
check("Top risks not empty", len(top) > 0)
r = requests.get(f"{BASE}/risk/top", headers=h, params={"limit": 3})
check("GET /risk/top?limit=3 → ≤3 results",
r.status_code == 200 and len(r.json()) <= 3)
print()
# ── Block 9: Auth protection ──────────────────────────────────────────────
print("── Block 9: Auth protection ──")
no_auth = [
("GET", f"{BASE}/risk/profiles"),
("GET", f"{BASE}/risk/matrix"),
("GET", f"{BASE}/risk/summary"),
("GET", f"{BASE}/risk/recommendations"),
("GET", f"{BASE}/risk/top"),
]
for method, url in no_auth:
r = requests.request(method, url)
ep = url.split("/api/v1")[1]
check(f"{method} {ep} without auth → 401", r.status_code == 401)
# Admin-only compute endpoint
# Create a non-admin token for role check
r_na = requests.post(f"{BASE}/risk/compute")
check("POST /risk/compute without auth → 401", r_na.status_code == 401)
print()
# ── Block 10: Regression ─────────────────────────────────────────────────
print("── Block 10: Regression ──")
r = requests.get(f"{BASE}/knowledge/playbooks", headers=h)
check("GET /knowledge/playbooks still works", r.status_code == 200)
r = requests.get(f"{BASE}/attack-paths", headers=h)
check("GET /attack-paths still works", r.status_code == 200)
r = requests.get(f"{BASE}/techniques", headers=h)
check("GET /techniques still works", r.status_code == 200)
print()
# ── Summary ───────────────────────────────────────────────────────────────
total = passed + failed
print(f"====== Results: {passed}/{total} passed", end="")
if failed:
print(f"\033[91m{failed} FAILED\033[0m ======\n")
sys.exit(1)
else:
print(" ✓ ALL PASSED ======\n")
if __name__ == "__main__":
main()