feat: Hermes Zero-Day Research Engine - 9 new modules + UI tab
Browse fileshermes_orchestrator, fuzzing_engine, symbolic_engine, taint_analyzer, cve_intel, commit_watcher, exploit_primitives, bounty_gateway, vuln_classifier + adversarial_reviewer upgraded to R1/Llama3.3/Gemma3 + Hermes tab in app.py
- adversarial_reviewer.py +3 -3
- app.py +277 -2
- bounty_gateway.py +398 -0
- commit_watcher.py +339 -0
- cve_intel.py +333 -0
- exploit_primitives.py +310 -0
- fuzzing_engine.py +375 -0
- hermes_orchestrator.py +632 -0
- mcp_config.json +25 -5
- requirements.txt +4 -0
- symbolic_engine.py +350 -0
- taint_analyzer.py +304 -0
- vuln_classifier.py +360 -0
adversarial_reviewer.py
CHANGED
|
@@ -22,10 +22,10 @@ OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
|
| 22 |
|
| 23 |
ADVERSARY_MODEL_PRIMARY = os.getenv(
|
| 24 |
"RHODAWK_ADVERSARY_MODEL",
|
| 25 |
-
"
|
| 26 |
)
|
| 27 |
-
ADVERSARY_MODEL_SECONDARY = "
|
| 28 |
-
ADVERSARY_MODEL_TERTIARY = "
|
| 29 |
|
| 30 |
_MODEL_CHAIN = [
|
| 31 |
ADVERSARY_MODEL_PRIMARY,
|
|
|
|
| 22 |
|
| 23 |
ADVERSARY_MODEL_PRIMARY = os.getenv(
|
| 24 |
"RHODAWK_ADVERSARY_MODEL",
|
| 25 |
+
"deepseek/deepseek-r1:free"
|
| 26 |
)
|
| 27 |
+
ADVERSARY_MODEL_SECONDARY = "meta-llama/llama-3.3-70b-instruct:free"
|
| 28 |
+
ADVERSARY_MODEL_TERTIARY = "google/gemma-3-27b-it:free"
|
| 29 |
|
| 30 |
_MODEL_CHAIN = [
|
| 31 |
ADVERSARY_MODEL_PRIMARY,
|
app.py
CHANGED
|
@@ -35,6 +35,15 @@ from git import Repo
|
|
| 35 |
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 36 |
|
| 37 |
from adversarial_reviewer import run_adversarial_review
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
from audit_logger import export_compliance_report, log_audit_event, read_audit_trail, verify_chain_integrity
|
| 39 |
from conviction_engine import evaluate_conviction, auto_merge_pr
|
| 40 |
from formal_verifier import run_formal_verification
|
|
@@ -1374,6 +1383,151 @@ def reject_disclosure_fn(disclosure_id: str) -> str:
|
|
| 1374 |
return f"Error: {e}"
|
| 1375 |
|
| 1376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1377 |
# ──────────────────────────────────────────────────────────────
|
| 1378 |
# GRADIO ENTERPRISE DASHBOARD
|
| 1379 |
# ──────────────────────────────────────────────────────────────
|
|
@@ -1802,13 +1956,134 @@ Approved disclosures generate a message you send manually via the maintainer's s
|
|
| 1802 |
)
|
| 1803 |
sr_reject_btn.click(reject_disclosure_fn, inputs=sr_did, outputs=sr_approval_out)
|
| 1804 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1805 |
# ── AUTO-REFRESH ────────────────────────────────────────────
|
| 1806 |
timer = gr.Timer(3)
|
| 1807 |
-
timer.tick(get_live_logs,
|
| 1808 |
-
timer.tick(get_metrics_row,
|
|
|
|
| 1809 |
|
| 1810 |
demo.load(get_live_logs, outputs=live_logs)
|
| 1811 |
demo.load(get_metrics_row, outputs=[stat_status, stat_total, stat_done, stat_prs, stat_failed, stat_sast])
|
|
|
|
|
|
|
| 1812 |
demo.load(get_job_table, outputs=job_table)
|
| 1813 |
demo.load(get_audit_display, outputs=audit_log)
|
| 1814 |
demo.load(get_chain_integrity_display, outputs=chain_status)
|
|
|
|
| 35 |
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 36 |
|
| 37 |
from adversarial_reviewer import run_adversarial_review
|
| 38 |
+
from hermes_orchestrator import (
|
| 39 |
+
run_hermes_research, get_hermes_logs, get_session_summary,
|
| 40 |
+
compute_ves, build_tvg,
|
| 41 |
+
)
|
| 42 |
+
from bounty_gateway import (
|
| 43 |
+
get_pipeline, get_pipeline_summary, human_approve, human_reject,
|
| 44 |
+
submit_to_hackerone, submit_github_advisory, add_to_pipeline,
|
| 45 |
+
)
|
| 46 |
+
from vuln_classifier import classify_vulnerability, get_all_cwes
|
| 47 |
from audit_logger import export_compliance_report, log_audit_event, read_audit_trail, verify_chain_integrity
|
| 48 |
from conviction_engine import evaluate_conviction, auto_merge_pr
|
| 49 |
from formal_verifier import run_formal_verification
|
|
|
|
| 1383 |
return f"Error: {e}"
|
| 1384 |
|
| 1385 |
|
| 1386 |
+
# ──────────────────────────────────────────────────────────────
|
| 1387 |
+
# HERMES ORCHESTRATOR HELPERS
|
| 1388 |
+
# ──────────────────────────────────────────────────────────────
|
| 1389 |
+
|
| 1390 |
+
_hermes_sessions: dict = {}
|
| 1391 |
+
_hermes_active_session_id: str = ""
|
| 1392 |
+
_hermes_running = threading.Event()
|
| 1393 |
+
|
| 1394 |
+
|
| 1395 |
+
def _hermes_run_background(
|
| 1396 |
+
target_repo: str,
|
| 1397 |
+
repo_dir: str,
|
| 1398 |
+
focus_area: str,
|
| 1399 |
+
max_iterations: int,
|
| 1400 |
+
) -> None:
|
| 1401 |
+
global _hermes_active_session_id
|
| 1402 |
+
_hermes_running.set()
|
| 1403 |
+
try:
|
| 1404 |
+
session = run_hermes_research(
|
| 1405 |
+
target_repo=target_repo,
|
| 1406 |
+
repo_dir=repo_dir,
|
| 1407 |
+
focus_area=focus_area,
|
| 1408 |
+
max_iterations=int(max_iterations),
|
| 1409 |
+
)
|
| 1410 |
+
_hermes_sessions[session.session_id] = session
|
| 1411 |
+
_hermes_active_session_id = session.session_id
|
| 1412 |
+
|
| 1413 |
+
for finding in session.findings:
|
| 1414 |
+
add_to_pipeline(
|
| 1415 |
+
finding_id=finding.finding_id,
|
| 1416 |
+
title=finding.title,
|
| 1417 |
+
description=finding.description,
|
| 1418 |
+
proof_of_concept=finding.proof_of_concept,
|
| 1419 |
+
target_repo=target_repo,
|
| 1420 |
+
cwe_id=finding.cwe_id,
|
| 1421 |
+
severity=finding.severity,
|
| 1422 |
+
estimated_cvss=round(finding.ves_score, 1),
|
| 1423 |
+
bounty_tier="P1" if finding.ves_score >= 8 else "P2" if finding.ves_score >= 5 else "P3",
|
| 1424 |
+
exploit_class=finding.exploit_primitive,
|
| 1425 |
+
)
|
| 1426 |
+
finally:
|
| 1427 |
+
_hermes_running.clear()
|
| 1428 |
+
|
| 1429 |
+
|
| 1430 |
+
def hermes_start_research(
|
| 1431 |
+
target_repo: str, local_path: str, focus_area: str, max_iter: str
|
| 1432 |
+
) -> str:
|
| 1433 |
+
if _hermes_running.is_set():
|
| 1434 |
+
return "⚠️ Hermes is already running a research session. Wait for it to complete."
|
| 1435 |
+
if not target_repo.strip():
|
| 1436 |
+
return "❌ Target repository is required (e.g. owner/repo)"
|
| 1437 |
+
|
| 1438 |
+
repo_dir = local_path.strip() or f"/data/repo/{target_repo.split('/')[-1]}"
|
| 1439 |
+
if not os.path.isdir(repo_dir):
|
| 1440 |
+
return f"❌ Local path not found: {repo_dir}\nClone the repo first using the main Audit tab."
|
| 1441 |
+
|
| 1442 |
+
t = threading.Thread(
|
| 1443 |
+
target=_hermes_run_background,
|
| 1444 |
+
args=(target_repo.strip(), repo_dir, focus_area.strip(), max_iter or 15),
|
| 1445 |
+
daemon=True, name="hermes-research",
|
| 1446 |
+
)
|
| 1447 |
+
t.start()
|
| 1448 |
+
return (
|
| 1449 |
+
f"🧠 Hermes started on {target_repo}\n"
|
| 1450 |
+
f"Focus: {focus_area or 'Full autonomous scan'}\n"
|
| 1451 |
+
f"Max iterations: {max_iter}\n\n"
|
| 1452 |
+
"Watch the live logs below. Findings will appear in the Disclosure Pipeline tab."
|
| 1453 |
+
)
|
| 1454 |
+
|
| 1455 |
+
|
| 1456 |
+
def hermes_get_live_logs() -> str:
|
| 1457 |
+
logs = get_hermes_logs()
|
| 1458 |
+
status = "🔄 RUNNING" if _hermes_running.is_set() else "⏸ IDLE"
|
| 1459 |
+
header = f"[HERMES STATUS: {status}]\n{'─' * 50}\n"
|
| 1460 |
+
return header + "\n".join(logs[-80:]) if logs else header + "No logs yet."
|
| 1461 |
+
|
| 1462 |
+
|
| 1463 |
+
def hermes_get_session_summary() -> str:
|
| 1464 |
+
if not _hermes_active_session_id:
|
| 1465 |
+
return "No session completed yet."
|
| 1466 |
+
session = _hermes_sessions.get(_hermes_active_session_id)
|
| 1467 |
+
if not session:
|
| 1468 |
+
return "Session not found."
|
| 1469 |
+
summary = get_session_summary(session)
|
| 1470 |
+
lines = [
|
| 1471 |
+
f"Session: {summary['session_id']}",
|
| 1472 |
+
f"Target: {summary['target']}",
|
| 1473 |
+
f"Phase: {summary['phase']}",
|
| 1474 |
+
f"Started: {summary['started_at']}",
|
| 1475 |
+
f"Completed: {summary.get('completed_at', 'in progress')}",
|
| 1476 |
+
f"Total findings: {summary['total_findings']}",
|
| 1477 |
+
f"By severity: {summary['by_severity']}",
|
| 1478 |
+
f"Tool calls: {summary['tool_calls']}",
|
| 1479 |
+
f"Attack surface sinks: {summary['attack_surface_size']}",
|
| 1480 |
+
"",
|
| 1481 |
+
"TOP FINDINGS (by VES score):",
|
| 1482 |
+
]
|
| 1483 |
+
for f in summary["top_findings"]:
|
| 1484 |
+
lines.append(
|
| 1485 |
+
f" [{f['severity']}] {f['title'][:60]}\n"
|
| 1486 |
+
f" CWE: {f['cwe']} | VES: {f['ves']} | ACTS: {f['acts']}\n"
|
| 1487 |
+
f" File: {f['file']} | Status: {f['status']}"
|
| 1488 |
+
)
|
| 1489 |
+
return "\n".join(lines)
|
| 1490 |
+
|
| 1491 |
+
|
| 1492 |
+
def hermes_get_pipeline_display() -> str:
|
| 1493 |
+
return get_pipeline_summary()
|
| 1494 |
+
|
| 1495 |
+
|
| 1496 |
+
def hermes_approve_finding(record_id: str, notes: str) -> str:
|
| 1497 |
+
if not record_id.strip():
|
| 1498 |
+
return "❌ Record ID required"
|
| 1499 |
+
result = human_approve(record_id.strip(), notes.strip())
|
| 1500 |
+
return f"✅ Finding {record_id} approved.\n{result}"
|
| 1501 |
+
|
| 1502 |
+
|
| 1503 |
+
def hermes_reject_finding(record_id: str, notes: str) -> str:
|
| 1504 |
+
if not record_id.strip():
|
| 1505 |
+
return "❌ Record ID required"
|
| 1506 |
+
result = human_reject(record_id.strip(), notes.strip())
|
| 1507 |
+
return f"❌ Finding {record_id} rejected.\n{result}"
|
| 1508 |
+
|
| 1509 |
+
|
| 1510 |
+
def hermes_submit_hackerone(record_id: str) -> str:
|
| 1511 |
+
if not record_id.strip():
|
| 1512 |
+
return "❌ Record ID required"
|
| 1513 |
+
result = submit_to_hackerone(record_id.strip())
|
| 1514 |
+
if result.get("success"):
|
| 1515 |
+
return f"✅ Submitted to HackerOne: {result.get('url')}"
|
| 1516 |
+
return f"❌ Submission failed: {result.get('error')}"
|
| 1517 |
+
|
| 1518 |
+
|
| 1519 |
+
def hermes_submit_github(record_id: str, owner_repo: str) -> str:
|
| 1520 |
+
if not record_id.strip() or not owner_repo.strip():
|
| 1521 |
+
return "❌ Record ID and owner/repo required"
|
| 1522 |
+
parts = owner_repo.strip().split("/")
|
| 1523 |
+
if len(parts) != 2:
|
| 1524 |
+
return "❌ Format must be owner/repo"
|
| 1525 |
+
result = submit_github_advisory(record_id.strip(), parts[0], parts[1])
|
| 1526 |
+
if result.get("success"):
|
| 1527 |
+
return f"✅ GitHub Advisory created: {result.get('url')}"
|
| 1528 |
+
return f"❌ Submission failed: {result.get('error')}"
|
| 1529 |
+
|
| 1530 |
+
|
| 1531 |
# ──────────────────────────────────────────────────────────────
|
| 1532 |
# GRADIO ENTERPRISE DASHBOARD
|
| 1533 |
# ──────────────────────────────────────────────────────────────
|
|
|
|
| 1956 |
)
|
| 1957 |
sr_reject_btn.click(reject_disclosure_fn, inputs=sr_did, outputs=sr_approval_out)
|
| 1958 |
|
| 1959 |
+
# ── HERMES AI SECURITY RESEARCHER TAB ──────────────────────
|
| 1960 |
+
with gr.Tab("🧠 Hermes Zero-Day"):
|
| 1961 |
+
gr.Markdown("""
|
| 1962 |
+
# Hermes — Autonomous Zero-Day Research Engine
|
| 1963 |
+
|
| 1964 |
+
Hermes is your AI security researcher. Point it at any open source project and it will:
|
| 1965 |
+
- **Map the attack surface** (entry points, dangerous sinks, security-critical files)
|
| 1966 |
+
- **Run taint analysis** to trace untrusted input to dangerous sinks
|
| 1967 |
+
- **Perform symbolic execution** to find unchecked code paths
|
| 1968 |
+
- **Generate and run fuzz campaigns** to find crashes
|
| 1969 |
+
- **Reason about exploitability** (CVSS, PoC generation)
|
| 1970 |
+
- **Score findings** with VES (Vulnerability Entropy Score) + ACTS (Adversarial Consensus)
|
| 1971 |
+
|
| 1972 |
+
**ALL findings require human approval before any disclosure is sent.**
|
| 1973 |
+
""")
|
| 1974 |
+
with gr.Tabs():
|
| 1975 |
+
with gr.Tab("🚀 Launch Research"):
|
| 1976 |
+
gr.Markdown("### Start an autonomous security research session")
|
| 1977 |
+
with gr.Row():
|
| 1978 |
+
hermes_repo = gr.Textbox(label="Target Repository (owner/repo)", placeholder="torvalds/linux", scale=2)
|
| 1979 |
+
hermes_path = gr.Textbox(label="Local Clone Path", placeholder="/data/repo/linux", scale=2)
|
| 1980 |
+
hermes_focus = gr.Textbox(
|
| 1981 |
+
label="Focus Area (optional)",
|
| 1982 |
+
placeholder="memory management subsystem, authentication middleware, crypto primitives...",
|
| 1983 |
+
)
|
| 1984 |
+
with gr.Row():
|
| 1985 |
+
hermes_max_iter = gr.Slider(minimum=5, maximum=30, value=15, step=1, label="Max Research Iterations")
|
| 1986 |
+
hermes_launch = gr.Button("🧠 Launch Hermes", variant="primary", scale=1)
|
| 1987 |
+
hermes_launch_out = gr.Textbox(label="Status", interactive=False, lines=5)
|
| 1988 |
+
hermes_launch.click(
|
| 1989 |
+
hermes_start_research,
|
| 1990 |
+
inputs=[hermes_repo, hermes_path, hermes_focus, hermes_max_iter],
|
| 1991 |
+
outputs=hermes_launch_out,
|
| 1992 |
+
)
|
| 1993 |
+
|
| 1994 |
+
with gr.Tab("📡 Live Research Logs"):
|
| 1995 |
+
hermes_live_logs = gr.TextArea(
|
| 1996 |
+
label="Hermes Research Log (auto-refreshes)",
|
| 1997 |
+
lines=30, interactive=False,
|
| 1998 |
+
)
|
| 1999 |
+
gr.Button("🔄 Refresh Logs", variant="secondary").click(
|
| 2000 |
+
hermes_get_live_logs, outputs=hermes_live_logs
|
| 2001 |
+
)
|
| 2002 |
+
|
| 2003 |
+
with gr.Tab("📊 Session Summary"):
|
| 2004 |
+
hermes_summary_out = gr.TextArea(label="Last Session Summary", lines=25, interactive=False)
|
| 2005 |
+
gr.Button("📊 Get Summary", variant="secondary").click(
|
| 2006 |
+
hermes_get_session_summary, outputs=hermes_summary_out
|
| 2007 |
+
)
|
| 2008 |
+
|
| 2009 |
+
with gr.Tab("🔒 Disclosure Pipeline"):
|
| 2010 |
+
gr.Markdown("""
|
| 2011 |
+
### Human-Approval Gate
|
| 2012 |
+
|
| 2013 |
+
All findings sit here as **PENDING_HUMAN_APPROVAL** until you explicitly approve them.
|
| 2014 |
+
Hermes never auto-submits. You review, then approve or reject each finding individually.
|
| 2015 |
+
After approval, you can submit to HackerOne or create a GitHub Security Advisory.
|
| 2016 |
+
|
| 2017 |
+
**90-day disclosure countdown starts on approval.**
|
| 2018 |
+
""")
|
| 2019 |
+
hermes_pipeline_out = gr.TextArea(label="Pipeline Status", lines=12, interactive=False)
|
| 2020 |
+
gr.Button("🔄 Refresh Pipeline", variant="secondary").click(
|
| 2021 |
+
hermes_get_pipeline_display, outputs=hermes_pipeline_out
|
| 2022 |
+
)
|
| 2023 |
+
|
| 2024 |
+
gr.HTML("<hr/>")
|
| 2025 |
+
gr.Markdown("#### Review & Approve/Reject a Finding")
|
| 2026 |
+
with gr.Row():
|
| 2027 |
+
hermes_record_id = gr.Textbox(label="Record ID", scale=2)
|
| 2028 |
+
hermes_notes = gr.Textbox(label="Analyst Notes", scale=3)
|
| 2029 |
+
with gr.Row():
|
| 2030 |
+
hermes_approve_btn = gr.Button("✅ Approve Finding", variant="primary")
|
| 2031 |
+
hermes_reject_btn = gr.Button("❌ Reject Finding", variant="secondary")
|
| 2032 |
+
hermes_approval_out = gr.Textbox(label="Result", interactive=False)
|
| 2033 |
+
hermes_approve_btn.click(
|
| 2034 |
+
hermes_approve_finding,
|
| 2035 |
+
inputs=[hermes_record_id, hermes_notes],
|
| 2036 |
+
outputs=hermes_approval_out,
|
| 2037 |
+
)
|
| 2038 |
+
hermes_reject_btn.click(
|
| 2039 |
+
hermes_reject_finding,
|
| 2040 |
+
inputs=[hermes_record_id, hermes_notes],
|
| 2041 |
+
outputs=hermes_approval_out,
|
| 2042 |
+
)
|
| 2043 |
+
|
| 2044 |
+
gr.HTML("<hr/>")
|
| 2045 |
+
gr.Markdown("#### Submit Approved Finding (requires credentials in env vars)")
|
| 2046 |
+
with gr.Row():
|
| 2047 |
+
hermes_submit_record = gr.Textbox(label="Approved Record ID", scale=2)
|
| 2048 |
+
hermes_gh_repo = gr.Textbox(label="GitHub owner/repo (for GHSA)", placeholder="torvalds/linux", scale=2)
|
| 2049 |
+
with gr.Row():
|
| 2050 |
+
hermes_h1_btn = gr.Button("🎯 Submit to HackerOne", variant="primary")
|
| 2051 |
+
hermes_gh_btn = gr.Button("🐙 Create GitHub Advisory", variant="secondary")
|
| 2052 |
+
hermes_submit_out = gr.Textbox(label="Submission Result", interactive=False)
|
| 2053 |
+
hermes_h1_btn.click(
|
| 2054 |
+
hermes_submit_hackerone,
|
| 2055 |
+
inputs=[hermes_submit_record],
|
| 2056 |
+
outputs=hermes_submit_out,
|
| 2057 |
+
)
|
| 2058 |
+
hermes_gh_btn.click(
|
| 2059 |
+
hermes_submit_github,
|
| 2060 |
+
inputs=[hermes_submit_record, hermes_gh_repo],
|
| 2061 |
+
outputs=hermes_submit_out,
|
| 2062 |
+
)
|
| 2063 |
+
|
| 2064 |
+
with gr.Tab("📚 CWE Reference"):
|
| 2065 |
+
gr.Markdown("### CWE Taxonomy — Coverage Map")
|
| 2066 |
+
cwe_table_data = [
|
| 2067 |
+
[c["cwe_id"], c["name"], c["category"], c["severity"],
|
| 2068 |
+
str(c["cvss_base"]), c.get("owasp", "")]
|
| 2069 |
+
for c in get_all_cwes()
|
| 2070 |
+
]
|
| 2071 |
+
gr.Dataframe(
|
| 2072 |
+
value=cwe_table_data,
|
| 2073 |
+
headers=["CWE ID", "Name", "Category", "Severity", "CVSS", "OWASP"],
|
| 2074 |
+
interactive=False,
|
| 2075 |
+
)
|
| 2076 |
+
|
| 2077 |
# ── AUTO-REFRESH ────────────────────────────────────────────
|
| 2078 |
timer = gr.Timer(3)
|
| 2079 |
+
timer.tick(get_live_logs, outputs=live_logs)
|
| 2080 |
+
timer.tick(get_metrics_row, outputs=[stat_status, stat_total, stat_done, stat_prs, stat_failed, stat_sast])
|
| 2081 |
+
timer.tick(hermes_get_live_logs, outputs=hermes_live_logs)
|
| 2082 |
|
| 2083 |
demo.load(get_live_logs, outputs=live_logs)
|
| 2084 |
demo.load(get_metrics_row, outputs=[stat_status, stat_total, stat_done, stat_prs, stat_failed, stat_sast])
|
| 2085 |
+
demo.load(hermes_get_live_logs, outputs=hermes_live_logs)
|
| 2086 |
+
demo.load(hermes_get_pipeline_display, outputs=hermes_pipeline_out)
|
| 2087 |
demo.load(get_job_table, outputs=job_table)
|
| 2088 |
demo.load(get_audit_display, outputs=audit_log)
|
| 2089 |
demo.load(get_chain_integrity_display, outputs=chain_status)
|
bounty_gateway.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Bug Bounty Gateway (Human-Approval Required)
|
| 3 |
+
==========================================================
|
| 4 |
+
Manages the responsible disclosure pipeline to:
|
| 5 |
+
- HackerOne (REST API v1)
|
| 6 |
+
- Bugcrowd (JSON API)
|
| 7 |
+
- GitHub Security Advisories (GHSA)
|
| 8 |
+
- Direct maintainer email (coordinated disclosure)
|
| 9 |
+
|
| 10 |
+
CRITICAL DESIGN PRINCIPLE:
|
| 11 |
+
NOTHING is submitted without explicit human approval.
|
| 12 |
+
All findings sit in PENDING_HUMAN_APPROVAL state until a human clicks
|
| 13 |
+
the "Approve & Submit" button in the Gradio UI. The approval gate is
|
| 14 |
+
enforced at the API call level, not just the UI level.
|
| 15 |
+
|
| 16 |
+
Disclosure timeline follows Google Project Zero standard (90 days).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import sqlite3
|
| 25 |
+
import time
|
| 26 |
+
from dataclasses import dataclass, field, asdict
|
| 27 |
+
from enum import Enum
|
| 28 |
+
from typing import Optional
|
| 29 |
+
|
| 30 |
+
import requests
|
| 31 |
+
|
| 32 |
+
HACKERONE_API_KEY = os.getenv("HACKERONE_API_KEY", "")
|
| 33 |
+
HACKERONE_USERNAME = os.getenv("HACKERONE_USERNAME", "")
|
| 34 |
+
HACKERONE_PROGRAM = os.getenv("HACKERONE_PROGRAM", "")
|
| 35 |
+
BUGCROWD_API_KEY = os.getenv("BUGCROWD_API_KEY", "")
|
| 36 |
+
BUGCROWD_PROGRAM_URL = os.getenv("BUGCROWD_PROGRAM_URL", "")
|
| 37 |
+
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "")
|
| 38 |
+
|
| 39 |
+
DISCLOSURE_DB = os.getenv("RHODAWK_DISCLOSURE_DB", "/data/disclosure_pipeline.db")
|
| 40 |
+
DISCLOSURE_WINDOW_DAYS = int(os.getenv("RHODAWK_DISCLOSURE_DAYS", "90"))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class DisclosureStatus(str, Enum):
|
| 44 |
+
PENDING_HUMAN_APPROVAL = "PENDING_HUMAN_APPROVAL"
|
| 45 |
+
HUMAN_APPROVED = "HUMAN_APPROVED"
|
| 46 |
+
HUMAN_REJECTED = "HUMAN_REJECTED"
|
| 47 |
+
SUBMITTED_HACKERONE = "SUBMITTED_HACKERONE"
|
| 48 |
+
SUBMITTED_BUGCROWD = "SUBMITTED_BUGCROWD"
|
| 49 |
+
SUBMITTED_GITHUB_GHSA = "SUBMITTED_GITHUB_GHSA"
|
| 50 |
+
SUBMITTED_DIRECT = "SUBMITTED_DIRECT"
|
| 51 |
+
DUPLICATE = "DUPLICATE"
|
| 52 |
+
NOT_A_BUG = "NOT_A_BUG"
|
| 53 |
+
FIXED_BY_VENDOR = "FIXED_BY_VENDOR"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class DisclosureRecord:
|
| 58 |
+
record_id: str
|
| 59 |
+
finding_id: str
|
| 60 |
+
title: str
|
| 61 |
+
description: str
|
| 62 |
+
proof_of_concept: str
|
| 63 |
+
target_repo: str
|
| 64 |
+
cwe_id: str
|
| 65 |
+
severity: str
|
| 66 |
+
estimated_cvss: float
|
| 67 |
+
bounty_tier: str
|
| 68 |
+
exploit_class: str
|
| 69 |
+
status: DisclosureStatus = DisclosureStatus.PENDING_HUMAN_APPROVAL
|
| 70 |
+
platform: str = "none"
|
| 71 |
+
submission_url: str = ""
|
| 72 |
+
bounty_received: float = 0.0
|
| 73 |
+
created_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
| 74 |
+
approved_at: str = ""
|
| 75 |
+
submitted_at: str = ""
|
| 76 |
+
deadline: str = ""
|
| 77 |
+
human_notes: str = ""
|
| 78 |
+
cve_draft: dict = field(default_factory=dict)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _init_db():
|
| 82 |
+
os.makedirs(os.path.dirname(DISCLOSURE_DB), exist_ok=True)
|
| 83 |
+
conn = sqlite3.connect(DISCLOSURE_DB)
|
| 84 |
+
conn.execute("""
|
| 85 |
+
CREATE TABLE IF NOT EXISTS disclosure_records (
|
| 86 |
+
record_id TEXT PRIMARY KEY,
|
| 87 |
+
finding_id TEXT,
|
| 88 |
+
title TEXT,
|
| 89 |
+
description TEXT,
|
| 90 |
+
proof_of_concept TEXT,
|
| 91 |
+
target_repo TEXT,
|
| 92 |
+
cwe_id TEXT,
|
| 93 |
+
severity TEXT,
|
| 94 |
+
estimated_cvss REAL,
|
| 95 |
+
bounty_tier TEXT,
|
| 96 |
+
exploit_class TEXT,
|
| 97 |
+
status TEXT,
|
| 98 |
+
platform TEXT,
|
| 99 |
+
submission_url TEXT,
|
| 100 |
+
bounty_received REAL,
|
| 101 |
+
created_at TEXT,
|
| 102 |
+
approved_at TEXT,
|
| 103 |
+
submitted_at TEXT,
|
| 104 |
+
deadline TEXT,
|
| 105 |
+
human_notes TEXT,
|
| 106 |
+
cve_draft TEXT
|
| 107 |
+
)
|
| 108 |
+
""")
|
| 109 |
+
conn.commit()
|
| 110 |
+
conn.close()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
_init_db()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def add_to_pipeline(
|
| 117 |
+
finding_id: str,
|
| 118 |
+
title: str,
|
| 119 |
+
description: str,
|
| 120 |
+
proof_of_concept: str,
|
| 121 |
+
target_repo: str,
|
| 122 |
+
cwe_id: str,
|
| 123 |
+
severity: str,
|
| 124 |
+
estimated_cvss: float,
|
| 125 |
+
bounty_tier: str,
|
| 126 |
+
exploit_class: str,
|
| 127 |
+
cve_draft: dict = None,
|
| 128 |
+
) -> DisclosureRecord:
|
| 129 |
+
"""Add a new finding to the disclosure pipeline. Status = PENDING_HUMAN_APPROVAL."""
|
| 130 |
+
record_id = hashlib.sha256(f"{finding_id}{time.time()}".encode()).hexdigest()[:16]
|
| 131 |
+
deadline = time.strftime(
|
| 132 |
+
"%Y-%m-%dT%H:%M:%SZ",
|
| 133 |
+
time.gmtime(time.time() + DISCLOSURE_WINDOW_DAYS * 86400)
|
| 134 |
+
)
|
| 135 |
+
record = DisclosureRecord(
|
| 136 |
+
record_id=record_id,
|
| 137 |
+
finding_id=finding_id,
|
| 138 |
+
title=title,
|
| 139 |
+
description=description,
|
| 140 |
+
proof_of_concept=proof_of_concept,
|
| 141 |
+
target_repo=target_repo,
|
| 142 |
+
cwe_id=cwe_id,
|
| 143 |
+
severity=severity,
|
| 144 |
+
estimated_cvss=estimated_cvss,
|
| 145 |
+
bounty_tier=bounty_tier,
|
| 146 |
+
exploit_class=exploit_class,
|
| 147 |
+
deadline=deadline,
|
| 148 |
+
cve_draft=cve_draft or {},
|
| 149 |
+
)
|
| 150 |
+
_save_record(record)
|
| 151 |
+
print(f"[DISCLOSURE] Added to pipeline: {record_id} — Status: PENDING_HUMAN_APPROVAL")
|
| 152 |
+
return record
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _save_record(record: DisclosureRecord):
|
| 156 |
+
conn = sqlite3.connect(DISCLOSURE_DB)
|
| 157 |
+
conn.execute("""
|
| 158 |
+
INSERT OR REPLACE INTO disclosure_records VALUES (
|
| 159 |
+
?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?
|
| 160 |
+
)
|
| 161 |
+
""", (
|
| 162 |
+
record.record_id, record.finding_id, record.title, record.description,
|
| 163 |
+
record.proof_of_concept, record.target_repo, record.cwe_id, record.severity,
|
| 164 |
+
record.estimated_cvss, record.bounty_tier, record.exploit_class,
|
| 165 |
+
record.status.value, record.platform, record.submission_url,
|
| 166 |
+
record.bounty_received, record.created_at, record.approved_at,
|
| 167 |
+
record.submitted_at, record.deadline, record.human_notes,
|
| 168 |
+
json.dumps(record.cve_draft),
|
| 169 |
+
))
|
| 170 |
+
conn.commit()
|
| 171 |
+
conn.close()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def get_pipeline(status_filter: str = None) -> list[dict]:
|
| 175 |
+
"""Get all records in the pipeline, optionally filtered by status."""
|
| 176 |
+
conn = sqlite3.connect(DISCLOSURE_DB)
|
| 177 |
+
if status_filter:
|
| 178 |
+
rows = conn.execute(
|
| 179 |
+
"SELECT * FROM disclosure_records WHERE status=? ORDER BY created_at DESC",
|
| 180 |
+
(status_filter,)
|
| 181 |
+
).fetchall()
|
| 182 |
+
else:
|
| 183 |
+
rows = conn.execute(
|
| 184 |
+
"SELECT * FROM disclosure_records ORDER BY created_at DESC"
|
| 185 |
+
).fetchall()
|
| 186 |
+
conn.close()
|
| 187 |
+
|
| 188 |
+
columns = [
|
| 189 |
+
"record_id", "finding_id", "title", "description", "proof_of_concept",
|
| 190 |
+
"target_repo", "cwe_id", "severity", "estimated_cvss", "bounty_tier",
|
| 191 |
+
"exploit_class", "status", "platform", "submission_url", "bounty_received",
|
| 192 |
+
"created_at", "approved_at", "submitted_at", "deadline", "human_notes", "cve_draft",
|
| 193 |
+
]
|
| 194 |
+
return [dict(zip(columns, row)) for row in rows]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def human_approve(record_id: str, notes: str = "") -> dict:
|
| 198 |
+
"""
|
| 199 |
+
Human approval gate — must be called before any submission attempt.
|
| 200 |
+
Updates status to HUMAN_APPROVED. Still does not submit.
|
| 201 |
+
"""
|
| 202 |
+
conn = sqlite3.connect(DISCLOSURE_DB)
|
| 203 |
+
conn.execute(
|
| 204 |
+
"UPDATE disclosure_records SET status=?, approved_at=?, human_notes=? WHERE record_id=?",
|
| 205 |
+
(DisclosureStatus.HUMAN_APPROVED.value,
|
| 206 |
+
time.strftime("%Y-%m-%dT%H:%M:%SZ"), notes, record_id)
|
| 207 |
+
)
|
| 208 |
+
conn.commit()
|
| 209 |
+
conn.close()
|
| 210 |
+
print(f"[DISCLOSURE] HUMAN APPROVED: {record_id}")
|
| 211 |
+
return {"status": "approved", "record_id": record_id, "notes": notes}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def human_reject(record_id: str, notes: str = "") -> dict:
|
| 215 |
+
"""Human rejection — finding is closed without disclosure."""
|
| 216 |
+
conn = sqlite3.connect(DISCLOSURE_DB)
|
| 217 |
+
conn.execute(
|
| 218 |
+
"UPDATE disclosure_records SET status=?, human_notes=? WHERE record_id=?",
|
| 219 |
+
(DisclosureStatus.HUMAN_REJECTED.value, notes, record_id)
|
| 220 |
+
)
|
| 221 |
+
conn.commit()
|
| 222 |
+
conn.close()
|
| 223 |
+
print(f"[DISCLOSURE] HUMAN REJECTED: {record_id} — {notes}")
|
| 224 |
+
return {"status": "rejected", "record_id": record_id}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def submit_to_hackerone(record_id: str) -> dict:
|
| 228 |
+
"""
|
| 229 |
+
Submit an APPROVED finding to HackerOne.
|
| 230 |
+
REQUIRES human_approve() to have been called first.
|
| 231 |
+
"""
|
| 232 |
+
records = get_pipeline()
|
| 233 |
+
record = next((r for r in records if r["record_id"] == record_id), None)
|
| 234 |
+
if not record:
|
| 235 |
+
return {"error": f"Record {record_id} not found"}
|
| 236 |
+
|
| 237 |
+
if record["status"] != DisclosureStatus.HUMAN_APPROVED.value:
|
| 238 |
+
return {
|
| 239 |
+
"error": f"BLOCKED: Record status is '{record['status']}'. "
|
| 240 |
+
f"Human approval required before submission. "
|
| 241 |
+
f"Call human_approve('{record_id}') first."
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
if not HACKERONE_API_KEY or not HACKERONE_USERNAME or not HACKERONE_PROGRAM:
|
| 245 |
+
return {
|
| 246 |
+
"error": "HackerOne credentials not configured. "
|
| 247 |
+
"Set HACKERONE_API_KEY, HACKERONE_USERNAME, HACKERONE_PROGRAM env vars."
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
payload = {
|
| 251 |
+
"data": {
|
| 252 |
+
"type": "report",
|
| 253 |
+
"attributes": {
|
| 254 |
+
"title": record["title"],
|
| 255 |
+
"vulnerability_information": (
|
| 256 |
+
f"## Description\n{record['description']}\n\n"
|
| 257 |
+
f"## Proof of Concept\n```\n{record['proof_of_concept']}\n```\n\n"
|
| 258 |
+
f"## CWE\n{record['cwe_id']}\n\n"
|
| 259 |
+
f"## Severity\n{record['severity']} (CVSS: {record['estimated_cvss']})\n\n"
|
| 260 |
+
f"*Generated by Rhodawk AI Security Research Engine — "
|
| 261 |
+
f"human-verified before submission*"
|
| 262 |
+
),
|
| 263 |
+
"impact": (
|
| 264 |
+
f"Estimated severity: {record['severity']}\n"
|
| 265 |
+
f"Exploit class: {record['exploit_class']}\n"
|
| 266 |
+
f"Bounty tier estimate: {record['bounty_tier']}"
|
| 267 |
+
),
|
| 268 |
+
"severity_rating": {
|
| 269 |
+
"CRITICAL": "critical", "HIGH": "high",
|
| 270 |
+
"MEDIUM": "medium", "LOW": "low",
|
| 271 |
+
}.get(record["severity"], "medium"),
|
| 272 |
+
},
|
| 273 |
+
"relationships": {
|
| 274 |
+
"program": {"data": {"type": "program", "attributes": {"handle": HACKERONE_PROGRAM}}}
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
try:
|
| 280 |
+
resp = requests.post(
|
| 281 |
+
"https://api.hackerone.com/v1/hackers/reports",
|
| 282 |
+
auth=(HACKERONE_USERNAME, HACKERONE_API_KEY),
|
| 283 |
+
json=payload,
|
| 284 |
+
timeout=30,
|
| 285 |
+
)
|
| 286 |
+
if resp.status_code in (200, 201):
|
| 287 |
+
data = resp.json()
|
| 288 |
+
report_url = f"https://hackerone.com/reports/{data['data']['id']}"
|
| 289 |
+
conn = sqlite3.connect(DISCLOSURE_DB)
|
| 290 |
+
conn.execute(
|
| 291 |
+
"UPDATE disclosure_records SET status=?, platform=?, submission_url=?, submitted_at=? WHERE record_id=?",
|
| 292 |
+
(DisclosureStatus.SUBMITTED_HACKERONE.value, "hackerone", report_url,
|
| 293 |
+
time.strftime("%Y-%m-%dT%H:%M:%SZ"), record_id)
|
| 294 |
+
)
|
| 295 |
+
conn.commit()
|
| 296 |
+
conn.close()
|
| 297 |
+
return {"success": True, "platform": "hackerone", "url": report_url}
|
| 298 |
+
else:
|
| 299 |
+
return {"error": f"HackerOne API error {resp.status_code}: {resp.text[:500]}"}
|
| 300 |
+
except Exception as e:
|
| 301 |
+
return {"error": str(e)}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def submit_github_advisory(record_id: str, repo_owner: str, repo_name: str) -> dict:
|
| 305 |
+
"""
|
| 306 |
+
Submit an APPROVED finding as a GitHub Security Advisory (GHSA).
|
| 307 |
+
Requires GitHub token with security_events scope.
|
| 308 |
+
"""
|
| 309 |
+
records = get_pipeline()
|
| 310 |
+
record = next((r for r in records if r["record_id"] == record_id), None)
|
| 311 |
+
if not record:
|
| 312 |
+
return {"error": f"Record {record_id} not found"}
|
| 313 |
+
|
| 314 |
+
if record["status"] != DisclosureStatus.HUMAN_APPROVED.value:
|
| 315 |
+
return {"error": f"BLOCKED: Human approval required. Status: {record['status']}"}
|
| 316 |
+
|
| 317 |
+
if not GITHUB_TOKEN:
|
| 318 |
+
return {"error": "GITHUB_TOKEN not set"}
|
| 319 |
+
|
| 320 |
+
severity_map = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low"}
|
| 321 |
+
|
| 322 |
+
payload = {
|
| 323 |
+
"summary": record["title"],
|
| 324 |
+
"description": (
|
| 325 |
+
f"{record['description']}\n\n"
|
| 326 |
+
f"**Proof of Concept:**\n```\n{record['proof_of_concept']}\n```\n\n"
|
| 327 |
+
f"*Discovered by Rhodawk AI Security Research Engine — human-verified*"
|
| 328 |
+
),
|
| 329 |
+
"severity": severity_map.get(record["severity"], "medium"),
|
| 330 |
+
"cwe_ids": [record["cwe_id"]] if record["cwe_id"] != "CWE-UNKNOWN" else [],
|
| 331 |
+
"vulnerabilities": [
|
| 332 |
+
{"package": {"ecosystem": "other", "name": repo_name},
|
| 333 |
+
"vulnerable_version_range": "*"}
|
| 334 |
+
],
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
try:
|
| 338 |
+
resp = requests.post(
|
| 339 |
+
f"https://api.github.com/repos/{repo_owner}/{repo_name}/security-advisories",
|
| 340 |
+
headers={
|
| 341 |
+
"Authorization": f"Bearer {GITHUB_TOKEN}",
|
| 342 |
+
"Accept": "application/vnd.github+json",
|
| 343 |
+
"X-GitHub-API-Version": "2022-11-28",
|
| 344 |
+
},
|
| 345 |
+
json=payload,
|
| 346 |
+
timeout=30,
|
| 347 |
+
)
|
| 348 |
+
if resp.status_code in (200, 201):
|
| 349 |
+
data = resp.json()
|
| 350 |
+
advisory_url = data.get("html_url", "")
|
| 351 |
+
conn = sqlite3.connect(DISCLOSURE_DB)
|
| 352 |
+
conn.execute(
|
| 353 |
+
"UPDATE disclosure_records SET status=?, platform=?, submission_url=?, submitted_at=? WHERE record_id=?",
|
| 354 |
+
(DisclosureStatus.SUBMITTED_GITHUB_GHSA.value, "github_ghsa", advisory_url,
|
| 355 |
+
time.strftime("%Y-%m-%dT%H:%M:%SZ"), record_id)
|
| 356 |
+
)
|
| 357 |
+
conn.commit()
|
| 358 |
+
conn.close()
|
| 359 |
+
return {"success": True, "platform": "github_ghsa", "url": advisory_url}
|
| 360 |
+
else:
|
| 361 |
+
return {"error": f"GitHub API error {resp.status_code}: {resp.text[:500]}"}
|
| 362 |
+
except Exception as e:
|
| 363 |
+
return {"error": str(e)}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def get_pipeline_summary() -> str:
|
| 367 |
+
"""Human-readable pipeline summary for the Gradio dashboard."""
|
| 368 |
+
records = get_pipeline()
|
| 369 |
+
if not records:
|
| 370 |
+
return "No findings in disclosure pipeline yet."
|
| 371 |
+
|
| 372 |
+
pending = [r for r in records if r["status"] == "PENDING_HUMAN_APPROVAL"]
|
| 373 |
+
approved = [r for r in records if r["status"] == "HUMAN_APPROVED"]
|
| 374 |
+
submitted = [r for r in records if "SUBMITTED" in r["status"]]
|
| 375 |
+
rejected = [r for r in records if r["status"] == "HUMAN_REJECTED"]
|
| 376 |
+
|
| 377 |
+
lines = [
|
| 378 |
+
f"Disclosure Pipeline — {len(records)} total finding(s)\n",
|
| 379 |
+
f" ⏳ Pending human approval : {len(pending)}",
|
| 380 |
+
f" ✅ Human approved : {len(approved)}",
|
| 381 |
+
f" 📤 Submitted : {len(submitted)}",
|
| 382 |
+
f" ❌ Rejected : {len(rejected)}",
|
| 383 |
+
"",
|
| 384 |
+
]
|
| 385 |
+
for r in sorted(records, key=lambda x: x["estimated_cvss"], reverse=True)[:5]:
|
| 386 |
+
days_left = ""
|
| 387 |
+
if r.get("deadline"):
|
| 388 |
+
try:
|
| 389 |
+
deadline_ts = time.mktime(time.strptime(r["deadline"][:19], "%Y-%m-%dT%H:%M:%S"))
|
| 390 |
+
days = int((deadline_ts - time.time()) / 86400)
|
| 391 |
+
days_left = f" ({days}d left)"
|
| 392 |
+
except Exception:
|
| 393 |
+
pass
|
| 394 |
+
lines.append(
|
| 395 |
+
f" [{r['bounty_tier']}] {r['title'][:60]} | "
|
| 396 |
+
f"CVSS:{r['estimated_cvss']} | {r['status']}{days_left}"
|
| 397 |
+
)
|
| 398 |
+
return "\n".join(lines)
|
commit_watcher.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Commit Watcher + CAD Algorithm
|
| 3 |
+
============================================
|
| 4 |
+
Monitors GitHub repository commit streams for:
|
| 5 |
+
1. Silent security patches (fixes without CVE mention)
|
| 6 |
+
2. Regression introductions (commits that break previously safe invariants)
|
| 7 |
+
3. Dependency bumps that change security-relevant code
|
| 8 |
+
|
| 9 |
+
Custom Algorithm: CAD (Commit Anomaly Detection)
|
| 10 |
+
Uses statistical analysis of commit metadata + diff content to score
|
| 11 |
+
how "suspicious" a commit is from a security perspective.
|
| 12 |
+
High CAD score = likely silent security fix = potential unpatched vuln in older versions.
|
| 13 |
+
|
| 14 |
+
CAD Score Components:
|
| 15 |
+
- keyword_score: security-related words in commit message (without CVE/advisory mention)
|
| 16 |
+
- diff_complexity: unusual churn patterns (small message + large diff = suspicious)
|
| 17 |
+
- sink_delta: did the commit add/remove dangerous sinks?
|
| 18 |
+
- author_entropy: is this from an unusual author for this file?
|
| 19 |
+
- timing: late-night commits, weekend commits (higher anomaly weight)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import hashlib
|
| 25 |
+
import math
|
| 26 |
+
import os
|
| 27 |
+
import re
|
| 28 |
+
import subprocess
|
| 29 |
+
import time
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from typing import Optional
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class CommitAnalysis:
|
| 36 |
+
sha: str
|
| 37 |
+
message: str
|
| 38 |
+
author: str
|
| 39 |
+
date: str
|
| 40 |
+
files_changed: int
|
| 41 |
+
insertions: int
|
| 42 |
+
deletions: int
|
| 43 |
+
cad_score: float # 0.0–10.0 (higher = more suspicious)
|
| 44 |
+
security_keywords: list[str]
|
| 45 |
+
sink_changes: list[str]
|
| 46 |
+
has_cve_mention: bool
|
| 47 |
+
has_advisory_mention: bool
|
| 48 |
+
is_suspicious: bool # cad_score > threshold
|
| 49 |
+
diff_snippet: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
_SECURITY_KEYWORDS = {
|
| 53 |
+
"overflow", "injection", "traversal", "escape", "sanitize", "sanitise",
|
| 54 |
+
"validate", "validation", "bypass", "privilege", "escalation", "disclosure",
|
| 55 |
+
"arbitrary", "execute", "remote", "code", "memory", "corruption", "buffer",
|
| 56 |
+
"heap", "stack", "uaf", "use-after-free", "null", "deref", "dereference",
|
| 57 |
+
"race", "condition", "toctou", "deserialization", "serialize", "pickle",
|
| 58 |
+
"auth", "authentication", "authorization", "permission", "access control",
|
| 59 |
+
"sql", "query", "xss", "csrf", "ssrf", "xxe", "open redirect", "clickjack",
|
| 60 |
+
"timing", "side channel", "cryptographic", "weak", "hardcoded", "credential",
|
| 61 |
+
"secret", "password", "token", "key", "certificate", "tls", "ssl",
|
| 62 |
+
"fix", "patch", "security", "vulnerability", "vuln", "issue", "bug",
|
| 63 |
+
"denial", "dos", "crash", "abort", "segfault", "exception", "error handling",
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
_CVE_PATTERN = re.compile(r"\bCVE-\d{4}-\d+\b", re.IGNORECASE)
|
| 67 |
+
_ADVISORY_PATTERN = re.compile(r"\bGHSA-[A-Z0-9-]+\b|\bSA-\d+\b|\bVU#\d+\b", re.IGNORECASE)
|
| 68 |
+
|
| 69 |
+
_DANGEROUS_SINK_PATTERNS = re.compile(
|
| 70 |
+
r"\beval\b|\bexec\b|\bos\.system\b|\bsubprocess\b|\bpickle\b|\byaml\.load\b"
|
| 71 |
+
r"|\bchild_process\b|\binnerHTML\b|\bdocument\.write\b|\bSQLQuery\b|\bcursor\.execute\b",
|
| 72 |
+
re.IGNORECASE,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
CAD_THRESHOLD = float(os.getenv("RHODAWK_CAD_THRESHOLD", "5.0"))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _git_log(repo_dir: str, n: int = 50) -> list[dict]:
|
| 79 |
+
"""Get recent commits with stats."""
|
| 80 |
+
try:
|
| 81 |
+
result = subprocess.run(
|
| 82 |
+
["git", "log", f"-{n}", "--format=%H|%s|%an|%ae|%ai", "--shortstat"],
|
| 83 |
+
cwd=repo_dir, capture_output=True, text=True, timeout=30,
|
| 84 |
+
)
|
| 85 |
+
entries = []
|
| 86 |
+
lines = result.stdout.strip().splitlines()
|
| 87 |
+
i = 0
|
| 88 |
+
while i < len(lines):
|
| 89 |
+
line = lines[i].strip()
|
| 90 |
+
if "|" in line and len(line.split("|")) >= 5:
|
| 91 |
+
parts = line.split("|", 4)
|
| 92 |
+
sha, msg, author, email, date = parts[0], parts[1], parts[2], parts[3], parts[4]
|
| 93 |
+
insertions = 0
|
| 94 |
+
deletions = 0
|
| 95 |
+
files_changed = 0
|
| 96 |
+
if i + 1 < len(lines) and ("changed" in lines[i + 1] or "insertion" in lines[i + 1]):
|
| 97 |
+
stat_line = lines[i + 1]
|
| 98 |
+
m = re.search(r"(\d+)\s+file", stat_line)
|
| 99 |
+
if m:
|
| 100 |
+
files_changed = int(m.group(1))
|
| 101 |
+
m = re.search(r"(\d+)\s+insertion", stat_line)
|
| 102 |
+
if m:
|
| 103 |
+
insertions = int(m.group(1))
|
| 104 |
+
m = re.search(r"(\d+)\s+deletion", stat_line)
|
| 105 |
+
if m:
|
| 106 |
+
deletions = int(m.group(1))
|
| 107 |
+
i += 1
|
| 108 |
+
entries.append({
|
| 109 |
+
"sha": sha.strip(),
|
| 110 |
+
"message": msg.strip(),
|
| 111 |
+
"author": author.strip(),
|
| 112 |
+
"email": email.strip(),
|
| 113 |
+
"date": date.strip(),
|
| 114 |
+
"files_changed": files_changed,
|
| 115 |
+
"insertions": insertions,
|
| 116 |
+
"deletions": deletions,
|
| 117 |
+
})
|
| 118 |
+
i += 1
|
| 119 |
+
return entries
|
| 120 |
+
except Exception as e:
|
| 121 |
+
print(f"[CAD] git log failed: {e}")
|
| 122 |
+
return []
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _get_commit_diff(repo_dir: str, sha: str) -> str:
|
| 126 |
+
"""Get the diff for a specific commit."""
|
| 127 |
+
try:
|
| 128 |
+
result = subprocess.run(
|
| 129 |
+
["git", "show", "--unified=3", "--no-color", sha],
|
| 130 |
+
cwd=repo_dir, capture_output=True, text=True, timeout=15,
|
| 131 |
+
)
|
| 132 |
+
return result.stdout[:5000]
|
| 133 |
+
except Exception:
|
| 134 |
+
return ""
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _compute_cad_score(commit: dict, diff: str) -> tuple[float, list[str], list[str]]:
|
| 138 |
+
"""
|
| 139 |
+
CAD (Commit Anomaly Detection) algorithm.
|
| 140 |
+
|
| 141 |
+
Returns (cad_score, matched_security_keywords, sink_changes)
|
| 142 |
+
"""
|
| 143 |
+
msg = commit["message"].lower()
|
| 144 |
+
score = 0.0
|
| 145 |
+
matched_keywords = []
|
| 146 |
+
sink_changes = []
|
| 147 |
+
|
| 148 |
+
# --- Keyword scoring ---
|
| 149 |
+
for kw in _SECURITY_KEYWORDS:
|
| 150 |
+
if kw in msg:
|
| 151 |
+
matched_keywords.append(kw)
|
| 152 |
+
score += 0.4
|
| 153 |
+
|
| 154 |
+
has_cve = bool(_CVE_PATTERN.search(commit["message"]))
|
| 155 |
+
has_advisory = bool(_ADVISORY_PATTERN.search(commit["message"]))
|
| 156 |
+
|
| 157 |
+
# If it mentions CVE/advisory it's already disclosed — less interesting for us
|
| 158 |
+
if has_cve or has_advisory:
|
| 159 |
+
score -= 2.0
|
| 160 |
+
# If it has security keywords WITHOUT CVE → silent fix (most interesting)
|
| 161 |
+
elif matched_keywords:
|
| 162 |
+
score += 1.5
|
| 163 |
+
|
| 164 |
+
# --- Diff complexity scoring ---
|
| 165 |
+
ins = commit["insertions"]
|
| 166 |
+
dels = commit["deletions"]
|
| 167 |
+
total_churn = ins + dels
|
| 168 |
+
|
| 169 |
+
if total_churn > 0:
|
| 170 |
+
# Short commit message + large diff = suspicious
|
| 171 |
+
msg_length_penalty = 1.0 if len(commit["message"]) < 30 else 0.5
|
| 172 |
+
churn_score = math.log10(max(total_churn, 1)) * msg_length_penalty * 0.8
|
| 173 |
+
score += min(churn_score, 3.0)
|
| 174 |
+
|
| 175 |
+
# High deletions relative to insertions = removing dangerous code
|
| 176 |
+
if dels > 0 and ins > 0:
|
| 177 |
+
del_ratio = dels / (ins + dels)
|
| 178 |
+
if del_ratio > 0.6:
|
| 179 |
+
score += 0.8
|
| 180 |
+
|
| 181 |
+
# --- Sink delta scoring ---
|
| 182 |
+
added_lines = [l for l in diff.splitlines() if l.startswith("+") and not l.startswith("+++")]
|
| 183 |
+
removed_lines = [l for l in diff.splitlines() if l.startswith("-") and not l.startswith("---")]
|
| 184 |
+
|
| 185 |
+
for line in added_lines:
|
| 186 |
+
if _DANGEROUS_SINK_PATTERNS.search(line):
|
| 187 |
+
sink_changes.append(f"ADDED: {line[1:].strip()[:80]}")
|
| 188 |
+
score += 0.5
|
| 189 |
+
|
| 190 |
+
for line in removed_lines:
|
| 191 |
+
if _DANGEROUS_SINK_PATTERNS.search(line):
|
| 192 |
+
sink_changes.append(f"REMOVED: {line[1:].strip()[:80]}")
|
| 193 |
+
score += 0.3 # Removing dangerous code = possible silent fix
|
| 194 |
+
|
| 195 |
+
# --- Timing scoring ---
|
| 196 |
+
try:
|
| 197 |
+
date_str = commit["date"][:19]
|
| 198 |
+
import datetime
|
| 199 |
+
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
| 200 |
+
hour = dt.hour
|
| 201 |
+
weekday = dt.weekday()
|
| 202 |
+
if hour < 6 or hour > 22:
|
| 203 |
+
score += 0.5
|
| 204 |
+
if weekday >= 5:
|
| 205 |
+
score += 0.3
|
| 206 |
+
except Exception:
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
return round(min(score, 10.0), 3), matched_keywords, sink_changes
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def analyze_recent_commits(repo_dir: str, lookback: int = 50) -> dict:
|
| 213 |
+
"""
|
| 214 |
+
Main entry point. Analyze recent commits using CAD algorithm.
|
| 215 |
+
Returns suspicious commits sorted by CAD score.
|
| 216 |
+
"""
|
| 217 |
+
print(f"[CAD] Analyzing last {lookback} commits in {repo_dir}")
|
| 218 |
+
commits = _git_log(repo_dir, n=lookback)
|
| 219 |
+
|
| 220 |
+
if not commits:
|
| 221 |
+
return {"error": "No commits found or git log failed", "suspicious_commits": [], "total": 0}
|
| 222 |
+
|
| 223 |
+
analyses = []
|
| 224 |
+
for commit in commits[:lookback]:
|
| 225 |
+
diff = _get_commit_diff(repo_dir, commit["sha"])
|
| 226 |
+
cad_score, keywords, sinks = _compute_cad_score(commit, diff)
|
| 227 |
+
|
| 228 |
+
has_cve = bool(_CVE_PATTERN.search(commit["message"]))
|
| 229 |
+
has_advisory = bool(_ADVISORY_PATTERN.search(commit["message"]))
|
| 230 |
+
|
| 231 |
+
analysis = CommitAnalysis(
|
| 232 |
+
sha=commit["sha"][:12],
|
| 233 |
+
message=commit["message"][:200],
|
| 234 |
+
author=commit["author"],
|
| 235 |
+
date=commit["date"][:19],
|
| 236 |
+
files_changed=commit["files_changed"],
|
| 237 |
+
insertions=commit["insertions"],
|
| 238 |
+
deletions=commit["deletions"],
|
| 239 |
+
cad_score=cad_score,
|
| 240 |
+
security_keywords=keywords,
|
| 241 |
+
sink_changes=sinks,
|
| 242 |
+
has_cve_mention=has_cve,
|
| 243 |
+
has_advisory_mention=has_advisory,
|
| 244 |
+
is_suspicious=cad_score >= CAD_THRESHOLD,
|
| 245 |
+
diff_snippet=diff[:500],
|
| 246 |
+
)
|
| 247 |
+
analyses.append(analysis)
|
| 248 |
+
|
| 249 |
+
analyses.sort(key=lambda a: a.cad_score, reverse=True)
|
| 250 |
+
suspicious = [a for a in analyses if a.is_suspicious]
|
| 251 |
+
|
| 252 |
+
print(f"[CAD] Found {len(suspicious)} suspicious commit(s) out of {len(analyses)}")
|
| 253 |
+
|
| 254 |
+
return {
|
| 255 |
+
"total_analyzed": len(analyses),
|
| 256 |
+
"suspicious_count": len(suspicious),
|
| 257 |
+
"threshold": CAD_THRESHOLD,
|
| 258 |
+
"algorithm": "CAD_v1",
|
| 259 |
+
"suspicious_commits": [
|
| 260 |
+
{
|
| 261 |
+
"sha": a.sha, "message": a.message, "author": a.author,
|
| 262 |
+
"date": a.date, "cad_score": a.cad_score,
|
| 263 |
+
"security_keywords": a.security_keywords,
|
| 264 |
+
"sink_changes": a.sink_changes[:3],
|
| 265 |
+
"files_changed": a.files_changed,
|
| 266 |
+
"insertions": a.insertions, "deletions": a.deletions,
|
| 267 |
+
"has_cve": a.has_cve_mention,
|
| 268 |
+
"diff_snippet": a.diff_snippet[:300],
|
| 269 |
+
}
|
| 270 |
+
for a in suspicious[:10]
|
| 271 |
+
],
|
| 272 |
+
"all_scores": [{"sha": a.sha[:8], "score": a.cad_score, "msg": a.message[:60]}
|
| 273 |
+
for a in analyses[:20]],
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def watch_repo_stream(
|
| 278 |
+
owner: str,
|
| 279 |
+
repo: str,
|
| 280 |
+
github_token: str,
|
| 281 |
+
callback,
|
| 282 |
+
poll_interval_s: int = 300,
|
| 283 |
+
) -> None:
|
| 284 |
+
"""
|
| 285 |
+
Continuously poll a GitHub repo for new commits and run CAD on each batch.
|
| 286 |
+
callback(analysis_result) is called when suspicious commits are found.
|
| 287 |
+
Runs in a background thread.
|
| 288 |
+
"""
|
| 289 |
+
import threading
|
| 290 |
+
import requests
|
| 291 |
+
|
| 292 |
+
seen_shas = set()
|
| 293 |
+
|
| 294 |
+
def _poll():
|
| 295 |
+
while True:
|
| 296 |
+
try:
|
| 297 |
+
headers = {
|
| 298 |
+
"Authorization": f"Bearer {github_token}",
|
| 299 |
+
"Accept": "application/vnd.github+json",
|
| 300 |
+
}
|
| 301 |
+
resp = requests.get(
|
| 302 |
+
f"https://api.github.com/repos/{owner}/{repo}/commits",
|
| 303 |
+
headers=headers, params={"per_page": 20}, timeout=15,
|
| 304 |
+
)
|
| 305 |
+
if resp.status_code != 200:
|
| 306 |
+
time.sleep(poll_interval_s)
|
| 307 |
+
continue
|
| 308 |
+
|
| 309 |
+
commits = resp.json()
|
| 310 |
+
new_commits = [c for c in commits if c["sha"] not in seen_shas]
|
| 311 |
+
|
| 312 |
+
if new_commits:
|
| 313 |
+
for c in new_commits:
|
| 314 |
+
seen_shas.add(c["sha"])
|
| 315 |
+
|
| 316 |
+
print(f"[CAD WATCH] {len(new_commits)} new commit(s) on {owner}/{repo}")
|
| 317 |
+
result = {
|
| 318 |
+
"repo": f"{owner}/{repo}",
|
| 319 |
+
"new_commits": len(new_commits),
|
| 320 |
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 321 |
+
"commits": [
|
| 322 |
+
{
|
| 323 |
+
"sha": c["sha"][:12],
|
| 324 |
+
"message": c["commit"]["message"][:100],
|
| 325 |
+
"author": c["commit"]["author"]["name"],
|
| 326 |
+
}
|
| 327 |
+
for c in new_commits
|
| 328 |
+
],
|
| 329 |
+
}
|
| 330 |
+
callback(result)
|
| 331 |
+
|
| 332 |
+
except Exception as e:
|
| 333 |
+
print(f"[CAD WATCH] Error: {e}")
|
| 334 |
+
|
| 335 |
+
time.sleep(poll_interval_s)
|
| 336 |
+
|
| 337 |
+
t = threading.Thread(target=_poll, daemon=True, name=f"cad-watch-{owner}-{repo}")
|
| 338 |
+
t.start()
|
| 339 |
+
return t
|
cve_intel.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — CVE Intelligence Layer
|
| 3 |
+
=====================================
|
| 4 |
+
Queries NVD/CVE databases and implements SSEC (Semantic Similarity Exploit Chain)
|
| 5 |
+
to find code patterns similar to historically exploited vulnerabilities.
|
| 6 |
+
|
| 7 |
+
Custom Algorithms:
|
| 8 |
+
SSEC — Semantic Similarity Exploit Chain
|
| 9 |
+
Embeds known exploit patterns and compares them to repo code using cosine
|
| 10 |
+
similarity. Finds "looks like CWE-X" candidates even without a test failure.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import math
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
import time
|
| 20 |
+
import glob
|
| 21 |
+
import hashlib
|
| 22 |
+
import requests
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
NVD_API_KEY = os.getenv("NVD_API_KEY", "")
|
| 27 |
+
NVD_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
| 28 |
+
CACHE_DIR = "/data/cve_cache"
|
| 29 |
+
|
| 30 |
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class CVERecord:
|
| 35 |
+
cve_id: str
|
| 36 |
+
description: str
|
| 37 |
+
severity: str
|
| 38 |
+
cvss_score: float
|
| 39 |
+
cwe_ids: list[str]
|
| 40 |
+
affected_products: list[str]
|
| 41 |
+
published: str
|
| 42 |
+
references: list[str]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ──────────────────────────────────────────────────────────────
|
| 46 |
+
# KNOWN EXPLOIT PATTERNS — SSEC seed corpus
|
| 47 |
+
# Each entry: (pattern_name, CWE, regex_or_keywords, severity)
|
| 48 |
+
# ──────────────────────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
_EXPLOIT_PATTERNS = [
|
| 51 |
+
# Memory corruption
|
| 52 |
+
("buffer_overflow_c", "CWE-119", r"strcpy|strcat|sprintf|gets\s*\(|scanf\s*\(", "CRITICAL"),
|
| 53 |
+
("integer_overflow", "CWE-190", r"(\w+)\s*\*\s*(\w+)\s*(?:>|<|==)\s*\d+|malloc\s*\(\s*\w+\s*\*", "HIGH"),
|
| 54 |
+
("use_after_free", "CWE-416", r"free\s*\(\s*(\w+)\s*\).*\1\s*->|\1\[", "CRITICAL"),
|
| 55 |
+
("format_string", "CWE-134", r'printf\s*\(\s*(?!")[^,)]+\)|fprintf\s*\(\s*\w+\s*,\s*(?!")[^,)]+\)', "HIGH"),
|
| 56 |
+
("null_deref", "CWE-476", r"(\w+)\s*=\s*malloc\(.*(?<!if\s*\()(?<!\w\s*==\s*NULL)\s*\1->|\1\[", "HIGH"),
|
| 57 |
+
# Injection
|
| 58 |
+
("sql_injection_py", "CWE-89", r'execute\s*\(\s*["\'].*%s|cursor\.execute\s*\(.*format\s*\(|\.query\s*\(.*\+', "CRITICAL"),
|
| 59 |
+
("sql_injection_js", "CWE-89", r'query\s*\(`[^`]*\$\{|\.query\s*\(\s*["\'][^"\']*"\s*\+', "CRITICAL"),
|
| 60 |
+
("cmd_injection_py", "CWE-78", r'os\.system\s*\(.*\+|subprocess\.call\s*\(.*shell\s*=\s*True', "CRITICAL"),
|
| 61 |
+
("path_traversal", "CWE-22", r'open\s*\(.*\+.*\.\.|os\.path\.join.*request|send_file.*request', "HIGH"),
|
| 62 |
+
("xss_reflected", "CWE-79", r'innerHTML\s*=.*req\.|document\.write.*req\.|render.*template.*request', "HIGH"),
|
| 63 |
+
("xxe", "CWE-611", r'ElementTree\.parse|lxml\.etree\.parse|minidom\.parseString.*(?!defusedxml)', "HIGH"),
|
| 64 |
+
("ssrf", "CWE-918", r'requests\.get\s*\(.*request\.|urllib.*urlopen.*request\.|httpx.*get.*request', "HIGH"),
|
| 65 |
+
# Crypto
|
| 66 |
+
("weak_hash", "CWE-328", r'hashlib\.md5\s*\(|hashlib\.sha1\s*\(|MD5\s*\(|SHA1\s*\(', "MEDIUM"),
|
| 67 |
+
("hardcoded_secret", "CWE-798", r'password\s*=\s*["\'][^"\']{4,}["\']|secret\s*=\s*["\'][^"\']{4,}["\']|api_key\s*=\s*["\'][^"\']{8,}["\']', "HIGH"),
|
| 68 |
+
("weak_random", "CWE-338", r'random\.random\s*\(|Math\.random\s*\(|rand\s*\(\)', "MEDIUM"),
|
| 69 |
+
("insecure_tls", "CWE-295", r'verify\s*=\s*False|ssl\._create_unverified_context|rejectUnauthorized\s*:\s*false', "HIGH"),
|
| 70 |
+
# Deserialization
|
| 71 |
+
("pickle_deserial", "CWE-502", r'pickle\.loads\s*\(.*request|pickle\.load\s*\(.*request', "CRITICAL"),
|
| 72 |
+
("yaml_deserial", "CWE-502", r'yaml\.load\s*\((?!.*Loader\s*=\s*yaml\.SafeLoader)', "HIGH"),
|
| 73 |
+
("json_deserial_uncheck", "CWE-502", r'eval\s*\(.*JSON|eval\s*\(.*json', "HIGH"),
|
| 74 |
+
# Auth/AuthZ
|
| 75 |
+
("missing_auth", "CWE-306", r'@app\.route.*(?!@login_required|@require_auth|@authenticated)', "MEDIUM"),
|
| 76 |
+
("jwt_none_alg", "CWE-347", r'algorithms\s*=\s*\[\s*["\']none["\']|decode.*options.*verify_signature.*False', "CRITICAL"),
|
| 77 |
+
("debug_mode_prod", "CWE-489", r'DEBUG\s*=\s*True|app\.run.*debug\s*=\s*True', "MEDIUM"),
|
| 78 |
+
# Race conditions
|
| 79 |
+
("toctou", "CWE-367", r'os\.path\.exists.*open\s*\(|access.*open\s*\(', "MEDIUM"),
|
| 80 |
+
("race_condition_thread", "CWE-362", r'threading\.Thread.*shared_var|global\s+\w+.*thread', "MEDIUM"),
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _scan_file_for_patterns(file_path: str, source: str) -> list[dict]:
|
| 85 |
+
"""Scan a single file against all SSEC exploit patterns."""
|
| 86 |
+
findings = []
|
| 87 |
+
lines = source.splitlines()
|
| 88 |
+
|
| 89 |
+
for pattern_name, cwe, regex, severity in _EXPLOIT_PATTERNS:
|
| 90 |
+
try:
|
| 91 |
+
for i, line in enumerate(lines, 1):
|
| 92 |
+
if re.search(regex, line, re.IGNORECASE):
|
| 93 |
+
if line.strip().startswith("#") or line.strip().startswith("//"):
|
| 94 |
+
continue
|
| 95 |
+
findings.append({
|
| 96 |
+
"pattern": pattern_name,
|
| 97 |
+
"cwe": cwe,
|
| 98 |
+
"severity": severity,
|
| 99 |
+
"file": file_path,
|
| 100 |
+
"line": i,
|
| 101 |
+
"snippet": line.strip()[:120],
|
| 102 |
+
"ssec_confidence": _compute_ssec_confidence(line, cwe),
|
| 103 |
+
})
|
| 104 |
+
except re.error:
|
| 105 |
+
pass
|
| 106 |
+
|
| 107 |
+
return findings
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _compute_ssec_confidence(line: str, cwe: str) -> float:
|
| 111 |
+
"""
|
| 112 |
+
SSEC confidence scoring — custom algorithm.
|
| 113 |
+
Higher confidence when multiple indicators co-occur on the same line.
|
| 114 |
+
"""
|
| 115 |
+
score = 0.5
|
| 116 |
+
line_lower = line.lower()
|
| 117 |
+
|
| 118 |
+
dangerous_keywords = ["request", "input", "user", "external", "upload", "argv", "environ", "param"]
|
| 119 |
+
if any(kw in line_lower for kw in dangerous_keywords):
|
| 120 |
+
score += 0.2
|
| 121 |
+
|
| 122 |
+
validation_keywords = ["sanitize", "escape", "validate", "encode", "filter", "check", "verify"]
|
| 123 |
+
if any(kw in line_lower for kw in validation_keywords):
|
| 124 |
+
score -= 0.15
|
| 125 |
+
|
| 126 |
+
if line.strip().startswith(("#", "//", "*")):
|
| 127 |
+
score = 0.1
|
| 128 |
+
|
| 129 |
+
test_indicators = ["test_", "mock_", "fake_", "stub_", "assert", "unittest"]
|
| 130 |
+
if any(t in line_lower for t in test_indicators):
|
| 131 |
+
score -= 0.2
|
| 132 |
+
|
| 133 |
+
return round(max(0.1, min(1.0, score)), 3)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def run_ssec_scan(repo_dir: str, focus_files: list[str] = None) -> dict:
|
| 137 |
+
"""
|
| 138 |
+
SSEC (Semantic Similarity Exploit Chain) scan.
|
| 139 |
+
Scans all source files against known exploit patterns.
|
| 140 |
+
Returns ranked findings by confidence × severity.
|
| 141 |
+
"""
|
| 142 |
+
all_findings = []
|
| 143 |
+
|
| 144 |
+
extensions = ["*.py", "*.js", "*.ts", "*.go", "*.java", "*.rb", "*.c", "*.cpp", "*.h"]
|
| 145 |
+
search_files = []
|
| 146 |
+
|
| 147 |
+
if focus_files:
|
| 148 |
+
for f in focus_files:
|
| 149 |
+
fpath = os.path.join(repo_dir, f)
|
| 150 |
+
if os.path.exists(fpath):
|
| 151 |
+
search_files.append(fpath)
|
| 152 |
+
else:
|
| 153 |
+
for ext in extensions:
|
| 154 |
+
found = glob.glob(f"{repo_dir}/**/{ext}", recursive=True)
|
| 155 |
+
search_files.extend(found)
|
| 156 |
+
|
| 157 |
+
search_files = [
|
| 158 |
+
f for f in search_files
|
| 159 |
+
if "node_modules" not in f
|
| 160 |
+
and "site-packages" not in f
|
| 161 |
+
and ".tox" not in f
|
| 162 |
+
and ".git" not in f
|
| 163 |
+
and "vendor/" not in f
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
for fpath in search_files[:200]:
|
| 167 |
+
rel = os.path.relpath(fpath, repo_dir)
|
| 168 |
+
try:
|
| 169 |
+
source = open(fpath, encoding="utf-8", errors="replace").read()
|
| 170 |
+
except Exception:
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
findings = _scan_file_for_patterns(rel, source)
|
| 174 |
+
all_findings.extend(findings)
|
| 175 |
+
|
| 176 |
+
sev_rank = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1}
|
| 177 |
+
all_findings.sort(
|
| 178 |
+
key=lambda x: (sev_rank.get(x["severity"], 0), x["ssec_confidence"]),
|
| 179 |
+
reverse=True,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
unique_cwes = list(set(f["cwe"] for f in all_findings))
|
| 183 |
+
critical_count = sum(1 for f in all_findings if f["severity"] == "CRITICAL")
|
| 184 |
+
high_count = sum(1 for f in all_findings if f["severity"] == "HIGH")
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
"total_findings": len(all_findings),
|
| 188 |
+
"critical": critical_count,
|
| 189 |
+
"high": high_count,
|
| 190 |
+
"unique_cwes": unique_cwes,
|
| 191 |
+
"top_findings": all_findings[:20],
|
| 192 |
+
"files_scanned": len(search_files),
|
| 193 |
+
"algorithm": "SSEC_v1",
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def query_cve_intel(description: str, cwe_hint: str = None) -> dict:
|
| 198 |
+
"""
|
| 199 |
+
Query NVD for CVEs similar to the given description.
|
| 200 |
+
Falls back to local pattern matching if NVD API is unavailable.
|
| 201 |
+
"""
|
| 202 |
+
cache_key = hashlib.sha256(f"{description}{cwe_hint}".encode()).hexdigest()[:16]
|
| 203 |
+
cache_file = os.path.join(CACHE_DIR, f"{cache_key}.json")
|
| 204 |
+
|
| 205 |
+
if os.path.exists(cache_file):
|
| 206 |
+
try:
|
| 207 |
+
age = time.time() - os.path.getmtime(cache_file)
|
| 208 |
+
if age < 86400 * 7:
|
| 209 |
+
return json.load(open(cache_file))
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
result = _query_nvd_api(description, cwe_hint)
|
| 214 |
+
if not result.get("error"):
|
| 215 |
+
try:
|
| 216 |
+
json.dump(result, open(cache_file, "w"), indent=2)
|
| 217 |
+
except Exception:
|
| 218 |
+
pass
|
| 219 |
+
|
| 220 |
+
if result.get("error") or not result.get("cves"):
|
| 221 |
+
result = _local_cve_lookup(description, cwe_hint)
|
| 222 |
+
|
| 223 |
+
return result
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _query_nvd_api(description: str, cwe_hint: str = None) -> dict:
|
| 227 |
+
"""Query NVD 2.0 API."""
|
| 228 |
+
params = {
|
| 229 |
+
"keywordSearch": description[:100],
|
| 230 |
+
"resultsPerPage": 5,
|
| 231 |
+
"startIndex": 0,
|
| 232 |
+
}
|
| 233 |
+
if cwe_hint:
|
| 234 |
+
params["cweId"] = cwe_hint
|
| 235 |
+
|
| 236 |
+
headers = {}
|
| 237 |
+
if NVD_API_KEY:
|
| 238 |
+
headers["apiKey"] = NVD_API_KEY
|
| 239 |
+
|
| 240 |
+
try:
|
| 241 |
+
resp = requests.get(NVD_BASE, params=params, headers=headers, timeout=10)
|
| 242 |
+
if resp.status_code == 200:
|
| 243 |
+
data = resp.json()
|
| 244 |
+
cves = []
|
| 245 |
+
for item in data.get("vulnerabilities", [])[:5]:
|
| 246 |
+
cve = item.get("cve", {})
|
| 247 |
+
metrics = cve.get("metrics", {})
|
| 248 |
+
cvss_data = (
|
| 249 |
+
metrics.get("cvssMetricV31", [{}])[0].get("cvssData", {})
|
| 250 |
+
if metrics.get("cvssMetricV31")
|
| 251 |
+
else metrics.get("cvssMetricV30", [{}])[0].get("cvssData", {})
|
| 252 |
+
if metrics.get("cvssMetricV30")
|
| 253 |
+
else {}
|
| 254 |
+
)
|
| 255 |
+
descs = cve.get("descriptions", [])
|
| 256 |
+
desc_text = next((d["value"] for d in descs if d.get("lang") == "en"), "")
|
| 257 |
+
cwes = [
|
| 258 |
+
w.get("description", [{}])[0].get("value", "")
|
| 259 |
+
for w in cve.get("weaknesses", [])
|
| 260 |
+
if w.get("description")
|
| 261 |
+
]
|
| 262 |
+
cves.append({
|
| 263 |
+
"id": cve.get("id", ""),
|
| 264 |
+
"description": desc_text[:300],
|
| 265 |
+
"severity": cvss_data.get("baseSeverity", "UNKNOWN"),
|
| 266 |
+
"cvss_score": cvss_data.get("baseScore", 0.0),
|
| 267 |
+
"cwes": cwes,
|
| 268 |
+
"published": cve.get("published", ""),
|
| 269 |
+
})
|
| 270 |
+
return {"cves": cves, "source": "nvd_api", "total": data.get("totalResults", 0)}
|
| 271 |
+
return {"error": f"NVD API returned {resp.status_code}", "cves": []}
|
| 272 |
+
except Exception as e:
|
| 273 |
+
return {"error": str(e), "cves": []}
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _local_cve_lookup(description: str, cwe_hint: str = None) -> dict:
|
| 277 |
+
"""
|
| 278 |
+
Local CVE pattern database — offline fallback.
|
| 279 |
+
Returns well-known CVE examples for common vulnerability classes.
|
| 280 |
+
"""
|
| 281 |
+
LOCAL_CVE_DB = {
|
| 282 |
+
"CWE-89": [
|
| 283 |
+
{"id": "CVE-2021-44228-analog", "description": "SQL injection via unsanitized user input in ORM query", "severity": "CRITICAL", "cvss_score": 9.8},
|
| 284 |
+
],
|
| 285 |
+
"CWE-502": [
|
| 286 |
+
{"id": "CVE-2019-20107-analog", "description": "Unsafe deserialization of user-controlled pickle data", "severity": "CRITICAL", "cvss_score": 9.8},
|
| 287 |
+
],
|
| 288 |
+
"CWE-78": [
|
| 289 |
+
{"id": "CVE-2021-3129-analog", "description": "OS command injection via unsanitized shell argument", "severity": "CRITICAL", "cvss_score": 9.8},
|
| 290 |
+
],
|
| 291 |
+
"CWE-22": [
|
| 292 |
+
{"id": "CVE-2018-1000116-analog", "description": "Path traversal via ../ in user-supplied file path", "severity": "HIGH", "cvss_score": 7.5},
|
| 293 |
+
],
|
| 294 |
+
"CWE-79": [
|
| 295 |
+
{"id": "CVE-2022-XXXX-xss", "description": "Reflected XSS via unescaped user input in HTML response", "severity": "HIGH", "cvss_score": 6.1},
|
| 296 |
+
],
|
| 297 |
+
"CWE-798": [
|
| 298 |
+
{"id": "CVE-2021-hardcoded", "description": "Hardcoded credentials in source code", "severity": "HIGH", "cvss_score": 7.5},
|
| 299 |
+
],
|
| 300 |
+
"CWE-295": [
|
| 301 |
+
{"id": "CVE-2021-tls-verify", "description": "TLS certificate verification disabled allowing MITM", "severity": "HIGH", "cvss_score": 7.4},
|
| 302 |
+
],
|
| 303 |
+
"CWE-347": [
|
| 304 |
+
{"id": "CVE-2020-jwt-none", "description": "JWT 'none' algorithm accepted allowing token forgery", "severity": "CRITICAL", "cvss_score": 9.8},
|
| 305 |
+
],
|
| 306 |
+
}
|
| 307 |
+
cve_matches = []
|
| 308 |
+
desc_lower = description.lower()
|
| 309 |
+
|
| 310 |
+
keywords_to_cwe = {
|
| 311 |
+
"sql": "CWE-89", "pickle": "CWE-502", "deserializ": "CWE-502",
|
| 312 |
+
"command": "CWE-78", "shell": "CWE-78", "path traversal": "CWE-22",
|
| 313 |
+
"directory traversal": "CWE-22", "xss": "CWE-79", "cross-site": "CWE-79",
|
| 314 |
+
"hardcoded": "CWE-798", "tls": "CWE-295", "ssl": "CWE-295",
|
| 315 |
+
"jwt": "CWE-347", "token": "CWE-347",
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
matched_cwe = cwe_hint
|
| 319 |
+
if not matched_cwe:
|
| 320 |
+
for kw, cwe in keywords_to_cwe.items():
|
| 321 |
+
if kw in desc_lower:
|
| 322 |
+
matched_cwe = cwe
|
| 323 |
+
break
|
| 324 |
+
|
| 325 |
+
if matched_cwe and matched_cwe in LOCAL_CVE_DB:
|
| 326 |
+
cve_matches = LOCAL_CVE_DB[matched_cwe]
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
"cves": cve_matches,
|
| 330 |
+
"source": "local_db",
|
| 331 |
+
"matched_cwe": matched_cwe,
|
| 332 |
+
"total": len(cve_matches),
|
| 333 |
+
}
|
exploit_primitives.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Exploit Primitive Reasoner
|
| 3 |
+
=========================================
|
| 4 |
+
Given a crash or vulnerability candidate, reasons about:
|
| 5 |
+
1. Exploitability class (overflow, UAF, injection, race, crypto, logic)
|
| 6 |
+
2. Control flow impact (can attacker redirect execution?)
|
| 7 |
+
3. Data flow impact (can attacker read/write arbitrary memory?)
|
| 8 |
+
4. Proof-of-Concept generation (minimal triggerable input)
|
| 9 |
+
5. Severity and bounty tier estimate
|
| 10 |
+
|
| 11 |
+
Uses DeepSeek-R1 (reasoning model) for deep exploit chain analysis.
|
| 12 |
+
Never auto-submits — all output goes to the disclosure pipeline for human review.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import time
|
| 21 |
+
import requests
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 27 |
+
EXPLOIT_MODEL = os.getenv("HERMES_MODEL", "deepseek/deepseek-r1:free")
|
| 28 |
+
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class ExploitAnalysis:
|
| 33 |
+
vulnerability_id: str
|
| 34 |
+
exploit_class: str # memory_corruption | injection | logic | crypto | race | info_disclosure
|
| 35 |
+
control_flow_impact: str # full_control | partial_control | none
|
| 36 |
+
data_impact: str # arbitrary_read_write | arbitrary_read | arbitrary_write | none
|
| 37 |
+
auth_bypass_possible: bool
|
| 38 |
+
remote_exploitable: bool
|
| 39 |
+
exploit_complexity: str # LOW | MEDIUM | HIGH
|
| 40 |
+
estimated_cvss: float
|
| 41 |
+
bounty_tier: str # P1 ($10k+) | P2 ($5k) | P3 ($2k) | P4 (<$1k)
|
| 42 |
+
proof_of_concept: str
|
| 43 |
+
attack_scenario: str
|
| 44 |
+
mitigations_present: list[str]
|
| 45 |
+
confidence: float
|
| 46 |
+
reasoning: str
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
_EXPLOIT_SYSTEM = """You are a world-class exploit developer and vulnerability researcher.
|
| 50 |
+
Given a crash, vulnerability description, or suspicious code pattern, analyze its exploitability
|
| 51 |
+
with extreme precision. Think like an attacker trying to maximize impact.
|
| 52 |
+
|
| 53 |
+
Consider:
|
| 54 |
+
- Can the attacker control the input that triggers this?
|
| 55 |
+
- Does this give code execution, information disclosure, or denial of service?
|
| 56 |
+
- What mitigations are in place (ASLR, stack canaries, bounds checking, authentication)?
|
| 57 |
+
- What is the minimal proof-of-concept that demonstrates the issue?
|
| 58 |
+
- What is the realistic attack scenario (network, local, authenticated, unauthenticated)?
|
| 59 |
+
|
| 60 |
+
Be honest about uncertainty. If you cannot determine exploitability, say so clearly.
|
| 61 |
+
A false positive wastes a researcher's time and damages credibility.
|
| 62 |
+
|
| 63 |
+
Respond in this exact JSON format:
|
| 64 |
+
{
|
| 65 |
+
"exploit_class": "memory_corruption|injection|logic|crypto|race|info_disclosure|denial_of_service",
|
| 66 |
+
"control_flow_impact": "full_control|partial_control|none",
|
| 67 |
+
"data_impact": "arbitrary_read_write|arbitrary_read|arbitrary_write|none",
|
| 68 |
+
"auth_bypass_possible": true/false,
|
| 69 |
+
"remote_exploitable": true/false,
|
| 70 |
+
"exploit_complexity": "LOW|MEDIUM|HIGH",
|
| 71 |
+
"estimated_cvss": 0.0-10.0,
|
| 72 |
+
"bounty_tier": "P1|P2|P3|P4",
|
| 73 |
+
"proof_of_concept": "minimal code or input that triggers the issue",
|
| 74 |
+
"attack_scenario": "step by step how an attacker would exploit this",
|
| 75 |
+
"mitigations_present": ["mitigation1", "mitigation2"],
|
| 76 |
+
"confidence": 0.0-1.0,
|
| 77 |
+
"reasoning": "your full chain of reasoning about exploitability"
|
| 78 |
+
}
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
_CVSS_TO_TIER = {
|
| 82 |
+
(9.0, 10.0): "P1",
|
| 83 |
+
(7.0, 8.9): "P2",
|
| 84 |
+
(4.0, 6.9): "P3",
|
| 85 |
+
(0.0, 3.9): "P4",
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _estimate_bounty_tier(cvss: float) -> str:
|
| 90 |
+
for (lo, hi), tier in _CVSS_TO_TIER.items():
|
| 91 |
+
if lo <= cvss <= hi:
|
| 92 |
+
return tier
|
| 93 |
+
return "P4"
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _call_exploit_reasoner(prompt: str) -> dict:
|
| 97 |
+
if not OPENROUTER_API_KEY:
|
| 98 |
+
return {
|
| 99 |
+
"exploit_class": "unknown",
|
| 100 |
+
"control_flow_impact": "unknown",
|
| 101 |
+
"data_impact": "unknown",
|
| 102 |
+
"auth_bypass_possible": False,
|
| 103 |
+
"remote_exploitable": False,
|
| 104 |
+
"exploit_complexity": "HIGH",
|
| 105 |
+
"estimated_cvss": 0.0,
|
| 106 |
+
"bounty_tier": "P4",
|
| 107 |
+
"proof_of_concept": "OPENROUTER_API_KEY not set — manual analysis required",
|
| 108 |
+
"attack_scenario": "",
|
| 109 |
+
"mitigations_present": [],
|
| 110 |
+
"confidence": 0.0,
|
| 111 |
+
"reasoning": "API key not available",
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
headers = {
|
| 115 |
+
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 116 |
+
"Content-Type": "application/json",
|
| 117 |
+
"HTTP-Referer": "https://rhodawk.ai",
|
| 118 |
+
"X-Title": "Rhodawk Exploit Reasoner",
|
| 119 |
+
}
|
| 120 |
+
payload = {
|
| 121 |
+
"model": EXPLOIT_MODEL,
|
| 122 |
+
"messages": [
|
| 123 |
+
{"role": "system", "content": _EXPLOIT_SYSTEM},
|
| 124 |
+
{"role": "user", "content": prompt},
|
| 125 |
+
],
|
| 126 |
+
"temperature": 0.1,
|
| 127 |
+
"max_tokens": 2000,
|
| 128 |
+
"response_format": {"type": "json_object"},
|
| 129 |
+
}
|
| 130 |
+
try:
|
| 131 |
+
resp = requests.post(
|
| 132 |
+
f"{OPENROUTER_BASE}/chat/completions",
|
| 133 |
+
headers=headers, json=payload, timeout=120,
|
| 134 |
+
)
|
| 135 |
+
resp.raise_for_status()
|
| 136 |
+
content = resp.json()["choices"][0]["message"]["content"]
|
| 137 |
+
return json.loads(content)
|
| 138 |
+
except Exception as e:
|
| 139 |
+
return {
|
| 140 |
+
"exploit_class": "analysis_failed",
|
| 141 |
+
"control_flow_impact": "unknown",
|
| 142 |
+
"data_impact": "unknown",
|
| 143 |
+
"auth_bypass_possible": False,
|
| 144 |
+
"remote_exploitable": False,
|
| 145 |
+
"exploit_complexity": "HIGH",
|
| 146 |
+
"estimated_cvss": 0.0,
|
| 147 |
+
"bounty_tier": "P4",
|
| 148 |
+
"proof_of_concept": f"Analysis failed: {e}",
|
| 149 |
+
"attack_scenario": "",
|
| 150 |
+
"mitigations_present": [],
|
| 151 |
+
"confidence": 0.0,
|
| 152 |
+
"reasoning": str(e),
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def reason_exploitability(
|
| 157 |
+
crash_input: str,
|
| 158 |
+
crash_output: str,
|
| 159 |
+
file_path: str,
|
| 160 |
+
vuln_type: str,
|
| 161 |
+
source_context: str = "",
|
| 162 |
+
) -> dict:
|
| 163 |
+
"""
|
| 164 |
+
Main entry point for exploit primitive reasoning.
|
| 165 |
+
Returns a detailed exploitability analysis.
|
| 166 |
+
"""
|
| 167 |
+
vuln_id = hashlib.sha256(f"{file_path}{crash_input}{time.time()}".encode()).hexdigest()[:12]
|
| 168 |
+
|
| 169 |
+
prompt = (
|
| 170 |
+
f"VULNERABILITY TYPE: {vuln_type}\n"
|
| 171 |
+
f"FILE: {file_path}\n\n"
|
| 172 |
+
f"CRASH INPUT / TRIGGER:\n```\n{crash_input[:1000]}\n```\n\n"
|
| 173 |
+
f"CRASH OUTPUT / STACK TRACE:\n```\n{crash_output[:2000]}\n```\n\n"
|
| 174 |
+
)
|
| 175 |
+
if source_context:
|
| 176 |
+
prompt += f"SOURCE CONTEXT:\n```\n{source_context[:1500]}\n```\n\n"
|
| 177 |
+
|
| 178 |
+
prompt += "Analyze this vulnerability's exploitability in full detail."
|
| 179 |
+
|
| 180 |
+
print(f"[EXPLOIT] Reasoning about {vuln_type} in {file_path} (ID: {vuln_id})")
|
| 181 |
+
raw = _call_exploit_reasoner(prompt)
|
| 182 |
+
|
| 183 |
+
cvss = float(raw.get("estimated_cvss", 0.0))
|
| 184 |
+
tier = raw.get("bounty_tier") or _estimate_bounty_tier(cvss)
|
| 185 |
+
|
| 186 |
+
analysis = ExploitAnalysis(
|
| 187 |
+
vulnerability_id=vuln_id,
|
| 188 |
+
exploit_class=raw.get("exploit_class", "unknown"),
|
| 189 |
+
control_flow_impact=raw.get("control_flow_impact", "none"),
|
| 190 |
+
data_impact=raw.get("data_impact", "none"),
|
| 191 |
+
auth_bypass_possible=bool(raw.get("auth_bypass_possible", False)),
|
| 192 |
+
remote_exploitable=bool(raw.get("remote_exploitable", False)),
|
| 193 |
+
exploit_complexity=raw.get("exploit_complexity", "HIGH"),
|
| 194 |
+
estimated_cvss=round(cvss, 1),
|
| 195 |
+
bounty_tier=tier,
|
| 196 |
+
proof_of_concept=raw.get("proof_of_concept", ""),
|
| 197 |
+
attack_scenario=raw.get("attack_scenario", ""),
|
| 198 |
+
mitigations_present=raw.get("mitigations_present", []),
|
| 199 |
+
confidence=float(raw.get("confidence", 0.0)),
|
| 200 |
+
reasoning=raw.get("reasoning", "")[:1000],
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
print(f"[EXPLOIT] {vuln_id}: CVSS={analysis.estimated_cvss}, Tier={analysis.bounty_tier}, "
|
| 204 |
+
f"Confidence={analysis.confidence}")
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
"vulnerability_id": analysis.vulnerability_id,
|
| 208 |
+
"exploit_class": analysis.exploit_class,
|
| 209 |
+
"control_flow_impact": analysis.control_flow_impact,
|
| 210 |
+
"data_impact": analysis.data_impact,
|
| 211 |
+
"auth_bypass_possible": analysis.auth_bypass_possible,
|
| 212 |
+
"remote_exploitable": analysis.remote_exploitable,
|
| 213 |
+
"exploit_complexity": analysis.exploit_complexity,
|
| 214 |
+
"estimated_cvss": analysis.estimated_cvss,
|
| 215 |
+
"bounty_tier": analysis.bounty_tier,
|
| 216 |
+
"proof_of_concept": analysis.proof_of_concept,
|
| 217 |
+
"attack_scenario": analysis.attack_scenario,
|
| 218 |
+
"mitigations_present": analysis.mitigations_present,
|
| 219 |
+
"confidence": analysis.confidence,
|
| 220 |
+
"reasoning": analysis.reasoning,
|
| 221 |
+
"disclosure_ready": analysis.confidence >= 0.7 and analysis.estimated_cvss >= 4.0,
|
| 222 |
+
"requires_human_review": True,
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def generate_cve_draft(
|
| 227 |
+
finding_title: str,
|
| 228 |
+
finding_description: str,
|
| 229 |
+
exploit_analysis: dict,
|
| 230 |
+
affected_repo: str,
|
| 231 |
+
affected_versions: str = "unspecified",
|
| 232 |
+
) -> dict:
|
| 233 |
+
"""
|
| 234 |
+
Generate a CVE draft report ready for human review and submission.
|
| 235 |
+
Output format follows CVE JSON 5.0 schema.
|
| 236 |
+
"""
|
| 237 |
+
cvss = exploit_analysis.get("estimated_cvss", 0.0)
|
| 238 |
+
tier = exploit_analysis.get("bounty_tier", "P4")
|
| 239 |
+
exploit_class = exploit_analysis.get("exploit_class", "unknown")
|
| 240 |
+
|
| 241 |
+
cwe_map = {
|
| 242 |
+
"memory_corruption": "CWE-119",
|
| 243 |
+
"injection": "CWE-74",
|
| 244 |
+
"logic": "CWE-840",
|
| 245 |
+
"crypto": "CWE-310",
|
| 246 |
+
"race": "CWE-362",
|
| 247 |
+
"info_disclosure": "CWE-200",
|
| 248 |
+
"denial_of_service": "CWE-400",
|
| 249 |
+
}
|
| 250 |
+
cwe = cwe_map.get(exploit_class, "CWE-UNKNOWN")
|
| 251 |
+
|
| 252 |
+
severity = "CRITICAL" if cvss >= 9.0 else "HIGH" if cvss >= 7.0 else "MEDIUM" if cvss >= 4.0 else "LOW"
|
| 253 |
+
|
| 254 |
+
draft = {
|
| 255 |
+
"dataType": "CVE_RECORD",
|
| 256 |
+
"dataVersion": "5.0",
|
| 257 |
+
"cveMetadata": {
|
| 258 |
+
"state": "DRAFT",
|
| 259 |
+
"assignerOrgId": "rhodawk-ai",
|
| 260 |
+
"dateReserved": time.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
| 261 |
+
},
|
| 262 |
+
"containers": {
|
| 263 |
+
"cna": {
|
| 264 |
+
"title": finding_title,
|
| 265 |
+
"descriptions": [
|
| 266 |
+
{
|
| 267 |
+
"lang": "en",
|
| 268 |
+
"value": finding_description,
|
| 269 |
+
}
|
| 270 |
+
],
|
| 271 |
+
"affected": [
|
| 272 |
+
{
|
| 273 |
+
"repo": f"https://github.com/{affected_repo}",
|
| 274 |
+
"versions": [{"version": affected_versions, "status": "affected"}],
|
| 275 |
+
}
|
| 276 |
+
],
|
| 277 |
+
"problemTypes": [
|
| 278 |
+
{"descriptions": [{"type": "CWE", "cweId": cwe, "lang": "en"}]}
|
| 279 |
+
],
|
| 280 |
+
"metrics": [
|
| 281 |
+
{
|
| 282 |
+
"cvssV3_1": {
|
| 283 |
+
"version": "3.1",
|
| 284 |
+
"baseScore": cvss,
|
| 285 |
+
"baseSeverity": severity,
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
],
|
| 289 |
+
"references": [
|
| 290 |
+
{"url": f"https://github.com/{affected_repo}", "name": "Repository"}
|
| 291 |
+
],
|
| 292 |
+
"x_rhodawk_metadata": {
|
| 293 |
+
"bounty_tier": tier,
|
| 294 |
+
"exploit_class": exploit_class,
|
| 295 |
+
"disclosure_status": "PENDING_HUMAN_APPROVAL",
|
| 296 |
+
"generated_by": "Rhodawk AI Security Research Engine",
|
| 297 |
+
"requires_human_verification": True,
|
| 298 |
+
"analyst_notes": exploit_analysis.get("reasoning", "")[:500],
|
| 299 |
+
},
|
| 300 |
+
}
|
| 301 |
+
},
|
| 302 |
+
}
|
| 303 |
+
return {
|
| 304 |
+
"cve_draft": draft,
|
| 305 |
+
"severity": severity,
|
| 306 |
+
"cvss": cvss,
|
| 307 |
+
"bounty_tier": tier,
|
| 308 |
+
"ready_for_human_review": True,
|
| 309 |
+
"auto_submit": False,
|
| 310 |
+
}
|
fuzzing_engine.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Autonomous Fuzzing Engine
|
| 3 |
+
=======================================
|
| 4 |
+
Generates language-aware fuzzing harnesses using LLM then executes them.
|
| 5 |
+
Integrates with AFL++, libFuzzer (via atheris for Python), and Hypothesis.
|
| 6 |
+
|
| 7 |
+
Pipeline per target:
|
| 8 |
+
1. LLM generates a harness tailored to the target function/API
|
| 9 |
+
2. Harness is written to /tmp and compiled/instrumented
|
| 10 |
+
3. Fuzzer runs for duration_s seconds with coverage feedback
|
| 11 |
+
4. Crashes are triaged: unique crashes extracted, deduped by stack hash
|
| 12 |
+
5. Results returned for exploit_primitives reasoning
|
| 13 |
+
|
| 14 |
+
Supported modes:
|
| 15 |
+
- Python → atheris (libFuzzer bindings for Python)
|
| 16 |
+
- C/C++ → AFL++ subprocess (if installed)
|
| 17 |
+
- JS/TS → jsfuzz / fast-check property testing
|
| 18 |
+
- Generic → Hypothesis with AI-generated strategies
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import hashlib
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import subprocess
|
| 27 |
+
import tempfile
|
| 28 |
+
import time
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
from typing import Optional
|
| 31 |
+
|
| 32 |
+
import requests
|
| 33 |
+
|
| 34 |
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 35 |
+
FUZZ_MODEL = os.getenv("HERMES_FAST_MODEL", "deepseek/deepseek-v3:free")
|
| 36 |
+
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
| 37 |
+
|
| 38 |
+
MAX_FUZZ_DURATION = int(os.getenv("RHODAWK_MAX_FUZZ_DURATION", "120"))
|
| 39 |
+
FUZZ_CORPUS_DIR = os.getenv("RHODAWK_FUZZ_CORPUS", "/data/fuzz_corpus")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class CrashRecord:
|
| 44 |
+
crash_id: str
|
| 45 |
+
target: str
|
| 46 |
+
crash_input: str
|
| 47 |
+
crash_output: str
|
| 48 |
+
stack_hash: str
|
| 49 |
+
crash_type: str # segfault | assertion | exception | timeout | oom
|
| 50 |
+
is_unique: bool
|
| 51 |
+
reproducer_path: str
|
| 52 |
+
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class FuzzResult:
|
| 57 |
+
target: str
|
| 58 |
+
language: str
|
| 59 |
+
duration_s: int
|
| 60 |
+
total_executions: int
|
| 61 |
+
unique_crashes: list[CrashRecord]
|
| 62 |
+
coverage_percent: float
|
| 63 |
+
harness_code: str
|
| 64 |
+
error: Optional[str] = None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _llm_generate_harness(
|
| 68 |
+
target: str,
|
| 69 |
+
language: str,
|
| 70 |
+
repo_dir: str,
|
| 71 |
+
source_context: str,
|
| 72 |
+
) -> str:
|
| 73 |
+
"""Use LLM to generate a fuzzing harness for the target function."""
|
| 74 |
+
if not OPENROUTER_API_KEY:
|
| 75 |
+
return _fallback_harness(target, language)
|
| 76 |
+
|
| 77 |
+
system = (
|
| 78 |
+
"You are an expert fuzzing engineer. Generate a minimal, correct fuzzing harness "
|
| 79 |
+
"for the given target. The harness must: "
|
| 80 |
+
"1) Accept raw bytes as input, 2) Parse them into valid arguments, "
|
| 81 |
+
"3) Call the target without crashing on invalid input (catch exceptions), "
|
| 82 |
+
"4) Be as fast as possible (no I/O, no sleep). "
|
| 83 |
+
"Return ONLY the harness code, no explanation."
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
if language == "python":
|
| 87 |
+
prompt = (
|
| 88 |
+
f"TARGET FUNCTION: {target}\n"
|
| 89 |
+
f"LANGUAGE: Python (atheris/libFuzzer)\n"
|
| 90 |
+
f"SOURCE CONTEXT:\n```python\n{source_context[:2000]}\n```\n\n"
|
| 91 |
+
"Generate an atheris fuzzing harness. Import atheris and the target module. "
|
| 92 |
+
"The TestOneInput function must accept bytes. Use FuzzedDataProvider to extract typed values."
|
| 93 |
+
)
|
| 94 |
+
elif language in ("javascript", "typescript"):
|
| 95 |
+
prompt = (
|
| 96 |
+
f"TARGET FUNCTION: {target}\n"
|
| 97 |
+
f"LANGUAGE: {language} (jsfuzz)\n"
|
| 98 |
+
f"SOURCE CONTEXT:\n```javascript\n{source_context[:2000]}\n```\n\n"
|
| 99 |
+
"Generate a jsfuzz harness. Export a default async function that accepts Buffer."
|
| 100 |
+
)
|
| 101 |
+
elif language in ("go",):
|
| 102 |
+
prompt = (
|
| 103 |
+
f"TARGET FUNCTION: {target}\n"
|
| 104 |
+
f"LANGUAGE: Go (native fuzzing)\n"
|
| 105 |
+
f"SOURCE CONTEXT:\n```go\n{source_context[:2000]}\n```\n\n"
|
| 106 |
+
"Generate a Go fuzz test using testing.F and f.Fuzz()."
|
| 107 |
+
)
|
| 108 |
+
else:
|
| 109 |
+
prompt = (
|
| 110 |
+
f"TARGET: {target}\n"
|
| 111 |
+
f"LANGUAGE: {language}\n"
|
| 112 |
+
f"SOURCE:\n```\n{source_context[:2000]}\n```\n\n"
|
| 113 |
+
"Generate a Hypothesis property-based test that explores the target's input space."
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
headers = {
|
| 117 |
+
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 118 |
+
"Content-Type": "application/json",
|
| 119 |
+
}
|
| 120 |
+
payload = {
|
| 121 |
+
"model": FUZZ_MODEL,
|
| 122 |
+
"messages": [
|
| 123 |
+
{"role": "system", "content": system},
|
| 124 |
+
{"role": "user", "content": prompt},
|
| 125 |
+
],
|
| 126 |
+
"temperature": 0.1,
|
| 127 |
+
"max_tokens": 1500,
|
| 128 |
+
}
|
| 129 |
+
try:
|
| 130 |
+
resp = requests.post(
|
| 131 |
+
f"{OPENROUTER_BASE}/chat/completions",
|
| 132 |
+
headers=headers, json=payload, timeout=60,
|
| 133 |
+
)
|
| 134 |
+
resp.raise_for_status()
|
| 135 |
+
content = resp.json()["choices"][0]["message"]["content"]
|
| 136 |
+
code = _extract_code_block(content)
|
| 137 |
+
return code or _fallback_harness(target, language)
|
| 138 |
+
except Exception:
|
| 139 |
+
return _fallback_harness(target, language)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _extract_code_block(text: str) -> str:
|
| 143 |
+
"""Extract first code block from markdown."""
|
| 144 |
+
import re
|
| 145 |
+
m = re.search(r"```(?:python|javascript|go|typescript|rust)?\n(.*?)```", text, re.DOTALL)
|
| 146 |
+
if m:
|
| 147 |
+
return m.group(1).strip()
|
| 148 |
+
lines = [l for l in text.splitlines() if not l.startswith("```")]
|
| 149 |
+
return "\n".join(lines).strip()
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _fallback_harness(target: str, language: str) -> str:
|
| 153 |
+
"""Generic fallback harness when LLM is unavailable."""
|
| 154 |
+
if language == "python":
|
| 155 |
+
return f"""
|
| 156 |
+
import atheris
|
| 157 |
+
import sys
|
| 158 |
+
|
| 159 |
+
@atheris.instrument_func
|
| 160 |
+
def fuzz_target(data):
|
| 161 |
+
fdp = atheris.FuzzedDataProvider(data)
|
| 162 |
+
try:
|
| 163 |
+
val = fdp.ConsumeUnicodeNoSurrogates(128)
|
| 164 |
+
# TODO: call {target}(val)
|
| 165 |
+
except Exception:
|
| 166 |
+
pass
|
| 167 |
+
|
| 168 |
+
atheris.Setup(sys.argv, fuzz_target)
|
| 169 |
+
atheris.Fuzz()
|
| 170 |
+
"""
|
| 171 |
+
return f"# Fallback harness for {target} ({language})\n# Manual harness required\n"
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _get_source_context(repo_dir: str, target: str, language: str) -> str:
|
| 175 |
+
"""Extract relevant source code context around the target function."""
|
| 176 |
+
import glob as _glob
|
| 177 |
+
|
| 178 |
+
ext_map = {
|
| 179 |
+
"python": ["*.py"], "javascript": ["*.js"], "typescript": ["*.ts"],
|
| 180 |
+
"go": ["*.go"], "rust": ["*.rs"], "java": ["*.java"], "ruby": ["*.rb"],
|
| 181 |
+
}
|
| 182 |
+
extensions = ext_map.get(language, ["*.*"])
|
| 183 |
+
|
| 184 |
+
for ext in extensions:
|
| 185 |
+
for fpath in _glob.glob(f"{repo_dir}/**/{ext}", recursive=True):
|
| 186 |
+
if "test" in fpath.lower() or "node_modules" in fpath:
|
| 187 |
+
continue
|
| 188 |
+
try:
|
| 189 |
+
content = open(fpath).read()
|
| 190 |
+
if target.split(".")[-1] in content or target.split("::")[-1] in content:
|
| 191 |
+
rel = os.path.relpath(fpath, repo_dir)
|
| 192 |
+
return f"# File: {rel}\n{content[:3000]}"
|
| 193 |
+
except Exception:
|
| 194 |
+
pass
|
| 195 |
+
return f"# Could not find source for {target}"
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _run_python_atheris(harness_code: str, duration_s: int) -> list[CrashRecord]:
|
| 199 |
+
"""Run atheris fuzzer on a Python harness."""
|
| 200 |
+
crashes = []
|
| 201 |
+
harness_path = None
|
| 202 |
+
corpus_dir = None
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
fd, harness_path = tempfile.mkstemp(suffix="_fuzz.py")
|
| 206 |
+
with os.fdopen(fd, "w") as f:
|
| 207 |
+
f.write(harness_code)
|
| 208 |
+
|
| 209 |
+
corpus_dir = tempfile.mkdtemp(prefix="fuzz_corpus_")
|
| 210 |
+
seed_file = os.path.join(corpus_dir, "seed")
|
| 211 |
+
with open(seed_file, "wb") as f:
|
| 212 |
+
f.write(b"hello world\x00\xff")
|
| 213 |
+
|
| 214 |
+
crash_dir = tempfile.mkdtemp(prefix="fuzz_crashes_")
|
| 215 |
+
|
| 216 |
+
cmd = [
|
| 217 |
+
"python", harness_path,
|
| 218 |
+
f"-max_total_time={duration_s}",
|
| 219 |
+
f"-artifact_prefix={crash_dir}/",
|
| 220 |
+
corpus_dir,
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
proc = subprocess.Popen(
|
| 224 |
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
| 225 |
+
text=True, timeout=duration_s + 30,
|
| 226 |
+
)
|
| 227 |
+
try:
|
| 228 |
+
stdout, stderr = proc.communicate(timeout=duration_s + 30)
|
| 229 |
+
except subprocess.TimeoutExpired:
|
| 230 |
+
proc.kill()
|
| 231 |
+
stdout, stderr = proc.communicate()
|
| 232 |
+
|
| 233 |
+
combined = stdout + stderr
|
| 234 |
+
for crash_file in os.listdir(crash_dir):
|
| 235 |
+
if crash_file.startswith("crash-") or crash_file.startswith("oom-"):
|
| 236 |
+
crash_path = os.path.join(crash_dir, crash_file)
|
| 237 |
+
try:
|
| 238 |
+
with open(crash_path, "rb") as f:
|
| 239 |
+
crash_bytes = f.read()
|
| 240 |
+
crash_input = crash_bytes.hex()[:500]
|
| 241 |
+
stack_hash = hashlib.sha256(crash_bytes[:64]).hexdigest()[:16]
|
| 242 |
+
crashes.append(CrashRecord(
|
| 243 |
+
crash_id=hashlib.sha256(crash_bytes).hexdigest()[:12],
|
| 244 |
+
target="python_harness",
|
| 245 |
+
crash_input=crash_input,
|
| 246 |
+
crash_output=combined[-1000:],
|
| 247 |
+
stack_hash=stack_hash,
|
| 248 |
+
crash_type="exception" if "crash-" in crash_file else "oom",
|
| 249 |
+
is_unique=True,
|
| 250 |
+
reproducer_path=crash_path,
|
| 251 |
+
))
|
| 252 |
+
except Exception:
|
| 253 |
+
pass
|
| 254 |
+
|
| 255 |
+
except Exception as e:
|
| 256 |
+
crashes.append(CrashRecord(
|
| 257 |
+
crash_id="setup_error",
|
| 258 |
+
target="python_harness",
|
| 259 |
+
crash_input="",
|
| 260 |
+
crash_output=str(e),
|
| 261 |
+
stack_hash="error",
|
| 262 |
+
crash_type="setup_error",
|
| 263 |
+
is_unique=False,
|
| 264 |
+
reproducer_path="",
|
| 265 |
+
))
|
| 266 |
+
finally:
|
| 267 |
+
if harness_path and os.path.exists(harness_path):
|
| 268 |
+
os.unlink(harness_path)
|
| 269 |
+
|
| 270 |
+
return crashes
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _run_hypothesis(repo_dir: str, target: str, harness_code: str, duration_s: int) -> list[CrashRecord]:
|
| 274 |
+
"""Run Hypothesis property-based testing as a fuzzing fallback."""
|
| 275 |
+
crashes = []
|
| 276 |
+
fd, test_path = tempfile.mkstemp(suffix="_hyp_test.py", dir="/tmp")
|
| 277 |
+
try:
|
| 278 |
+
with os.fdopen(fd, "w") as f:
|
| 279 |
+
f.write(harness_code)
|
| 280 |
+
|
| 281 |
+
proc = subprocess.Popen(
|
| 282 |
+
["python", "-m", "pytest", test_path, "-x", "--tb=short",
|
| 283 |
+
f"--hypothesis-seed=0", "-q"],
|
| 284 |
+
cwd=repo_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
| 285 |
+
text=True,
|
| 286 |
+
)
|
| 287 |
+
try:
|
| 288 |
+
stdout, stderr = proc.communicate(timeout=min(duration_s, 60))
|
| 289 |
+
except subprocess.TimeoutExpired:
|
| 290 |
+
proc.kill()
|
| 291 |
+
stdout, stderr = proc.communicate()
|
| 292 |
+
|
| 293 |
+
combined = (stdout or "") + (stderr or "")
|
| 294 |
+
if "FAILED" in combined or "AssertionError" in combined or "Falsifying" in combined:
|
| 295 |
+
stack_hash = hashlib.sha256(combined[:200].encode()).hexdigest()[:16]
|
| 296 |
+
crashes.append(CrashRecord(
|
| 297 |
+
crash_id=stack_hash,
|
| 298 |
+
target=target,
|
| 299 |
+
crash_input="see Hypothesis output",
|
| 300 |
+
crash_output=combined[:2000],
|
| 301 |
+
stack_hash=stack_hash,
|
| 302 |
+
crash_type="assertion",
|
| 303 |
+
is_unique=True,
|
| 304 |
+
reproducer_path=test_path,
|
| 305 |
+
))
|
| 306 |
+
except Exception as e:
|
| 307 |
+
pass
|
| 308 |
+
finally:
|
| 309 |
+
try:
|
| 310 |
+
os.unlink(test_path)
|
| 311 |
+
except OSError:
|
| 312 |
+
pass
|
| 313 |
+
|
| 314 |
+
return crashes
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def run_fuzzing_campaign(
|
| 318 |
+
repo_dir: str,
|
| 319 |
+
target: str,
|
| 320 |
+
language: str = "python",
|
| 321 |
+
duration_s: int = 60,
|
| 322 |
+
) -> dict:
|
| 323 |
+
"""
|
| 324 |
+
Main entry point. Generate harness + run fuzzer + return triage results.
|
| 325 |
+
"""
|
| 326 |
+
duration_s = min(duration_s, MAX_FUZZ_DURATION)
|
| 327 |
+
print(f"[FUZZ] Starting campaign: {target} ({language}, {duration_s}s)")
|
| 328 |
+
|
| 329 |
+
source_context = _get_source_context(repo_dir, target, language)
|
| 330 |
+
harness_code = _llm_generate_harness(target, language, repo_dir, source_context)
|
| 331 |
+
|
| 332 |
+
start = time.time()
|
| 333 |
+
if language == "python" and "atheris" in harness_code:
|
| 334 |
+
crashes = _run_python_atheris(harness_code, duration_s)
|
| 335 |
+
else:
|
| 336 |
+
crashes = _run_hypothesis(repo_dir, target, harness_code, duration_s)
|
| 337 |
+
|
| 338 |
+
elapsed = time.time() - start
|
| 339 |
+
|
| 340 |
+
unique_hashes = set()
|
| 341 |
+
unique_crashes = []
|
| 342 |
+
for c in crashes:
|
| 343 |
+
if c.stack_hash not in unique_hashes:
|
| 344 |
+
unique_hashes.add(c.stack_hash)
|
| 345 |
+
unique_crashes.append(c)
|
| 346 |
+
|
| 347 |
+
result = FuzzResult(
|
| 348 |
+
target=target,
|
| 349 |
+
language=language,
|
| 350 |
+
duration_s=int(elapsed),
|
| 351 |
+
total_executions=len(crashes),
|
| 352 |
+
unique_crashes=unique_crashes,
|
| 353 |
+
coverage_percent=0.0,
|
| 354 |
+
harness_code=harness_code,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
print(f"[FUZZ] Done: {len(unique_crashes)} unique crash(es) in {elapsed:.1f}s")
|
| 358 |
+
|
| 359 |
+
return {
|
| 360 |
+
"target": result.target,
|
| 361 |
+
"language": result.language,
|
| 362 |
+
"duration_s": result.duration_s,
|
| 363 |
+
"unique_crashes": len(result.unique_crashes),
|
| 364 |
+
"harness_code": result.harness_code[:500],
|
| 365 |
+
"crashes": [
|
| 366 |
+
{
|
| 367 |
+
"id": c.crash_id, "type": c.crash_type,
|
| 368 |
+
"input_hex": c.crash_input[:100],
|
| 369 |
+
"output_snippet": c.crash_output[:500],
|
| 370 |
+
"stack_hash": c.stack_hash,
|
| 371 |
+
}
|
| 372 |
+
for c in result.unique_crashes
|
| 373 |
+
],
|
| 374 |
+
"has_crashes": len(result.unique_crashes) > 0,
|
| 375 |
+
}
|
hermes_orchestrator.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Hermes Master Orchestrator
|
| 3 |
+
========================================
|
| 4 |
+
Hermes is the intelligent agent that coordinates all security research components.
|
| 5 |
+
It acts as the "brain" — deciding which tools to deploy, in what order, and how
|
| 6 |
+
to synthesize findings into a coherent vulnerability report.
|
| 7 |
+
|
| 8 |
+
Architecture:
|
| 9 |
+
Hermes receives a target (repo + optional focus area) and executes a dynamic
|
| 10 |
+
multi-phase research plan using tool calls. It maintains state across phases,
|
| 11 |
+
routes findings between components, and escalates confidence incrementally.
|
| 12 |
+
|
| 13 |
+
Phases:
|
| 14 |
+
1. RECON — clone, fingerprint, map attack surface
|
| 15 |
+
2. STATIC — taint analysis, symbolic execution planning, CWE pattern match
|
| 16 |
+
3. DYNAMIC — fuzzing harness generation + execution
|
| 17 |
+
4. EXPLOIT — exploit primitive reasoning on confirmed crashes
|
| 18 |
+
5. CONSENSUS — multi-model adversarial verdict on findings
|
| 19 |
+
6. DISCLOSURE — package report, hold for human approval
|
| 20 |
+
|
| 21 |
+
Custom Algorithms:
|
| 22 |
+
VES — Vulnerability Entropy Score: how surprising/dangerous a code path is
|
| 23 |
+
TVG — Temporal Vulnerability Graph: how bugs propagate across commits
|
| 24 |
+
ACTS — Adversarial Consensus Trust Score: Bayesian multi-model confidence
|
| 25 |
+
CAD — Commit Anomaly Detection: statistical detection of silent security patches
|
| 26 |
+
SSEC — Semantic Similarity Exploit Chain: embedding distance to known exploit patterns
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import hashlib
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import threading
|
| 35 |
+
import time
|
| 36 |
+
from dataclasses import dataclass, field, asdict
|
| 37 |
+
from enum import Enum
|
| 38 |
+
from typing import Any, Callable, Optional
|
| 39 |
+
|
| 40 |
+
import requests
|
| 41 |
+
|
| 42 |
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
| 43 |
+
HERMES_MODEL = os.getenv("HERMES_MODEL", "deepseek/deepseek-r1:free")
|
| 44 |
+
HERMES_FAST_MODEL = os.getenv("HERMES_FAST_MODEL", "deepseek/deepseek-v3:free")
|
| 45 |
+
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
| 46 |
+
|
| 47 |
+
_log_lock = threading.Lock()
|
| 48 |
+
_hermes_logs: list[str] = []
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def hermes_log(msg: str, level: str = "HERMES"):
|
| 52 |
+
ts = time.strftime("%H:%M:%S")
|
| 53 |
+
icons = {
|
| 54 |
+
"HERMES": "🧠", "RECON": "🔭", "STATIC": "🔬", "DYNAMIC": "💥",
|
| 55 |
+
"EXPLOIT": "⚔️", "CONSENSUS": "🗳", "DISCLOSURE": "📋",
|
| 56 |
+
"TOOL": "🔧", "FIND": "🎯", "WARN": "⚠️", "OK": "✅", "FAIL": "❌",
|
| 57 |
+
"VES": "📊", "TVG": "🕸", "ACTS": "🧮", "CAD": "👁", "SSEC": "🔗",
|
| 58 |
+
}
|
| 59 |
+
line = f"[{ts}] {icons.get(level, '🧠')} [HERMES] {msg}"
|
| 60 |
+
print(line)
|
| 61 |
+
with _log_lock:
|
| 62 |
+
_hermes_logs.append(line)
|
| 63 |
+
if len(_hermes_logs) > 500:
|
| 64 |
+
_hermes_logs.pop(0)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_hermes_logs() -> list[str]:
|
| 68 |
+
with _log_lock:
|
| 69 |
+
return list(_hermes_logs)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ──────────────────────────────────────────────────────────────
|
| 73 |
+
# DATA STRUCTURES
|
| 74 |
+
# ──────────────────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
class ResearchPhase(str, Enum):
|
| 77 |
+
RECON = "RECON"
|
| 78 |
+
STATIC = "STATIC"
|
| 79 |
+
DYNAMIC = "DYNAMIC"
|
| 80 |
+
EXPLOIT = "EXPLOIT"
|
| 81 |
+
CONSENSUS = "CONSENSUS"
|
| 82 |
+
DISCLOSURE = "DISCLOSURE"
|
| 83 |
+
COMPLETE = "COMPLETE"
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass
|
| 87 |
+
class VulnerabilityFinding:
|
| 88 |
+
finding_id: str
|
| 89 |
+
title: str
|
| 90 |
+
cwe_id: str
|
| 91 |
+
severity: str # CRITICAL | HIGH | MEDIUM | LOW
|
| 92 |
+
confidence: float # 0.0 - 1.0
|
| 93 |
+
file_path: str
|
| 94 |
+
line_number: int
|
| 95 |
+
description: str
|
| 96 |
+
proof_of_concept: str
|
| 97 |
+
exploit_primitive: str # overflow | uaf | race | injection | crypto | logic
|
| 98 |
+
ves_score: float # Vulnerability Entropy Score
|
| 99 |
+
acts_score: float # Adversarial Consensus Trust Score
|
| 100 |
+
phase_found: str
|
| 101 |
+
raw_evidence: dict = field(default_factory=dict)
|
| 102 |
+
disclosure_status: str = "PENDING_HUMAN_APPROVAL"
|
| 103 |
+
timestamp: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@dataclass
|
| 107 |
+
class HermesSession:
|
| 108 |
+
session_id: str
|
| 109 |
+
target_repo: str
|
| 110 |
+
repo_dir: str
|
| 111 |
+
phase: ResearchPhase = ResearchPhase.RECON
|
| 112 |
+
findings: list[VulnerabilityFinding] = field(default_factory=list)
|
| 113 |
+
tool_call_log: list[dict] = field(default_factory=list)
|
| 114 |
+
attack_surface: dict = field(default_factory=dict)
|
| 115 |
+
tvg_graph: dict = field(default_factory=dict)
|
| 116 |
+
started_at: str = field(default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ"))
|
| 117 |
+
completed_at: Optional[str] = None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ──────────────────────────────────────────────────────────────
|
| 121 |
+
# TOOL REGISTRY — Hermes dispatches these
|
| 122 |
+
# ──────────────────────────────────────────────────────────────
|
| 123 |
+
|
| 124 |
+
class HermesTool:
|
| 125 |
+
"""Base class for all Hermes-dispatchable tools."""
|
| 126 |
+
name: str = "base_tool"
|
| 127 |
+
description: str = ""
|
| 128 |
+
|
| 129 |
+
def run(self, **kwargs) -> dict:
|
| 130 |
+
raise NotImplementedError
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class ReconTool(HermesTool):
|
| 134 |
+
name = "recon"
|
| 135 |
+
description = "Map attack surface of a cloned repository. Returns language, entry points, dangerous sinks, security-critical files."
|
| 136 |
+
|
| 137 |
+
def run(self, repo_dir: str, **kwargs) -> dict:
|
| 138 |
+
hermes_log(f"Recon scan → {repo_dir}", "RECON")
|
| 139 |
+
from taint_analyzer import map_attack_surface
|
| 140 |
+
return map_attack_surface(repo_dir)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class TaintTool(HermesTool):
|
| 144 |
+
name = "taint_analysis"
|
| 145 |
+
description = "Run taint/data-flow analysis to trace untrusted input to dangerous sinks."
|
| 146 |
+
|
| 147 |
+
def run(self, repo_dir: str, focus_files: list[str] = None, **kwargs) -> dict:
|
| 148 |
+
hermes_log(f"Taint analysis → {len(focus_files or [])} focus files", "STATIC")
|
| 149 |
+
from taint_analyzer import run_taint_analysis
|
| 150 |
+
return run_taint_analysis(repo_dir, focus_files=focus_files)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class SymbolicTool(HermesTool):
|
| 154 |
+
name = "symbolic_execution"
|
| 155 |
+
description = "Run symbolic execution to explore all code paths and find unsatisfied constraints."
|
| 156 |
+
|
| 157 |
+
def run(self, repo_dir: str, target_function: str = None, **kwargs) -> dict:
|
| 158 |
+
hermes_log(f"Symbolic execution → {target_function or 'auto-select'}", "STATIC")
|
| 159 |
+
from symbolic_engine import run_symbolic_analysis
|
| 160 |
+
return run_symbolic_analysis(repo_dir, target_function=target_function)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class FuzzTool(HermesTool):
|
| 164 |
+
name = "fuzz"
|
| 165 |
+
description = "Generate a fuzzing harness and execute it against a target function or binary."
|
| 166 |
+
|
| 167 |
+
def run(self, repo_dir: str, target: str, language: str = "python", duration_s: int = 60, **kwargs) -> dict:
|
| 168 |
+
hermes_log(f"Fuzzing → {target} ({duration_s}s)", "DYNAMIC")
|
| 169 |
+
from fuzzing_engine import run_fuzzing_campaign
|
| 170 |
+
return run_fuzzing_campaign(repo_dir, target, language=language, duration_s=duration_s)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class ExploitTool(HermesTool):
|
| 174 |
+
name = "exploit_reasoning"
|
| 175 |
+
description = "Reason about exploitability of a crash or vulnerability candidate. Generate PoC."
|
| 176 |
+
|
| 177 |
+
def run(self, crash_input: str, crash_output: str, file_path: str, vuln_type: str, **kwargs) -> dict:
|
| 178 |
+
hermes_log(f"Exploit reasoning → {vuln_type} in {file_path}", "EXPLOIT")
|
| 179 |
+
from exploit_primitives import reason_exploitability
|
| 180 |
+
return reason_exploitability(crash_input, crash_output, file_path, vuln_type)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class CVETool(HermesTool):
|
| 184 |
+
name = "cve_intel"
|
| 185 |
+
description = "Query CVE/NVD/CWE knowledge base for similar historical vulnerabilities."
|
| 186 |
+
|
| 187 |
+
def run(self, description: str, cwe_hint: str = None, **kwargs) -> dict:
|
| 188 |
+
hermes_log(f"CVE intel → {cwe_hint or 'auto'}", "STATIC")
|
| 189 |
+
from cve_intel import query_cve_intel
|
| 190 |
+
return query_cve_intel(description, cwe_hint=cwe_hint)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class CommitWatchTool(HermesTool):
|
| 194 |
+
name = "commit_watch"
|
| 195 |
+
description = "Analyze recent commits for silent security patches using CAD algorithm."
|
| 196 |
+
|
| 197 |
+
def run(self, repo_dir: str, lookback_commits: int = 50, **kwargs) -> dict:
|
| 198 |
+
hermes_log(f"Commit watch → last {lookback_commits} commits", "CAD")
|
| 199 |
+
from commit_watcher import analyze_recent_commits
|
| 200 |
+
return analyze_recent_commits(repo_dir, lookback=lookback_commits)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class SSECTool(HermesTool):
|
| 204 |
+
name = "ssec_scan"
|
| 205 |
+
description = "Semantic Similarity Exploit Chain: find code patterns similar to known exploits."
|
| 206 |
+
|
| 207 |
+
def run(self, repo_dir: str, focus_files: list[str] = None, **kwargs) -> dict:
|
| 208 |
+
hermes_log("SSEC scan — embedding similarity to known exploit patterns", "SSEC")
|
| 209 |
+
from cve_intel import run_ssec_scan
|
| 210 |
+
return run_ssec_scan(repo_dir, focus_files=focus_files)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
_TOOL_REGISTRY: dict[str, HermesTool] = {
|
| 214 |
+
t.name: t() for t in [
|
| 215 |
+
ReconTool, TaintTool, SymbolicTool, FuzzTool,
|
| 216 |
+
ExploitTool, CVETool, CommitWatchTool, SSECTool,
|
| 217 |
+
]
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _dispatch_tool(tool_name: str, args: dict, session: HermesSession) -> dict:
|
| 222 |
+
tool = _TOOL_REGISTRY.get(tool_name)
|
| 223 |
+
if not tool:
|
| 224 |
+
return {"error": f"Unknown tool: {tool_name}"}
|
| 225 |
+
start = time.time()
|
| 226 |
+
try:
|
| 227 |
+
result = tool.run(**args)
|
| 228 |
+
except Exception as e:
|
| 229 |
+
result = {"error": str(e)}
|
| 230 |
+
elapsed = round(time.time() - start, 2)
|
| 231 |
+
session.tool_call_log.append({
|
| 232 |
+
"tool": tool_name, "args": args,
|
| 233 |
+
"result_keys": list(result.keys()) if isinstance(result, dict) else "non-dict",
|
| 234 |
+
"elapsed_s": elapsed, "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 235 |
+
})
|
| 236 |
+
return result
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ──────────────────────────────────────────────────────────────
|
| 240 |
+
# VES — VULNERABILITY ENTROPY SCORE (Custom Algorithm)
|
| 241 |
+
# ──────────────────────────────────────────────────────────────
|
| 242 |
+
|
| 243 |
+
def compute_ves(
|
| 244 |
+
reachability: float, # 0-1: how reachable is this from untrusted input
|
| 245 |
+
severity_class: str, # CRITICAL | HIGH | MEDIUM | LOW
|
| 246 |
+
novelty: float, # 0-1: how different from known CVEs (higher = more novel/interesting)
|
| 247 |
+
exploit_complexity: str, # LOW | MEDIUM | HIGH
|
| 248 |
+
auth_required: bool,
|
| 249 |
+
) -> float:
|
| 250 |
+
"""
|
| 251 |
+
VES (Vulnerability Entropy Score) — custom algorithm.
|
| 252 |
+
|
| 253 |
+
Measures the "information surprise" of a vulnerability weighted by its
|
| 254 |
+
danger. High VES = high-value finding (novel + dangerous + reachable).
|
| 255 |
+
|
| 256 |
+
Formula inspired by Shannon entropy: VES = -log2(P) × W
|
| 257 |
+
Where P is the probability an auditor would find this naturally,
|
| 258 |
+
and W is a danger weight. Higher VES = better bug bounty target.
|
| 259 |
+
"""
|
| 260 |
+
import math
|
| 261 |
+
|
| 262 |
+
severity_weight = {"CRITICAL": 1.0, "HIGH": 0.75, "MEDIUM": 0.5, "LOW": 0.25}.get(severity_class, 0.5)
|
| 263 |
+
complexity_factor = {"LOW": 0.9, "MEDIUM": 0.6, "HIGH": 0.3}.get(exploit_complexity, 0.6)
|
| 264 |
+
auth_penalty = 0.7 if auth_required else 1.0
|
| 265 |
+
|
| 266 |
+
# P(naturally found) = (1 - novelty) × (1 - reachability × 0.5)
|
| 267 |
+
p_found_naturally = max(0.001, (1.0 - novelty) * (1.0 - reachability * 0.5))
|
| 268 |
+
entropy = -math.log2(p_found_naturally)
|
| 269 |
+
|
| 270 |
+
ves = entropy * severity_weight * complexity_factor * auth_penalty * reachability
|
| 271 |
+
return round(min(ves, 10.0), 4)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ──────────────────────────────────────────────────────────────
|
| 275 |
+
# ACTS — ADVERSARIAL CONSENSUS TRUST SCORE (Custom Algorithm)
|
| 276 |
+
# ──────────────────────────────────────────────────────────────
|
| 277 |
+
|
| 278 |
+
def compute_acts(model_verdicts: list[dict]) -> float:
|
| 279 |
+
"""
|
| 280 |
+
ACTS (Adversarial Consensus Trust Score) — Bayesian multi-model confidence.
|
| 281 |
+
|
| 282 |
+
Each model contributes a vote weighted by its stated confidence.
|
| 283 |
+
Agreement amplifies confidence. Disagreement deflates it.
|
| 284 |
+
Returns 0.0–1.0 where >0.7 = high trust finding.
|
| 285 |
+
"""
|
| 286 |
+
if not model_verdicts:
|
| 287 |
+
return 0.0
|
| 288 |
+
|
| 289 |
+
confirm_weight = 0.0
|
| 290 |
+
total_weight = 0.0
|
| 291 |
+
agreements = 0
|
| 292 |
+
first_verdict = model_verdicts[0].get("verdict", "UNCERTAIN")
|
| 293 |
+
|
| 294 |
+
for verdict in model_verdicts:
|
| 295 |
+
v = verdict.get("verdict", "UNCERTAIN")
|
| 296 |
+
c = float(verdict.get("confidence", 0.5))
|
| 297 |
+
total_weight += c
|
| 298 |
+
if v in ("CONFIRM", "APPROVE", "VULNERABLE"):
|
| 299 |
+
confirm_weight += c
|
| 300 |
+
if v == first_verdict:
|
| 301 |
+
agreements += 1
|
| 302 |
+
|
| 303 |
+
if total_weight == 0:
|
| 304 |
+
return 0.0
|
| 305 |
+
|
| 306 |
+
raw_score = confirm_weight / total_weight
|
| 307 |
+
agreement_factor = agreements / len(model_verdicts)
|
| 308 |
+
acts = raw_score * (0.6 + 0.4 * agreement_factor)
|
| 309 |
+
return round(acts, 4)
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ──────────────────────────────────────────────────────────────
|
| 313 |
+
# LLM CALLS — Hermes reasoning engine
|
| 314 |
+
# ──────────────────────────────────────────────────────────────
|
| 315 |
+
|
| 316 |
+
_HERMES_SYSTEM = """You are Hermes, a world-class autonomous security research agent.
|
| 317 |
+
Your goal is to find real, exploitable vulnerabilities in open source projects.
|
| 318 |
+
You are methodical, adversarial, and thorough. You think like an attacker.
|
| 319 |
+
|
| 320 |
+
You have access to these tools:
|
| 321 |
+
- recon: Map attack surface (entry points, dangerous sinks, security-critical files)
|
| 322 |
+
- taint_analysis: Trace untrusted input to dangerous sinks
|
| 323 |
+
- symbolic_execution: Explore all code paths mathematically
|
| 324 |
+
- fuzz: Generate and run fuzzing campaigns to find crashes
|
| 325 |
+
- exploit_reasoning: Reason about exploitability, generate PoC
|
| 326 |
+
- cve_intel: Query historical CVEs for similar patterns
|
| 327 |
+
- commit_watch: Find silent security patches in commit history
|
| 328 |
+
- ssec_scan: Semantic similarity to known exploit patterns
|
| 329 |
+
|
| 330 |
+
For each target, produce a research plan and execute it step by step.
|
| 331 |
+
When you find something, rate its severity honestly. Never hallucinate findings.
|
| 332 |
+
A false positive wastes a maintainer's time. Be certain before escalating.
|
| 333 |
+
|
| 334 |
+
Respond with JSON tool calls in this format:
|
| 335 |
+
{
|
| 336 |
+
"thought": "your reasoning about what to do next",
|
| 337 |
+
"tool": "tool_name",
|
| 338 |
+
"args": {"key": "value"},
|
| 339 |
+
"phase": "RECON|STATIC|DYNAMIC|EXPLOIT|CONSENSUS|DISCLOSURE"
|
| 340 |
+
}
|
| 341 |
+
Or to report a final finding:
|
| 342 |
+
{
|
| 343 |
+
"thought": "summary reasoning",
|
| 344 |
+
"finding": {
|
| 345 |
+
"title": "...", "cwe_id": "CWE-XXX", "severity": "HIGH",
|
| 346 |
+
"confidence": 0.85, "file_path": "...", "line_number": 0,
|
| 347 |
+
"description": "...", "proof_of_concept": "...", "exploit_primitive": "..."
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
Or to signal completion:
|
| 351 |
+
{"done": true, "summary": "..."}
|
| 352 |
+
"""
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def _hermes_llm_call(messages: list[dict], model: str = None, timeout: int = 120) -> dict:
|
| 356 |
+
if not OPENROUTER_API_KEY:
|
| 357 |
+
return {"done": True, "summary": "OPENROUTER_API_KEY not set"}
|
| 358 |
+
|
| 359 |
+
model = model or HERMES_MODEL
|
| 360 |
+
headers = {
|
| 361 |
+
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
| 362 |
+
"Content-Type": "application/json",
|
| 363 |
+
"HTTP-Referer": "https://rhodawk.ai",
|
| 364 |
+
"X-Title": "Rhodawk Hermes Orchestrator",
|
| 365 |
+
}
|
| 366 |
+
payload = {
|
| 367 |
+
"model": model,
|
| 368 |
+
"messages": messages,
|
| 369 |
+
"temperature": 0.2,
|
| 370 |
+
"max_tokens": 2048,
|
| 371 |
+
"response_format": {"type": "json_object"},
|
| 372 |
+
}
|
| 373 |
+
try:
|
| 374 |
+
resp = requests.post(
|
| 375 |
+
f"{OPENROUTER_BASE}/chat/completions",
|
| 376 |
+
headers=headers, json=payload, timeout=timeout,
|
| 377 |
+
)
|
| 378 |
+
resp.raise_for_status()
|
| 379 |
+
content = resp.json()["choices"][0]["message"]["content"]
|
| 380 |
+
return json.loads(content)
|
| 381 |
+
except Exception as e:
|
| 382 |
+
hermes_log(f"LLM call failed: {e}", "WARN")
|
| 383 |
+
return {"done": True, "summary": f"LLM error: {e}"}
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# ──────────────────────────────────────────────────────────────
|
| 387 |
+
# MAIN HERMES RESEARCH LOOP
|
| 388 |
+
# ──────────────────────────────────────────────────────────────
|
| 389 |
+
|
| 390 |
+
def run_hermes_research(
|
| 391 |
+
target_repo: str,
|
| 392 |
+
repo_dir: str,
|
| 393 |
+
focus_area: str = "",
|
| 394 |
+
max_iterations: int = 20,
|
| 395 |
+
progress_callback: Optional[Callable[[str], None]] = None,
|
| 396 |
+
) -> HermesSession:
|
| 397 |
+
"""
|
| 398 |
+
Main Hermes research loop. Runs until max_iterations or completion.
|
| 399 |
+
Returns a HermesSession with all findings.
|
| 400 |
+
"""
|
| 401 |
+
session_id = hashlib.sha256(f"{target_repo}{time.time()}".encode()).hexdigest()[:12]
|
| 402 |
+
session = HermesSession(
|
| 403 |
+
session_id=session_id,
|
| 404 |
+
target_repo=target_repo,
|
| 405 |
+
repo_dir=repo_dir,
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
def log(msg, level="HERMES"):
|
| 409 |
+
hermes_log(msg, level)
|
| 410 |
+
if progress_callback:
|
| 411 |
+
progress_callback(f"[{level}] {msg}")
|
| 412 |
+
|
| 413 |
+
log(f"Session {session_id} started → {target_repo}")
|
| 414 |
+
log(f"Model: {HERMES_MODEL} | Max iterations: {max_iterations}")
|
| 415 |
+
|
| 416 |
+
messages = [
|
| 417 |
+
{"role": "system", "content": _HERMES_SYSTEM},
|
| 418 |
+
{"role": "user", "content": (
|
| 419 |
+
f"TARGET REPOSITORY: {target_repo}\n"
|
| 420 |
+
f"LOCAL PATH: {repo_dir}\n"
|
| 421 |
+
f"FOCUS AREA: {focus_area or 'Full autonomous scan — prioritize attack surface'}\n\n"
|
| 422 |
+
"Begin your security research. Start with reconnaissance to map the attack surface, "
|
| 423 |
+
"then systematically probe for vulnerabilities. Remember: only report findings you "
|
| 424 |
+
"are confident are real and exploitable."
|
| 425 |
+
)},
|
| 426 |
+
]
|
| 427 |
+
|
| 428 |
+
for iteration in range(max_iterations):
|
| 429 |
+
log(f"Iteration {iteration + 1}/{max_iterations}", "HERMES")
|
| 430 |
+
|
| 431 |
+
response = _hermes_llm_call(messages)
|
| 432 |
+
|
| 433 |
+
if response.get("done"):
|
| 434 |
+
log(f"Research complete: {response.get('summary', 'No summary')}", "OK")
|
| 435 |
+
session.phase = ResearchPhase.COMPLETE
|
| 436 |
+
break
|
| 437 |
+
|
| 438 |
+
if "finding" in response:
|
| 439 |
+
finding_data = response["finding"]
|
| 440 |
+
log(f"FINDING: {finding_data.get('title', '?')} [{finding_data.get('severity', '?')}]", "FIND")
|
| 441 |
+
|
| 442 |
+
# Compute VES
|
| 443 |
+
ves = compute_ves(
|
| 444 |
+
reachability=finding_data.get("confidence", 0.5),
|
| 445 |
+
severity_class=finding_data.get("severity", "MEDIUM"),
|
| 446 |
+
novelty=0.6,
|
| 447 |
+
exploit_complexity="MEDIUM",
|
| 448 |
+
auth_required=False,
|
| 449 |
+
)
|
| 450 |
+
log(f"VES Score: {ves}", "VES")
|
| 451 |
+
|
| 452 |
+
finding = VulnerabilityFinding(
|
| 453 |
+
finding_id=hashlib.sha256(
|
| 454 |
+
f"{finding_data.get('file_path', '')}{finding_data.get('line_number', 0)}{time.time()}".encode()
|
| 455 |
+
).hexdigest()[:12],
|
| 456 |
+
title=finding_data.get("title", "Unnamed Finding"),
|
| 457 |
+
cwe_id=finding_data.get("cwe_id", "CWE-UNKNOWN"),
|
| 458 |
+
severity=finding_data.get("severity", "MEDIUM"),
|
| 459 |
+
confidence=float(finding_data.get("confidence", 0.5)),
|
| 460 |
+
file_path=finding_data.get("file_path", ""),
|
| 461 |
+
line_number=int(finding_data.get("line_number", 0)),
|
| 462 |
+
description=finding_data.get("description", ""),
|
| 463 |
+
proof_of_concept=finding_data.get("proof_of_concept", ""),
|
| 464 |
+
exploit_primitive=finding_data.get("exploit_primitive", "unknown"),
|
| 465 |
+
ves_score=ves,
|
| 466 |
+
acts_score=0.0,
|
| 467 |
+
phase_found=session.phase.value,
|
| 468 |
+
disclosure_status="PENDING_HUMAN_APPROVAL",
|
| 469 |
+
)
|
| 470 |
+
session.findings.append(finding)
|
| 471 |
+
|
| 472 |
+
messages.append({"role": "assistant", "content": json.dumps(response)})
|
| 473 |
+
messages.append({"role": "user", "content": (
|
| 474 |
+
f"Finding recorded (ID: {finding.finding_id}, VES: {ves}). "
|
| 475 |
+
"Continue research ��� there may be more vulnerabilities. "
|
| 476 |
+
"If you believe the surface is exhausted, signal done."
|
| 477 |
+
)})
|
| 478 |
+
continue
|
| 479 |
+
|
| 480 |
+
if "tool" not in response:
|
| 481 |
+
log("No tool call in response — signaling done", "WARN")
|
| 482 |
+
break
|
| 483 |
+
|
| 484 |
+
tool_name = response.get("tool", "")
|
| 485 |
+
tool_args = response.get("args", {})
|
| 486 |
+
phase_str = response.get("phase", session.phase.value)
|
| 487 |
+
thought = response.get("thought", "")
|
| 488 |
+
|
| 489 |
+
try:
|
| 490 |
+
session.phase = ResearchPhase(phase_str)
|
| 491 |
+
except ValueError:
|
| 492 |
+
pass
|
| 493 |
+
|
| 494 |
+
log(f"Phase: {session.phase.value} | Tool: {tool_name}", session.phase.value)
|
| 495 |
+
if thought:
|
| 496 |
+
log(f"Reasoning: {thought[:200]}", "HERMES")
|
| 497 |
+
|
| 498 |
+
tool_args["repo_dir"] = repo_dir
|
| 499 |
+
tool_result = _dispatch_tool(tool_name, tool_args, session)
|
| 500 |
+
|
| 501 |
+
if tool_name == "recon" and isinstance(tool_result, dict):
|
| 502 |
+
session.attack_surface = tool_result
|
| 503 |
+
log(f"Attack surface: {len(tool_result.get('dangerous_sinks', []))} sinks, "
|
| 504 |
+
f"{len(tool_result.get('entry_points', []))} entry points", "RECON")
|
| 505 |
+
|
| 506 |
+
messages.append({"role": "assistant", "content": json.dumps(response)})
|
| 507 |
+
messages.append({
|
| 508 |
+
"role": "user",
|
| 509 |
+
"content": (
|
| 510 |
+
f"Tool '{tool_name}' result:\n```json\n"
|
| 511 |
+
f"{json.dumps(tool_result, indent=2)[:3000]}\n```\n\n"
|
| 512 |
+
"Based on these results, what is your next action?"
|
| 513 |
+
),
|
| 514 |
+
})
|
| 515 |
+
|
| 516 |
+
time.sleep(1)
|
| 517 |
+
|
| 518 |
+
# Run ACTS consensus on all findings
|
| 519 |
+
if session.findings:
|
| 520 |
+
log(f"Running ACTS consensus on {len(session.findings)} finding(s)...", "CONSENSUS")
|
| 521 |
+
session.phase = ResearchPhase.CONSENSUS
|
| 522 |
+
_run_acts_consensus(session)
|
| 523 |
+
|
| 524 |
+
session.phase = ResearchPhase.DISCLOSURE
|
| 525 |
+
session.completed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 526 |
+
log(f"Session complete — {len(session.findings)} finding(s) pending human approval", "DISCLOSURE")
|
| 527 |
+
return session
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
def _run_acts_consensus(session: HermesSession):
|
| 531 |
+
"""Run multi-model adversarial consensus on each finding to compute ACTS score."""
|
| 532 |
+
from adversarial_reviewer import _call_concurrent_consensus
|
| 533 |
+
|
| 534 |
+
CONSENSUS_MODELS = [
|
| 535 |
+
"deepseek/deepseek-r1:free",
|
| 536 |
+
"meta-llama/llama-3.3-70b-instruct:free",
|
| 537 |
+
"google/gemma-3-27b-it:free",
|
| 538 |
+
]
|
| 539 |
+
|
| 540 |
+
for finding in session.findings:
|
| 541 |
+
hermes_log(f"ACTS consensus: {finding.finding_id}", "ACTS")
|
| 542 |
+
prompt = (
|
| 543 |
+
f"VULNERABILITY FINDING FOR CONSENSUS REVIEW\n\n"
|
| 544 |
+
f"Title: {finding.title}\n"
|
| 545 |
+
f"CWE: {finding.cwe_id}\n"
|
| 546 |
+
f"Severity claimed: {finding.severity}\n"
|
| 547 |
+
f"File: {finding.file_path}:{finding.line_number}\n"
|
| 548 |
+
f"Description: {finding.description}\n"
|
| 549 |
+
f"PoC: {finding.proof_of_concept}\n"
|
| 550 |
+
f"Exploit primitive: {finding.exploit_primitive}\n\n"
|
| 551 |
+
"Is this a real, exploitable vulnerability? Respond as a hostile security reviewer."
|
| 552 |
+
)
|
| 553 |
+
try:
|
| 554 |
+
result, _ = _call_concurrent_consensus(prompt)
|
| 555 |
+
verdicts = [
|
| 556 |
+
{"verdict": result.get("verdict", "UNCERTAIN"), "confidence": result.get("confidence", 0.5)}
|
| 557 |
+
]
|
| 558 |
+
finding.acts_score = compute_acts(verdicts)
|
| 559 |
+
hermes_log(f"ACTS score for {finding.finding_id}: {finding.acts_score}", "ACTS")
|
| 560 |
+
except Exception as e:
|
| 561 |
+
hermes_log(f"ACTS consensus failed for {finding.finding_id}: {e}", "WARN")
|
| 562 |
+
finding.acts_score = finding.confidence
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
# ──────────────────────────────────────────────────────────────
|
| 566 |
+
# TVG — TEMPORAL VULNERABILITY GRAPH (Custom Algorithm)
|
| 567 |
+
# ──────────────────────────────────────────────────────────────
|
| 568 |
+
|
| 569 |
+
def build_tvg(repo_dir: str, findings: list[VulnerabilityFinding]) -> dict:
|
| 570 |
+
"""
|
| 571 |
+
TVG (Temporal Vulnerability Graph) — tracks how vulnerability patterns
|
| 572 |
+
propagate across commits over time.
|
| 573 |
+
|
| 574 |
+
Builds a directed graph: commit → file → vulnerable_function → severity
|
| 575 |
+
Allows answering: "was this bug always here, or introduced recently?"
|
| 576 |
+
"""
|
| 577 |
+
import subprocess
|
| 578 |
+
graph = {"nodes": [], "edges": [], "hotspots": []}
|
| 579 |
+
|
| 580 |
+
for finding in findings:
|
| 581 |
+
if not finding.file_path:
|
| 582 |
+
continue
|
| 583 |
+
try:
|
| 584 |
+
result = subprocess.run(
|
| 585 |
+
["git", "log", "--follow", "--oneline", "-20", "--", finding.file_path],
|
| 586 |
+
cwd=repo_dir, capture_output=True, text=True, timeout=10,
|
| 587 |
+
)
|
| 588 |
+
commits = [line.split(" ", 1) for line in result.stdout.strip().splitlines() if line]
|
| 589 |
+
for sha, msg in commits[:5]:
|
| 590 |
+
node = {"id": sha[:8], "file": finding.file_path, "msg": msg[:80]}
|
| 591 |
+
if node not in graph["nodes"]:
|
| 592 |
+
graph["nodes"].append(node)
|
| 593 |
+
graph["edges"].append({
|
| 594 |
+
"from": sha[:8], "to": finding.finding_id,
|
| 595 |
+
"label": finding.cwe_id,
|
| 596 |
+
})
|
| 597 |
+
if len(commits) > 10:
|
| 598 |
+
graph["hotspots"].append({
|
| 599 |
+
"file": finding.file_path, "commit_count": len(commits),
|
| 600 |
+
"finding": finding.finding_id,
|
| 601 |
+
})
|
| 602 |
+
except Exception:
|
| 603 |
+
pass
|
| 604 |
+
|
| 605 |
+
return graph
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def get_session_summary(session: HermesSession) -> dict:
|
| 609 |
+
"""Produce a human-readable summary of a research session."""
|
| 610 |
+
findings_by_sev: dict[str, list] = {}
|
| 611 |
+
for f in session.findings:
|
| 612 |
+
findings_by_sev.setdefault(f.severity, []).append(f)
|
| 613 |
+
|
| 614 |
+
return {
|
| 615 |
+
"session_id": session.session_id,
|
| 616 |
+
"target": session.target_repo,
|
| 617 |
+
"started_at": session.started_at,
|
| 618 |
+
"completed_at": session.completed_at,
|
| 619 |
+
"phase": session.phase.value,
|
| 620 |
+
"total_findings": len(session.findings),
|
| 621 |
+
"by_severity": {k: len(v) for k, v in findings_by_sev.items()},
|
| 622 |
+
"top_findings": [
|
| 623 |
+
{
|
| 624 |
+
"id": f.finding_id, "title": f.title, "cwe": f.cwe_id,
|
| 625 |
+
"severity": f.severity, "ves": f.ves_score, "acts": f.acts_score,
|
| 626 |
+
"file": f.file_path, "status": f.disclosure_status,
|
| 627 |
+
}
|
| 628 |
+
for f in sorted(session.findings, key=lambda x: x.ves_score, reverse=True)[:5]
|
| 629 |
+
],
|
| 630 |
+
"tool_calls": len(session.tool_call_log),
|
| 631 |
+
"attack_surface_size": len(session.attack_surface.get("dangerous_sinks", [])),
|
| 632 |
+
}
|
mcp_config.json
CHANGED
|
@@ -3,17 +3,14 @@
|
|
| 3 |
"TEMPLATE ONLY — contains NO secrets.",
|
| 4 |
"Actual runtime config is written to /tmp/mcp_runtime.json at startup.",
|
| 5 |
"FETCH_ALLOWED_DOMAINS prevents SSRF against internal services.",
|
| 6 |
-
"
|
| 7 |
-
"NOTE: @modelcontextprotocol/server-fetch does not exist on npm.",
|
| 8 |
-
" The fetch MCP server is a Python package (mcp-server-fetch on PyPI).",
|
| 9 |
-
" It is installed via uv in the Dockerfile and invoked with `uvx mcp-server-fetch`."
|
| 10 |
],
|
| 11 |
"mcpServers": {
|
| 12 |
"fetch-docs": {
|
| 13 |
"command": "uvx",
|
| 14 |
"args": ["mcp-server-fetch"],
|
| 15 |
"env": {
|
| 16 |
-
"FETCH_ALLOWED_DOMAINS": "docs.python.org,pypi.org,docs.github.com,packaging.python.org,peps.python.org"
|
| 17 |
}
|
| 18 |
},
|
| 19 |
"github-manager": {
|
|
@@ -22,6 +19,29 @@
|
|
| 22 |
"env": {
|
| 23 |
"GITHUB_PERSONAL_ACCESS_TOKEN": "__INJECTED_BY_APP_AT_RUNTIME__"
|
| 24 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
}
|
| 27 |
}
|
|
|
|
| 3 |
"TEMPLATE ONLY — contains NO secrets.",
|
| 4 |
"Actual runtime config is written to /tmp/mcp_runtime.json at startup.",
|
| 5 |
"FETCH_ALLOWED_DOMAINS prevents SSRF against internal services.",
|
| 6 |
+
"Secrets injected from env at runtime — never committed."
|
|
|
|
|
|
|
|
|
|
| 7 |
],
|
| 8 |
"mcpServers": {
|
| 9 |
"fetch-docs": {
|
| 10 |
"command": "uvx",
|
| 11 |
"args": ["mcp-server-fetch"],
|
| 12 |
"env": {
|
| 13 |
+
"FETCH_ALLOWED_DOMAINS": "docs.python.org,pypi.org,docs.github.com,packaging.python.org,peps.python.org,cwe.mitre.org,nvd.nist.gov,owasp.org,portswigger.net,hackerone.com,bugcrowd.com,cve.org,exploit-db.com,docs.rs,go.dev,nodejs.org,developer.mozilla.org"
|
| 14 |
}
|
| 15 |
},
|
| 16 |
"github-manager": {
|
|
|
|
| 19 |
"env": {
|
| 20 |
"GITHUB_PERSONAL_ACCESS_TOKEN": "__INJECTED_BY_APP_AT_RUNTIME__"
|
| 21 |
}
|
| 22 |
+
},
|
| 23 |
+
"filesystem-research": {
|
| 24 |
+
"command": "npx",
|
| 25 |
+
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data/repo", "/tmp/research"],
|
| 26 |
+
"description": "Read-only access to cloned repos and research scratch space"
|
| 27 |
+
},
|
| 28 |
+
"memory-store": {
|
| 29 |
+
"command": "npx",
|
| 30 |
+
"args": ["-y", "@modelcontextprotocol/server-memory"],
|
| 31 |
+
"description": "Persistent knowledge graph for cross-session vulnerability memory"
|
| 32 |
+
},
|
| 33 |
+
"sequential-thinking": {
|
| 34 |
+
"command": "npx",
|
| 35 |
+
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
| 36 |
+
"description": "Structured chain-of-thought for complex vulnerability analysis"
|
| 37 |
+
},
|
| 38 |
+
"web-search": {
|
| 39 |
+
"command": "npx",
|
| 40 |
+
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
| 41 |
+
"env": {
|
| 42 |
+
"BRAVE_API_KEY": "__INJECTED_BY_APP_AT_RUNTIME__"
|
| 43 |
+
},
|
| 44 |
+
"description": "Search for CVEs, exploit PoCs, vendor advisories, and security research"
|
| 45 |
}
|
| 46 |
}
|
| 47 |
}
|
requirements.txt
CHANGED
|
@@ -24,3 +24,7 @@ z3-solver>=4.12.0
|
|
| 24 |
qdrant-client>=1.9.0
|
| 25 |
transformers>=4.40.0
|
| 26 |
torch>=2.2.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
qdrant-client>=1.9.0
|
| 25 |
transformers>=4.40.0
|
| 26 |
torch>=2.2.0
|
| 27 |
+
atheris>=2.3.0
|
| 28 |
+
angr>=9.2.0
|
| 29 |
+
networkx>=3.0
|
| 30 |
+
defusedxml>=0.7.1
|
symbolic_engine.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Symbolic Execution Engine
|
| 3 |
+
========================================
|
| 4 |
+
Uses angr (Python binary analysis framework) to perform symbolic execution
|
| 5 |
+
on compiled binaries, and AST-based path analysis for interpreted languages.
|
| 6 |
+
|
| 7 |
+
For Python/JS repos where angr isn't applicable, performs:
|
| 8 |
+
- Control flow graph analysis
|
| 9 |
+
- Constraint collection on input-touching branches
|
| 10 |
+
- Path condition enumeration to find unreachable/unchecked branches
|
| 11 |
+
|
| 12 |
+
Findings fed back to Hermes for exploit_primitives reasoning.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import ast
|
| 18 |
+
import os
|
| 19 |
+
import json
|
| 20 |
+
import tempfile
|
| 21 |
+
import subprocess
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class SymbolicPath:
|
| 28 |
+
function_name: str
|
| 29 |
+
file_path: str
|
| 30 |
+
line_start: int
|
| 31 |
+
line_end: int
|
| 32 |
+
constraint_summary: str
|
| 33 |
+
is_vulnerable: bool
|
| 34 |
+
vulnerability_type: str # unchecked_input | integer_overflow | null_deref | format_string
|
| 35 |
+
confidence: float
|
| 36 |
+
angr_available: bool = False
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class SymbolicResult:
|
| 41 |
+
target_function: str
|
| 42 |
+
paths_explored: int
|
| 43 |
+
vulnerable_paths: list[SymbolicPath]
|
| 44 |
+
overflow_candidates: list[dict]
|
| 45 |
+
null_deref_candidates: list[dict]
|
| 46 |
+
unchecked_inputs: list[dict]
|
| 47 |
+
tool_used: str # angr | ast_analysis | semgrep_symbolic
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _try_import_angr():
|
| 51 |
+
try:
|
| 52 |
+
import angr
|
| 53 |
+
return angr
|
| 54 |
+
except ImportError:
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _find_binary(repo_dir: str) -> Optional[str]:
|
| 59 |
+
"""Find a compiled binary in the repo."""
|
| 60 |
+
for root, dirs, files in os.walk(repo_dir):
|
| 61 |
+
dirs[:] = [d for d in dirs if d not in [".git", "node_modules", "__pycache__"]]
|
| 62 |
+
for f in files:
|
| 63 |
+
fpath = os.path.join(root, f)
|
| 64 |
+
try:
|
| 65 |
+
if os.access(fpath, os.X_OK) and not f.endswith((".py", ".js", ".ts", ".sh")):
|
| 66 |
+
result = subprocess.run(
|
| 67 |
+
["file", fpath], capture_output=True, text=True, timeout=3,
|
| 68 |
+
)
|
| 69 |
+
if "ELF" in result.stdout or "Mach-O" in result.stdout:
|
| 70 |
+
return fpath
|
| 71 |
+
except Exception:
|
| 72 |
+
pass
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _angr_analysis(binary_path: str, target_function: str) -> SymbolicResult:
|
| 77 |
+
"""Run angr symbolic execution on a compiled binary."""
|
| 78 |
+
angr = _try_import_angr()
|
| 79 |
+
if not angr:
|
| 80 |
+
return SymbolicResult(
|
| 81 |
+
target_function=target_function,
|
| 82 |
+
paths_explored=0,
|
| 83 |
+
vulnerable_paths=[],
|
| 84 |
+
overflow_candidates=[],
|
| 85 |
+
null_deref_candidates=[],
|
| 86 |
+
unchecked_inputs=[],
|
| 87 |
+
tool_used="angr_unavailable",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
vulnerable_paths = []
|
| 91 |
+
overflow_candidates = []
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
proj = angr.Project(binary_path, auto_load_libs=False)
|
| 95 |
+
cfg = proj.analyses.CFGFast()
|
| 96 |
+
|
| 97 |
+
func = None
|
| 98 |
+
for addr, f in proj.kb.functions.items():
|
| 99 |
+
if target_function in (f.name or ""):
|
| 100 |
+
func = f
|
| 101 |
+
break
|
| 102 |
+
|
| 103 |
+
if func is None:
|
| 104 |
+
func = list(proj.kb.functions.values())[0] if proj.kb.functions else None
|
| 105 |
+
|
| 106 |
+
if func:
|
| 107 |
+
state = proj.factory.blank_state(addr=func.addr)
|
| 108 |
+
simgr = proj.factory.simulation_manager(state)
|
| 109 |
+
simgr.explore(find=lambda s: s.solver.satisfiable(), n=50)
|
| 110 |
+
|
| 111 |
+
paths_explored = len(simgr.active) + len(simgr.deadended)
|
| 112 |
+
|
| 113 |
+
for state in simgr.active[:10]:
|
| 114 |
+
constraints = str(state.solver.constraints)[:200]
|
| 115 |
+
if any(kw in constraints for kw in ["__add__", "__mul__", "SignExt"]):
|
| 116 |
+
overflow_candidates.append({
|
| 117 |
+
"address": hex(state.addr),
|
| 118 |
+
"constraint_hint": constraints[:100],
|
| 119 |
+
"type": "potential_integer_overflow",
|
| 120 |
+
})
|
| 121 |
+
|
| 122 |
+
return SymbolicResult(
|
| 123 |
+
target_function=target_function or func.name or "unknown",
|
| 124 |
+
paths_explored=paths_explored,
|
| 125 |
+
vulnerable_paths=vulnerable_paths,
|
| 126 |
+
overflow_candidates=overflow_candidates,
|
| 127 |
+
null_deref_candidates=[],
|
| 128 |
+
unchecked_inputs=[],
|
| 129 |
+
tool_used="angr",
|
| 130 |
+
)
|
| 131 |
+
except Exception as e:
|
| 132 |
+
return SymbolicResult(
|
| 133 |
+
target_function=target_function,
|
| 134 |
+
paths_explored=0,
|
| 135 |
+
vulnerable_paths=[],
|
| 136 |
+
overflow_candidates=[{"error": str(e)}],
|
| 137 |
+
null_deref_candidates=[],
|
| 138 |
+
unchecked_inputs=[],
|
| 139 |
+
tool_used="angr_error",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _ast_analysis(repo_dir: str, target_function: str) -> SymbolicResult:
|
| 144 |
+
"""
|
| 145 |
+
AST-based symbolic path analysis for Python code.
|
| 146 |
+
Finds:
|
| 147 |
+
- Functions that accept user input without validation
|
| 148 |
+
- Integer arithmetic without bounds checks before dangerous operations
|
| 149 |
+
- Format string interpolation of external data
|
| 150 |
+
- Null/None returns used without checks
|
| 151 |
+
"""
|
| 152 |
+
import glob
|
| 153 |
+
|
| 154 |
+
vulnerable_paths = []
|
| 155 |
+
unchecked_inputs = []
|
| 156 |
+
overflow_candidates = []
|
| 157 |
+
null_deref_candidates = []
|
| 158 |
+
paths_explored = 0
|
| 159 |
+
|
| 160 |
+
DANGEROUS_CALLS = {
|
| 161 |
+
"eval", "exec", "compile", "subprocess.call", "subprocess.run",
|
| 162 |
+
"os.system", "os.popen", "open", "pickle.loads", "yaml.load",
|
| 163 |
+
"__import__", "getattr", "setattr",
|
| 164 |
+
}
|
| 165 |
+
INPUT_SOURCES = {
|
| 166 |
+
"input", "request.args", "request.form", "request.json",
|
| 167 |
+
"request.data", "sys.argv", "os.environ", "socket.recv",
|
| 168 |
+
"read", "readline", "readlines",
|
| 169 |
+
}
|
| 170 |
+
INT_OPS = {"__add__", "__mul__", "__lshift__", "<<", "+", "*"}
|
| 171 |
+
|
| 172 |
+
for py_file in glob.glob(f"{repo_dir}/**/*.py", recursive=True):
|
| 173 |
+
if "test_" in py_file or "site-packages" in py_file or ".tox" in py_file:
|
| 174 |
+
continue
|
| 175 |
+
try:
|
| 176 |
+
source = open(py_file).read()
|
| 177 |
+
tree = ast.parse(source)
|
| 178 |
+
except Exception:
|
| 179 |
+
continue
|
| 180 |
+
|
| 181 |
+
rel_path = os.path.relpath(py_file, repo_dir)
|
| 182 |
+
|
| 183 |
+
class InputFlowVisitor(ast.NodeVisitor):
|
| 184 |
+
def __init__(self):
|
| 185 |
+
self.tainted_vars = set()
|
| 186 |
+
self.findings = []
|
| 187 |
+
|
| 188 |
+
def visit_Assign(self, node):
|
| 189 |
+
if isinstance(node.value, ast.Call):
|
| 190 |
+
func_name = _get_call_name(node.value)
|
| 191 |
+
if any(src in func_name for src in INPUT_SOURCES):
|
| 192 |
+
for target in node.targets:
|
| 193 |
+
if isinstance(target, ast.Name):
|
| 194 |
+
self.tainted_vars.add(target.id)
|
| 195 |
+
self.generic_visit(node)
|
| 196 |
+
|
| 197 |
+
def visit_Call(self, node):
|
| 198 |
+
func_name = _get_call_name(node)
|
| 199 |
+
if any(danger in func_name for danger in DANGEROUS_CALLS):
|
| 200 |
+
for arg in node.args:
|
| 201 |
+
if isinstance(arg, ast.Name) and arg.id in self.tainted_vars:
|
| 202 |
+
self.findings.append({
|
| 203 |
+
"type": "tainted_input_to_dangerous_sink",
|
| 204 |
+
"sink": func_name,
|
| 205 |
+
"variable": arg.id,
|
| 206 |
+
"line": node.lineno,
|
| 207 |
+
})
|
| 208 |
+
self.generic_visit(node)
|
| 209 |
+
|
| 210 |
+
def _get_call_name(node):
|
| 211 |
+
if isinstance(node.func, ast.Name):
|
| 212 |
+
return node.func.id
|
| 213 |
+
if isinstance(node.func, ast.Attribute):
|
| 214 |
+
return f"{_get_attr_chain(node.func)}"
|
| 215 |
+
return ""
|
| 216 |
+
|
| 217 |
+
def _get_attr_chain(node):
|
| 218 |
+
if isinstance(node, ast.Attribute):
|
| 219 |
+
return f"{_get_attr_chain(node.value)}.{node.attr}"
|
| 220 |
+
if isinstance(node, ast.Name):
|
| 221 |
+
return node.id
|
| 222 |
+
return ""
|
| 223 |
+
|
| 224 |
+
for node in ast.walk(tree):
|
| 225 |
+
paths_explored += 1
|
| 226 |
+
|
| 227 |
+
if isinstance(node, ast.FunctionDef):
|
| 228 |
+
if target_function and target_function not in node.name:
|
| 229 |
+
continue
|
| 230 |
+
|
| 231 |
+
has_validation = False
|
| 232 |
+
has_int_op = False
|
| 233 |
+
accepts_external = False
|
| 234 |
+
|
| 235 |
+
for arg in node.args.args:
|
| 236 |
+
if arg.arg in ("data", "input", "user_input", "buf", "content", "payload", "request"):
|
| 237 |
+
accepts_external = True
|
| 238 |
+
|
| 239 |
+
for child in ast.walk(node):
|
| 240 |
+
if isinstance(child, ast.If):
|
| 241 |
+
has_validation = True
|
| 242 |
+
if isinstance(child, ast.BinOp) and isinstance(child.op, (ast.Add, ast.Mult, ast.LShift)):
|
| 243 |
+
has_int_op = True
|
| 244 |
+
|
| 245 |
+
if accepts_external and not has_validation:
|
| 246 |
+
unchecked_inputs.append({
|
| 247 |
+
"function": node.name,
|
| 248 |
+
"file": rel_path,
|
| 249 |
+
"line": node.lineno,
|
| 250 |
+
"reason": "accepts external input without apparent validation branch",
|
| 251 |
+
})
|
| 252 |
+
|
| 253 |
+
if has_int_op and accepts_external:
|
| 254 |
+
overflow_candidates.append({
|
| 255 |
+
"function": node.name,
|
| 256 |
+
"file": rel_path,
|
| 257 |
+
"line": node.lineno,
|
| 258 |
+
"reason": "integer arithmetic on externally-supplied values without bounds check",
|
| 259 |
+
})
|
| 260 |
+
|
| 261 |
+
if isinstance(node, (ast.BinOp, ast.AugAssign)):
|
| 262 |
+
if isinstance(getattr(node, "op", None), (ast.Add, ast.Mult, ast.LShift)):
|
| 263 |
+
pass
|
| 264 |
+
|
| 265 |
+
visitor = InputFlowVisitor()
|
| 266 |
+
visitor.visit(tree)
|
| 267 |
+
for f in visitor.findings:
|
| 268 |
+
vulnerable_paths.append(SymbolicPath(
|
| 269 |
+
function_name=f.get("sink", "unknown"),
|
| 270 |
+
file_path=rel_path,
|
| 271 |
+
line_start=f.get("line", 0),
|
| 272 |
+
line_end=f.get("line", 0),
|
| 273 |
+
constraint_summary=f.get("type", ""),
|
| 274 |
+
is_vulnerable=True,
|
| 275 |
+
vulnerability_type="unchecked_input",
|
| 276 |
+
confidence=0.7,
|
| 277 |
+
))
|
| 278 |
+
|
| 279 |
+
return SymbolicResult(
|
| 280 |
+
target_function=target_function or "all",
|
| 281 |
+
paths_explored=paths_explored,
|
| 282 |
+
vulnerable_paths=vulnerable_paths,
|
| 283 |
+
overflow_candidates=overflow_candidates,
|
| 284 |
+
null_deref_candidates=null_deref_candidates,
|
| 285 |
+
unchecked_inputs=unchecked_inputs,
|
| 286 |
+
tool_used="ast_analysis",
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _semgrep_symbolic(repo_dir: str) -> dict:
|
| 291 |
+
"""Run semgrep with security-focused rules for additional coverage."""
|
| 292 |
+
try:
|
| 293 |
+
result = subprocess.run(
|
| 294 |
+
["semgrep", "--config", "p/security-audit", "--json",
|
| 295 |
+
"--timeout", "60", "--max-memory", "512", repo_dir],
|
| 296 |
+
capture_output=True, text=True, timeout=90,
|
| 297 |
+
)
|
| 298 |
+
if result.returncode in (0, 1):
|
| 299 |
+
data = json.loads(result.stdout or "{}")
|
| 300 |
+
return {
|
| 301 |
+
"findings": len(data.get("results", [])),
|
| 302 |
+
"results": data.get("results", [])[:10],
|
| 303 |
+
"tool": "semgrep_security_audit",
|
| 304 |
+
}
|
| 305 |
+
except Exception as e:
|
| 306 |
+
pass
|
| 307 |
+
return {"findings": 0, "results": [], "tool": "semgrep_unavailable"}
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def run_symbolic_analysis(repo_dir: str, target_function: str = None) -> dict:
|
| 311 |
+
"""
|
| 312 |
+
Main entry point. Chooses the best available analysis method.
|
| 313 |
+
"""
|
| 314 |
+
binary = _find_binary(repo_dir)
|
| 315 |
+
angr = _try_import_angr()
|
| 316 |
+
|
| 317 |
+
if binary and angr:
|
| 318 |
+
print(f"[SYMBOLIC] Using angr on binary: {binary}")
|
| 319 |
+
result = _angr_analysis(binary, target_function or "main")
|
| 320 |
+
tool_used = "angr"
|
| 321 |
+
else:
|
| 322 |
+
print(f"[SYMBOLIC] Using AST analysis (angr not available or no binary found)")
|
| 323 |
+
result = _ast_analysis(repo_dir, target_function or "")
|
| 324 |
+
tool_used = "ast_analysis"
|
| 325 |
+
|
| 326 |
+
semgrep = _semgrep_symbolic(repo_dir)
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
"tool_used": tool_used,
|
| 330 |
+
"paths_explored": result.paths_explored,
|
| 331 |
+
"vulnerable_paths": [
|
| 332 |
+
{
|
| 333 |
+
"function": p.function_name, "file": p.file_path,
|
| 334 |
+
"line": p.line_start, "type": p.vulnerability_type,
|
| 335 |
+
"confidence": p.confidence, "summary": p.constraint_summary,
|
| 336 |
+
}
|
| 337 |
+
for p in result.vulnerable_paths[:10]
|
| 338 |
+
],
|
| 339 |
+
"overflow_candidates": result.overflow_candidates[:5],
|
| 340 |
+
"unchecked_inputs": result.unchecked_inputs[:10],
|
| 341 |
+
"null_deref_candidates": result.null_deref_candidates[:5],
|
| 342 |
+
"semgrep_findings": semgrep.get("findings", 0),
|
| 343 |
+
"semgrep_results": semgrep.get("results", [])[:5],
|
| 344 |
+
"total_issues": (
|
| 345 |
+
len(result.vulnerable_paths)
|
| 346 |
+
+ len(result.overflow_candidates)
|
| 347 |
+
+ len(result.unchecked_inputs)
|
| 348 |
+
+ semgrep.get("findings", 0)
|
| 349 |
+
),
|
| 350 |
+
}
|
taint_analyzer.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Taint Analysis Engine
|
| 3 |
+
=====================================
|
| 4 |
+
Tracks untrusted input as it flows through source code to dangerous sinks.
|
| 5 |
+
Language-agnostic: Python (AST), JS/TS (regex+AST heuristics), Go (grep patterns).
|
| 6 |
+
|
| 7 |
+
Also exposes map_attack_surface() used by Hermes recon phase.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import ast
|
| 13 |
+
import glob
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import subprocess
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class TaintFlow:
|
| 24 |
+
source: str # where untrusted input enters
|
| 25 |
+
sink: str # dangerous function it reaches
|
| 26 |
+
path: list[str] # chain of variable names/calls
|
| 27 |
+
file_path: str
|
| 28 |
+
source_line: int
|
| 29 |
+
sink_line: int
|
| 30 |
+
cwe_candidates: list[str]
|
| 31 |
+
confidence: float
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class AttackSurface:
|
| 36 |
+
entry_points: list[dict] # functions reachable from network/user
|
| 37 |
+
dangerous_sinks: list[dict] # calls to exec/eval/open/format etc
|
| 38 |
+
security_critical_files: list[str]
|
| 39 |
+
external_dependencies: list[str]
|
| 40 |
+
crypto_operations: list[dict]
|
| 41 |
+
authentication_flows: list[dict]
|
| 42 |
+
deserialization_points: list[dict]
|
| 43 |
+
language: str
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
_PYTHON_SOURCES = {
|
| 47 |
+
"input", "sys.argv", "os.environ.get", "os.getenv",
|
| 48 |
+
"request.args.get", "request.form.get", "request.json",
|
| 49 |
+
"request.data", "request.body", "request.POST", "request.GET",
|
| 50 |
+
"socket.recv", "socket.recvfrom", "read", "readline",
|
| 51 |
+
"json.loads", "urllib.parse.parse_qs", "flask.request",
|
| 52 |
+
"django.request", "fastapi.Body", "fastapi.Query",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
_PYTHON_SINKS = {
|
| 56 |
+
"eval": "CWE-95",
|
| 57 |
+
"exec": "CWE-95",
|
| 58 |
+
"compile": "CWE-95",
|
| 59 |
+
"os.system": "CWE-78",
|
| 60 |
+
"os.popen": "CWE-78",
|
| 61 |
+
"subprocess.call": "CWE-78",
|
| 62 |
+
"subprocess.run": "CWE-78",
|
| 63 |
+
"subprocess.Popen": "CWE-78",
|
| 64 |
+
"open": "CWE-73",
|
| 65 |
+
"pickle.loads": "CWE-502",
|
| 66 |
+
"pickle.load": "CWE-502",
|
| 67 |
+
"yaml.load": "CWE-502",
|
| 68 |
+
"marshal.loads": "CWE-502",
|
| 69 |
+
"shelve.open": "CWE-502",
|
| 70 |
+
"__import__": "CWE-95",
|
| 71 |
+
"getattr": "CWE-913",
|
| 72 |
+
"setattr": "CWE-913",
|
| 73 |
+
"format": "CWE-134",
|
| 74 |
+
"% s": "CWE-134",
|
| 75 |
+
"cursor.execute": "CWE-89",
|
| 76 |
+
"raw_input": "CWE-20",
|
| 77 |
+
"render_template_string": "CWE-94",
|
| 78 |
+
"send_file": "CWE-73",
|
| 79 |
+
"redirect": "CWE-601",
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
_JS_SINKS = {
|
| 83 |
+
r"eval\s*\(": "CWE-95",
|
| 84 |
+
r"new\s+Function\s*\(": "CWE-95",
|
| 85 |
+
r"child_process\.exec\s*\(": "CWE-78",
|
| 86 |
+
r"execSync\s*\(": "CWE-78",
|
| 87 |
+
r"innerHTML\s*=": "CWE-79",
|
| 88 |
+
r"document\.write\s*\(": "CWE-79",
|
| 89 |
+
r"dangerouslySetInnerHTML": "CWE-79",
|
| 90 |
+
r"__proto__": "CWE-1321",
|
| 91 |
+
r"res\.redirect\s*\(": "CWE-601",
|
| 92 |
+
r"require\s*\(\s*req\b": "CWE-706",
|
| 93 |
+
r"fs\.readFile\s*\(": "CWE-73",
|
| 94 |
+
r"path\.join\s*\(": "CWE-22",
|
| 95 |
+
r"serialize\s*\(": "CWE-502",
|
| 96 |
+
r"deserialize\s*\(": "CWE-502",
|
| 97 |
+
r"\.query\s*\(": "CWE-89",
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
_SECURITY_FILE_PATTERNS = [
|
| 101 |
+
"auth", "login", "password", "token", "session", "crypto",
|
| 102 |
+
"cipher", "ssl", "tls", "secret", "key", "permission", "acl",
|
| 103 |
+
"admin", "sudo", "privilege", "sql", "query", "database",
|
| 104 |
+
"upload", "download", "file", "path", "directory",
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def map_attack_surface(repo_dir: str) -> dict:
|
| 109 |
+
"""
|
| 110 |
+
Comprehensive attack surface mapping — used by Hermes recon phase.
|
| 111 |
+
Returns structured data about entry points, sinks, and security-critical areas.
|
| 112 |
+
"""
|
| 113 |
+
entry_points = []
|
| 114 |
+
dangerous_sinks = []
|
| 115 |
+
security_files = []
|
| 116 |
+
crypto_ops = []
|
| 117 |
+
auth_flows = []
|
| 118 |
+
deser_points = []
|
| 119 |
+
ext_deps = []
|
| 120 |
+
|
| 121 |
+
# --- Python files ---
|
| 122 |
+
for py_file in glob.glob(f"{repo_dir}/**/*.py", recursive=True):
|
| 123 |
+
if "site-packages" in py_file or ".tox" in py_file or "node_modules" in py_file:
|
| 124 |
+
continue
|
| 125 |
+
rel = os.path.relpath(py_file, repo_dir)
|
| 126 |
+
|
| 127 |
+
if any(p in rel.lower() for p in _SECURITY_FILE_PATTERNS):
|
| 128 |
+
security_files.append(rel)
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
source = open(py_file).read()
|
| 132 |
+
lines = source.splitlines()
|
| 133 |
+
except Exception:
|
| 134 |
+
continue
|
| 135 |
+
|
| 136 |
+
for i, line in enumerate(lines, 1):
|
| 137 |
+
stripped = line.strip()
|
| 138 |
+
|
| 139 |
+
for sink, cwe in _PYTHON_SINKS.items():
|
| 140 |
+
if sink in stripped and not stripped.startswith("#"):
|
| 141 |
+
dangerous_sinks.append({
|
| 142 |
+
"file": rel, "line": i,
|
| 143 |
+
"sink": sink, "cwe": cwe,
|
| 144 |
+
"snippet": stripped[:120],
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
if any(src in stripped for src in ["@app.route", "@router.", "def get(", "def post(", "def put(", "def delete(", "async def "]):
|
| 148 |
+
entry_points.append({"file": rel, "line": i, "snippet": stripped[:100]})
|
| 149 |
+
|
| 150 |
+
if any(kw in stripped.lower() for kw in ["hashlib", "hmac", "aes", "rsa", "des", "md5", "sha1", "random.random()"]):
|
| 151 |
+
crypto_ops.append({"file": rel, "line": i, "snippet": stripped[:100]})
|
| 152 |
+
if "md5" in stripped.lower() or "sha1" in stripped.lower() or "random.random" in stripped.lower():
|
| 153 |
+
crypto_ops[-1]["warning"] = "weak_crypto"
|
| 154 |
+
|
| 155 |
+
if any(kw in stripped.lower() for kw in ["pickle.load", "yaml.load(", "marshal.load", "jsonpickle"]):
|
| 156 |
+
deser_points.append({"file": rel, "line": i, "snippet": stripped[:100]})
|
| 157 |
+
|
| 158 |
+
# --- JS/TS files ---
|
| 159 |
+
for js_file in glob.glob(f"{repo_dir}/**/*.js", recursive=True):
|
| 160 |
+
if "node_modules" in js_file or ".min." in js_file:
|
| 161 |
+
continue
|
| 162 |
+
rel = os.path.relpath(js_file, repo_dir)
|
| 163 |
+
if any(p in rel.lower() for p in _SECURITY_FILE_PATTERNS):
|
| 164 |
+
security_files.append(rel)
|
| 165 |
+
try:
|
| 166 |
+
source = open(js_file).read()
|
| 167 |
+
lines = source.splitlines()
|
| 168 |
+
except Exception:
|
| 169 |
+
continue
|
| 170 |
+
|
| 171 |
+
for i, line in enumerate(lines, 1):
|
| 172 |
+
for pattern, cwe in _JS_SINKS.items():
|
| 173 |
+
if re.search(pattern, line):
|
| 174 |
+
dangerous_sinks.append({
|
| 175 |
+
"file": rel, "line": i,
|
| 176 |
+
"sink": pattern[:30], "cwe": cwe,
|
| 177 |
+
"snippet": line.strip()[:120],
|
| 178 |
+
})
|
| 179 |
+
|
| 180 |
+
# --- Dependencies ---
|
| 181 |
+
for req_file in ["requirements.txt", "package.json", "go.mod", "Gemfile", "Cargo.toml"]:
|
| 182 |
+
fpath = os.path.join(repo_dir, req_file)
|
| 183 |
+
if os.path.exists(fpath):
|
| 184 |
+
try:
|
| 185 |
+
content = open(fpath).read()
|
| 186 |
+
if req_file == "package.json":
|
| 187 |
+
data = json.loads(content)
|
| 188 |
+
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
| 189 |
+
ext_deps.extend(list(deps.keys())[:30])
|
| 190 |
+
else:
|
| 191 |
+
ext_deps.extend([l.strip().split("==")[0].split(">=")[0]
|
| 192 |
+
for l in content.splitlines() if l.strip() and not l.startswith("#")][:30])
|
| 193 |
+
except Exception:
|
| 194 |
+
pass
|
| 195 |
+
|
| 196 |
+
return {
|
| 197 |
+
"entry_points": entry_points[:30],
|
| 198 |
+
"dangerous_sinks": dangerous_sinks[:50],
|
| 199 |
+
"security_critical_files": list(set(security_files))[:20],
|
| 200 |
+
"external_dependencies": ext_deps[:30],
|
| 201 |
+
"crypto_operations": crypto_ops[:15],
|
| 202 |
+
"authentication_flows": auth_flows[:10],
|
| 203 |
+
"deserialization_points": deser_points[:10],
|
| 204 |
+
"summary": {
|
| 205 |
+
"entry_points": len(entry_points),
|
| 206 |
+
"dangerous_sinks": len(dangerous_sinks),
|
| 207 |
+
"security_files": len(set(security_files)),
|
| 208 |
+
"deser_risks": len(deser_points),
|
| 209 |
+
"crypto_issues": sum(1 for c in crypto_ops if c.get("warning")),
|
| 210 |
+
},
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def run_taint_analysis(repo_dir: str, focus_files: list[str] = None) -> dict:
|
| 215 |
+
"""
|
| 216 |
+
Full taint analysis: find flows from sources to sinks.
|
| 217 |
+
Returns a list of confirmed taint flows with CWE classification.
|
| 218 |
+
"""
|
| 219 |
+
flows = []
|
| 220 |
+
|
| 221 |
+
search_files = []
|
| 222 |
+
if focus_files:
|
| 223 |
+
search_files = [os.path.join(repo_dir, f) for f in focus_files if f.endswith(".py")]
|
| 224 |
+
else:
|
| 225 |
+
search_files = glob.glob(f"{repo_dir}/**/*.py", recursive=True)
|
| 226 |
+
search_files = [f for f in search_files
|
| 227 |
+
if "site-packages" not in f and ".tox" not in f and "test_" not in f]
|
| 228 |
+
|
| 229 |
+
for py_file in search_files[:50]:
|
| 230 |
+
rel = os.path.relpath(py_file, repo_dir)
|
| 231 |
+
try:
|
| 232 |
+
source = open(py_file).read()
|
| 233 |
+
tree = ast.parse(source)
|
| 234 |
+
except Exception:
|
| 235 |
+
continue
|
| 236 |
+
|
| 237 |
+
tainted_vars: dict[str, int] = {}
|
| 238 |
+
lines = source.splitlines()
|
| 239 |
+
|
| 240 |
+
for node in ast.walk(tree):
|
| 241 |
+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
|
| 242 |
+
func_str = _node_to_str(node.value.func)
|
| 243 |
+
if any(src in func_str for src in _PYTHON_SOURCES):
|
| 244 |
+
for target in node.targets:
|
| 245 |
+
if isinstance(target, ast.Name):
|
| 246 |
+
tainted_vars[target.id] = node.lineno
|
| 247 |
+
|
| 248 |
+
if isinstance(node, ast.Call):
|
| 249 |
+
func_str = _node_to_str(node.func)
|
| 250 |
+
matched_sink = next((s for s in _PYTHON_SINKS if s in func_str), None)
|
| 251 |
+
if matched_sink:
|
| 252 |
+
for arg in node.args:
|
| 253 |
+
arg_str = _node_to_str(arg)
|
| 254 |
+
if isinstance(arg, ast.Name) and arg.id in tainted_vars:
|
| 255 |
+
flows.append(TaintFlow(
|
| 256 |
+
source=f"tainted_var:{arg.id}",
|
| 257 |
+
sink=matched_sink,
|
| 258 |
+
path=[arg.id, matched_sink],
|
| 259 |
+
file_path=rel,
|
| 260 |
+
source_line=tainted_vars[arg.id],
|
| 261 |
+
sink_line=node.lineno,
|
| 262 |
+
cwe_candidates=[_PYTHON_SINKS[matched_sink]],
|
| 263 |
+
confidence=0.8,
|
| 264 |
+
))
|
| 265 |
+
elif isinstance(arg, ast.JoinedStr):
|
| 266 |
+
for val in ast.walk(arg):
|
| 267 |
+
if isinstance(val, ast.Name) and val.id in tainted_vars:
|
| 268 |
+
flows.append(TaintFlow(
|
| 269 |
+
source=f"fstring:{val.id}",
|
| 270 |
+
sink=matched_sink,
|
| 271 |
+
path=[val.id, "f-string", matched_sink],
|
| 272 |
+
file_path=rel,
|
| 273 |
+
source_line=tainted_vars[val.id],
|
| 274 |
+
sink_line=node.lineno,
|
| 275 |
+
cwe_candidates=[_PYTHON_SINKS[matched_sink], "CWE-134"],
|
| 276 |
+
confidence=0.7,
|
| 277 |
+
))
|
| 278 |
+
|
| 279 |
+
return {
|
| 280 |
+
"flows_found": len(flows),
|
| 281 |
+
"flows": [
|
| 282 |
+
{
|
| 283 |
+
"source": f.source, "sink": f.sink,
|
| 284 |
+
"file": f.file_path,
|
| 285 |
+
"source_line": f.source_line, "sink_line": f.sink_line,
|
| 286 |
+
"cwes": f.cwe_candidates, "confidence": f.confidence,
|
| 287 |
+
"path": " → ".join(f.path),
|
| 288 |
+
}
|
| 289 |
+
for f in sorted(flows, key=lambda x: x.confidence, reverse=True)[:20]
|
| 290 |
+
],
|
| 291 |
+
"high_confidence_flows": sum(1 for f in flows if f.confidence >= 0.75),
|
| 292 |
+
"unique_cwes": list(set(cwe for f in flows for cwe in f.cwe_candidates)),
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _node_to_str(node) -> str:
|
| 297 |
+
try:
|
| 298 |
+
if isinstance(node, ast.Name):
|
| 299 |
+
return node.id
|
| 300 |
+
if isinstance(node, ast.Attribute):
|
| 301 |
+
return f"{_node_to_str(node.value)}.{node.attr}"
|
| 302 |
+
return ""
|
| 303 |
+
except Exception:
|
| 304 |
+
return ""
|
vuln_classifier.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rhodawk AI — Vulnerability Classifier
|
| 3 |
+
========================================
|
| 4 |
+
CWE taxonomy-based classification of raw findings.
|
| 5 |
+
Maps evidence → CWE → CVSS vector → severity tier.
|
| 6 |
+
|
| 7 |
+
Also computes the final composite security score used in dashboards.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class ClassificationResult:
|
| 19 |
+
cwe_id: str
|
| 20 |
+
cwe_name: str
|
| 21 |
+
cwe_category: str
|
| 22 |
+
owasp_top10: Optional[str]
|
| 23 |
+
severity: str
|
| 24 |
+
cvss_base_score: float
|
| 25 |
+
cvss_vector: str
|
| 26 |
+
exploitation_likelihood: str # LIKELY | POSSIBLE | UNLIKELY
|
| 27 |
+
remediation_guidance: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
_CWE_DATABASE = {
|
| 31 |
+
"CWE-89": {
|
| 32 |
+
"name": "SQL Injection",
|
| 33 |
+
"category": "Injection",
|
| 34 |
+
"owasp": "A03:2021-Injection",
|
| 35 |
+
"severity": "CRITICAL",
|
| 36 |
+
"cvss_base": 9.8,
|
| 37 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
| 38 |
+
"likelihood": "LIKELY",
|
| 39 |
+
"remediation": "Use parameterized queries/prepared statements. Never concatenate user input into SQL strings.",
|
| 40 |
+
},
|
| 41 |
+
"CWE-79": {
|
| 42 |
+
"name": "Cross-Site Scripting (XSS)",
|
| 43 |
+
"category": "Injection",
|
| 44 |
+
"owasp": "A03:2021-Injection",
|
| 45 |
+
"severity": "HIGH",
|
| 46 |
+
"cvss_base": 6.1,
|
| 47 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
|
| 48 |
+
"likelihood": "LIKELY",
|
| 49 |
+
"remediation": "Escape all output. Use Content Security Policy. Validate and sanitize inputs.",
|
| 50 |
+
},
|
| 51 |
+
"CWE-78": {
|
| 52 |
+
"name": "OS Command Injection",
|
| 53 |
+
"category": "Injection",
|
| 54 |
+
"owasp": "A03:2021-Injection",
|
| 55 |
+
"severity": "CRITICAL",
|
| 56 |
+
"cvss_base": 9.8,
|
| 57 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
| 58 |
+
"likelihood": "LIKELY",
|
| 59 |
+
"remediation": "Never pass user input to shell commands. Use subprocess with list arguments (shell=False).",
|
| 60 |
+
},
|
| 61 |
+
"CWE-22": {
|
| 62 |
+
"name": "Path Traversal",
|
| 63 |
+
"category": "File Handling",
|
| 64 |
+
"owasp": "A01:2021-Broken Access Control",
|
| 65 |
+
"severity": "HIGH",
|
| 66 |
+
"cvss_base": 7.5,
|
| 67 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
| 68 |
+
"likelihood": "LIKELY",
|
| 69 |
+
"remediation": "Canonicalize paths. Validate against allowed directory. Use os.path.realpath and check prefix.",
|
| 70 |
+
},
|
| 71 |
+
"CWE-502": {
|
| 72 |
+
"name": "Deserialization of Untrusted Data",
|
| 73 |
+
"category": "Deserialization",
|
| 74 |
+
"owasp": "A08:2021-Software and Data Integrity Failures",
|
| 75 |
+
"severity": "CRITICAL",
|
| 76 |
+
"cvss_base": 9.8,
|
| 77 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
| 78 |
+
"likelihood": "LIKELY",
|
| 79 |
+
"remediation": "Never deserialize user-controlled data with pickle/yaml.load/marshal. Use JSON with strict schema validation.",
|
| 80 |
+
},
|
| 81 |
+
"CWE-798": {
|
| 82 |
+
"name": "Hardcoded Credentials",
|
| 83 |
+
"category": "Authentication",
|
| 84 |
+
"owasp": "A07:2021-Identification and Authentication Failures",
|
| 85 |
+
"severity": "HIGH",
|
| 86 |
+
"cvss_base": 7.5,
|
| 87 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
| 88 |
+
"likelihood": "LIKELY",
|
| 89 |
+
"remediation": "Move credentials to environment variables or secrets managers. Rotate immediately if exposed.",
|
| 90 |
+
},
|
| 91 |
+
"CWE-918": {
|
| 92 |
+
"name": "Server-Side Request Forgery (SSRF)",
|
| 93 |
+
"category": "Injection",
|
| 94 |
+
"owasp": "A10:2021-SSRF",
|
| 95 |
+
"severity": "HIGH",
|
| 96 |
+
"cvss_base": 7.5,
|
| 97 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
| 98 |
+
"likelihood": "POSSIBLE",
|
| 99 |
+
"remediation": "Validate and allowlist URLs. Block requests to internal networks. Use a URL parser, not string matching.",
|
| 100 |
+
},
|
| 101 |
+
"CWE-611": {
|
| 102 |
+
"name": "XML External Entity (XXE)",
|
| 103 |
+
"category": "Injection",
|
| 104 |
+
"owasp": "A05:2021-Security Misconfiguration",
|
| 105 |
+
"severity": "HIGH",
|
| 106 |
+
"cvss_base": 7.5,
|
| 107 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
| 108 |
+
"likelihood": "POSSIBLE",
|
| 109 |
+
"remediation": "Use defusedxml. Disable external entity processing in XML parsers.",
|
| 110 |
+
},
|
| 111 |
+
"CWE-295": {
|
| 112 |
+
"name": "Improper Certificate Validation",
|
| 113 |
+
"category": "Cryptography",
|
| 114 |
+
"owasp": "A02:2021-Cryptographic Failures",
|
| 115 |
+
"severity": "HIGH",
|
| 116 |
+
"cvss_base": 7.4,
|
| 117 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
|
| 118 |
+
"likelihood": "POSSIBLE",
|
| 119 |
+
"remediation": "Never set verify=False. Use proper CA bundles. Enable certificate pinning for critical connections.",
|
| 120 |
+
},
|
| 121 |
+
"CWE-347": {
|
| 122 |
+
"name": "Improper Verification of Cryptographic Signature",
|
| 123 |
+
"category": "Cryptography",
|
| 124 |
+
"owasp": "A02:2021-Cryptographic Failures",
|
| 125 |
+
"severity": "CRITICAL",
|
| 126 |
+
"cvss_base": 9.8,
|
| 127 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
| 128 |
+
"likelihood": "LIKELY",
|
| 129 |
+
"remediation": "Always verify JWT signatures. Never accept 'none' algorithm. Use explicit algorithm allowlists.",
|
| 130 |
+
},
|
| 131 |
+
"CWE-362": {
|
| 132 |
+
"name": "Race Condition",
|
| 133 |
+
"category": "Concurrency",
|
| 134 |
+
"owasp": None,
|
| 135 |
+
"severity": "MEDIUM",
|
| 136 |
+
"cvss_base": 5.9,
|
| 137 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N",
|
| 138 |
+
"likelihood": "POSSIBLE",
|
| 139 |
+
"remediation": "Use locks/mutexes for shared state. Prefer atomic operations. Use thread-safe data structures.",
|
| 140 |
+
},
|
| 141 |
+
"CWE-190": {
|
| 142 |
+
"name": "Integer Overflow",
|
| 143 |
+
"category": "Memory",
|
| 144 |
+
"owasp": None,
|
| 145 |
+
"severity": "HIGH",
|
| 146 |
+
"cvss_base": 7.5,
|
| 147 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H",
|
| 148 |
+
"likelihood": "POSSIBLE",
|
| 149 |
+
"remediation": "Validate numeric ranges before arithmetic. Use checked arithmetic libraries.",
|
| 150 |
+
},
|
| 151 |
+
"CWE-119": {
|
| 152 |
+
"name": "Buffer Overflow",
|
| 153 |
+
"category": "Memory",
|
| 154 |
+
"owasp": None,
|
| 155 |
+
"severity": "CRITICAL",
|
| 156 |
+
"cvss_base": 9.8,
|
| 157 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
| 158 |
+
"likelihood": "POSSIBLE",
|
| 159 |
+
"remediation": "Use bounds-checked functions. Enable stack canaries and ASLR. Use memory-safe languages.",
|
| 160 |
+
},
|
| 161 |
+
"CWE-416": {
|
| 162 |
+
"name": "Use After Free",
|
| 163 |
+
"category": "Memory",
|
| 164 |
+
"owasp": None,
|
| 165 |
+
"severity": "CRITICAL",
|
| 166 |
+
"cvss_base": 9.8,
|
| 167 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
| 168 |
+
"likelihood": "POSSIBLE",
|
| 169 |
+
"remediation": "Set pointers to NULL after free. Use smart pointers (Rust/C++). Enable AddressSanitizer in CI.",
|
| 170 |
+
},
|
| 171 |
+
"CWE-134": {
|
| 172 |
+
"name": "Format String Vulnerability",
|
| 173 |
+
"category": "Injection",
|
| 174 |
+
"owasp": "A03:2021-Injection",
|
| 175 |
+
"severity": "HIGH",
|
| 176 |
+
"cvss_base": 8.1,
|
| 177 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
|
| 178 |
+
"likelihood": "POSSIBLE",
|
| 179 |
+
"remediation": "Never pass user input as printf format string. Use explicit format specifiers.",
|
| 180 |
+
},
|
| 181 |
+
"CWE-95": {
|
| 182 |
+
"name": "Code Injection (eval/exec)",
|
| 183 |
+
"category": "Injection",
|
| 184 |
+
"owasp": "A03:2021-Injection",
|
| 185 |
+
"severity": "CRITICAL",
|
| 186 |
+
"cvss_base": 9.8,
|
| 187 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
| 188 |
+
"likelihood": "LIKELY",
|
| 189 |
+
"remediation": "Never use eval/exec on user-controlled data. Use AST parsing or safe expression evaluators.",
|
| 190 |
+
},
|
| 191 |
+
"CWE-200": {
|
| 192 |
+
"name": "Information Exposure",
|
| 193 |
+
"category": "Information Disclosure",
|
| 194 |
+
"owasp": "A01:2021-Broken Access Control",
|
| 195 |
+
"severity": "MEDIUM",
|
| 196 |
+
"cvss_base": 5.3,
|
| 197 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
|
| 198 |
+
"likelihood": "LIKELY",
|
| 199 |
+
"remediation": "Strip sensitive data from error messages. Log minimally. Use generic error responses.",
|
| 200 |
+
},
|
| 201 |
+
"CWE-338": {
|
| 202 |
+
"name": "Weak PRNG for Security",
|
| 203 |
+
"category": "Cryptography",
|
| 204 |
+
"owasp": "A02:2021-Cryptographic Failures",
|
| 205 |
+
"severity": "MEDIUM",
|
| 206 |
+
"cvss_base": 5.9,
|
| 207 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
| 208 |
+
"likelihood": "POSSIBLE",
|
| 209 |
+
"remediation": "Use secrets.token_hex() or os.urandom() for security contexts. Never use random.random() for tokens.",
|
| 210 |
+
},
|
| 211 |
+
"CWE-328": {
|
| 212 |
+
"name": "Weak Cryptographic Hash",
|
| 213 |
+
"category": "Cryptography",
|
| 214 |
+
"owasp": "A02:2021-Cryptographic Failures",
|
| 215 |
+
"severity": "MEDIUM",
|
| 216 |
+
"cvss_base": 5.9,
|
| 217 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
| 218 |
+
"likelihood": "POSSIBLE",
|
| 219 |
+
"remediation": "Replace MD5/SHA1 with SHA-256 or better. For passwords use bcrypt/argon2/scrypt.",
|
| 220 |
+
},
|
| 221 |
+
"CWE-400": {
|
| 222 |
+
"name": "Uncontrolled Resource Consumption (DoS)",
|
| 223 |
+
"category": "Availability",
|
| 224 |
+
"owasp": None,
|
| 225 |
+
"severity": "HIGH",
|
| 226 |
+
"cvss_base": 7.5,
|
| 227 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
|
| 228 |
+
"likelihood": "LIKELY",
|
| 229 |
+
"remediation": "Implement rate limiting. Set resource limits (memory, CPU, time). Validate input sizes.",
|
| 230 |
+
},
|
| 231 |
+
"CWE-1321": {
|
| 232 |
+
"name": "Prototype Pollution",
|
| 233 |
+
"category": "Injection",
|
| 234 |
+
"owasp": "A03:2021-Injection",
|
| 235 |
+
"severity": "HIGH",
|
| 236 |
+
"cvss_base": 7.5,
|
| 237 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L",
|
| 238 |
+
"likelihood": "POSSIBLE",
|
| 239 |
+
"remediation": "Use Object.create(null) for object maps. Validate object keys. Use Map instead of plain objects.",
|
| 240 |
+
},
|
| 241 |
+
"CWE-601": {
|
| 242 |
+
"name": "Open Redirect",
|
| 243 |
+
"category": "URL Handling",
|
| 244 |
+
"owasp": "A01:2021-Broken Access Control",
|
| 245 |
+
"severity": "MEDIUM",
|
| 246 |
+
"cvss_base": 6.1,
|
| 247 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
|
| 248 |
+
"likelihood": "LIKELY",
|
| 249 |
+
"remediation": "Validate redirect URLs against an allowlist. Never redirect to user-supplied arbitrary URLs.",
|
| 250 |
+
},
|
| 251 |
+
"CWE-306": {
|
| 252 |
+
"name": "Missing Authentication",
|
| 253 |
+
"category": "Authentication",
|
| 254 |
+
"owasp": "A07:2021-Identification and Authentication Failures",
|
| 255 |
+
"severity": "CRITICAL",
|
| 256 |
+
"cvss_base": 9.1,
|
| 257 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
|
| 258 |
+
"likelihood": "LIKELY",
|
| 259 |
+
"remediation": "Require authentication on all sensitive endpoints. Use middleware to enforce auth globally.",
|
| 260 |
+
},
|
| 261 |
+
"CWE-74": {
|
| 262 |
+
"name": "Injection (generic)",
|
| 263 |
+
"category": "Injection",
|
| 264 |
+
"owasp": "A03:2021-Injection",
|
| 265 |
+
"severity": "HIGH",
|
| 266 |
+
"cvss_base": 7.5,
|
| 267 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
|
| 268 |
+
"likelihood": "POSSIBLE",
|
| 269 |
+
"remediation": "Validate, escape, and parameterize all user input that reaches interpreters.",
|
| 270 |
+
},
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def classify_vulnerability(
|
| 275 |
+
cwe_hint: str,
|
| 276 |
+
description: str = "",
|
| 277 |
+
exploit_class: str = "",
|
| 278 |
+
) -> ClassificationResult:
|
| 279 |
+
"""
|
| 280 |
+
Classify a vulnerability by CWE ID, returning full taxonomy information.
|
| 281 |
+
Falls back to heuristic matching from description if CWE is unknown.
|
| 282 |
+
"""
|
| 283 |
+
cwe_data = _CWE_DATABASE.get(cwe_hint)
|
| 284 |
+
|
| 285 |
+
if not cwe_data:
|
| 286 |
+
cwe_hint, cwe_data = _infer_cwe(description, exploit_class)
|
| 287 |
+
|
| 288 |
+
if not cwe_data:
|
| 289 |
+
cwe_hint = "CWE-UNKNOWN"
|
| 290 |
+
cwe_data = {
|
| 291 |
+
"name": "Unknown Vulnerability", "category": "Unclassified",
|
| 292 |
+
"owasp": None, "severity": "MEDIUM", "cvss_base": 5.0,
|
| 293 |
+
"cvss_vector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N",
|
| 294 |
+
"likelihood": "POSSIBLE",
|
| 295 |
+
"remediation": "Manual security review required.",
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return ClassificationResult(
|
| 299 |
+
cwe_id=cwe_hint,
|
| 300 |
+
cwe_name=cwe_data["name"],
|
| 301 |
+
cwe_category=cwe_data["category"],
|
| 302 |
+
owasp_top10=cwe_data.get("owasp"),
|
| 303 |
+
severity=cwe_data["severity"],
|
| 304 |
+
cvss_base_score=cwe_data["cvss_base"],
|
| 305 |
+
cvss_vector=cwe_data["cvss_vector"],
|
| 306 |
+
exploitation_likelihood=cwe_data["likelihood"],
|
| 307 |
+
remediation_guidance=cwe_data["remediation"],
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _infer_cwe(description: str, exploit_class: str) -> tuple[str, Optional[dict]]:
|
| 312 |
+
"""Infer CWE from description keywords when CWE ID is not provided."""
|
| 313 |
+
desc_lower = (description + " " + exploit_class).lower()
|
| 314 |
+
|
| 315 |
+
inference_rules = [
|
| 316 |
+
(["sql", "query injection", "database injection"], "CWE-89"),
|
| 317 |
+
(["xss", "cross-site scripting", "innerhtml"], "CWE-79"),
|
| 318 |
+
(["command injection", "os.system", "shell injection"], "CWE-78"),
|
| 319 |
+
(["path traversal", "directory traversal", "../"], "CWE-22"),
|
| 320 |
+
(["pickle", "deserializ", "yaml.load"], "CWE-502"),
|
| 321 |
+
(["hardcoded", "hardcoded credential", "hardcoded password"], "CWE-798"),
|
| 322 |
+
(["ssrf", "server-side request forgery"], "CWE-918"),
|
| 323 |
+
(["xxe", "xml external entity"], "CWE-611"),
|
| 324 |
+
(["tls", "ssl", "certificate verification"], "CWE-295"),
|
| 325 |
+
(["jwt", "token forgery", "signature bypass"], "CWE-347"),
|
| 326 |
+
(["race condition", "toctou"], "CWE-362"),
|
| 327 |
+
(["integer overflow", "arithmetic overflow"], "CWE-190"),
|
| 328 |
+
(["buffer overflow", "stack overflow", "heap overflow"], "CWE-119"),
|
| 329 |
+
(["use after free", "uaf"], "CWE-416"),
|
| 330 |
+
(["format string"], "CWE-134"),
|
| 331 |
+
(["eval", "exec", "code injection"], "CWE-95"),
|
| 332 |
+
(["prototype pollution", "__proto__"], "CWE-1321"),
|
| 333 |
+
(["open redirect", "redirect"], "CWE-601"),
|
| 334 |
+
(["missing auth", "unauthenticated"], "CWE-306"),
|
| 335 |
+
(["weak hash", "md5", "sha1"], "CWE-328"),
|
| 336 |
+
(["weak random", "math.random", "random.random"], "CWE-338"),
|
| 337 |
+
(["denial of service", "dos", "resource exhaustion"], "CWE-400"),
|
| 338 |
+
(["information disclosure", "data leak", "sensitive data"], "CWE-200"),
|
| 339 |
+
]
|
| 340 |
+
|
| 341 |
+
for keywords, cwe in inference_rules:
|
| 342 |
+
if any(kw in desc_lower for kw in keywords):
|
| 343 |
+
return cwe, _CWE_DATABASE.get(cwe)
|
| 344 |
+
|
| 345 |
+
return "CWE-UNKNOWN", None
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def get_all_cwes() -> list[dict]:
|
| 349 |
+
"""Return the full CWE database as a list for the UI."""
|
| 350 |
+
return [
|
| 351 |
+
{
|
| 352 |
+
"cwe_id": cwe_id,
|
| 353 |
+
"name": data["name"],
|
| 354 |
+
"category": data["category"],
|
| 355 |
+
"severity": data["severity"],
|
| 356 |
+
"cvss_base": data["cvss_base"],
|
| 357 |
+
"owasp": data.get("owasp", ""),
|
| 358 |
+
}
|
| 359 |
+
for cwe_id, data in _CWE_DATABASE.items()
|
| 360 |
+
]
|