Skip to content

Commit f282a41

Browse files
Add batch processing, html reports, and config
1 parent 6fdc8cc commit f282a41

145 files changed

Lines changed: 16060 additions & 45 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

herald/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""
1+
"""
22
HERALD — API-free phishing domain detection for critical infrastructure.
33
44
Usage:
@@ -7,5 +7,5 @@
77
result = predictor.predict("sbi-login-secure.xyz")
88
"""
99

10-
__version__ = "0.1.0"
10+
__version__ = "0.1.1"
1111
__author__ = "Athiyo Chakma"

herald/cli.py

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from rich.panel import Panel
1313
from rich.progress import Progress, SpinnerColumn, TextColumn
1414
from rich.table import Table
15+
from rich.prompt import Confirm
1516

1617
from herald.core.security import SSRFProtectionError
1718
from herald.investigation.persistence import find_report
@@ -23,7 +24,16 @@
2324

2425
def main(argv: list[str] | None = None) -> int:
2526
parser = build_parser()
26-
args = parser.parse_args(argv)
27+
args, _ = parser.parse_known_args(argv)
28+
29+
if args.version:
30+
console.print(Panel.fit(
31+
f"[bold cyan]HERALD[/bold cyan] v{__version__}\n"
32+
"[dim]Heuristic & Ensemble Risk Assessment for Lookalike Domains[/dim]",
33+
border_style="cyan",
34+
box=box.ROUNDED
35+
))
36+
return 0
2737

2838
if args.command == "investigate":
2939
return run_investigate(args)
@@ -33,14 +43,20 @@ def main(argv: list[str] | None = None) -> int:
3343
return run_screenshot(args)
3444
if args.command == "report":
3545
return run_report(args)
46+
if args.command == "update":
47+
return run_update(args)
48+
if args.command == "config":
49+
return run_config(args)
50+
if args.command == "cleanup":
51+
return run_cleanup(args)
3652

3753
parser.print_help()
3854
return 1
3955

4056

4157
def build_parser() -> argparse.ArgumentParser:
4258
parser = argparse.ArgumentParser(prog="herald", description="HERALD phishing investigation CLI")
43-
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
59+
parser.add_argument("-v", "--version", action="store_true", help="show program's version number and exit")
4460
subparsers = parser.add_subparsers(dest="command")
4561

4662
investigate = subparsers.add_parser("investigate", help="Run a full URL investigation")
@@ -64,18 +80,80 @@ def build_parser() -> argparse.ArgumentParser:
6480
report = subparsers.add_parser("report", help="Show a persisted investigation by trace ID")
6581
report.add_argument("trace_id")
6682
report.add_argument("--json", action="store_true", help="Print machine-readable JSON")
83+
report.add_argument("--open", action="store_true", help="Open the evidence directory in the file explorer")
84+
85+
update = subparsers.add_parser("update", help="Update HERALD Investigator to the latest version")
86+
87+
config = subparsers.add_parser("config", help="Manage HERALD configuration")
88+
config_sub = config.add_subparsers(dest="config_command", required=True)
89+
config_sub.add_parser("show", help="Show current configuration")
90+
config_set = config_sub.add_parser("set", help="Set a configuration value")
91+
config_set.add_argument("key")
92+
config_set.add_argument("value")
93+
94+
cleanup = subparsers.add_parser("cleanup", help="Clean up old evidence directories")
95+
cleanup.add_argument("--older-than", type=int, default=30, help="Delete investigations older than N days (default: 30)")
6796

6897
return parser
6998

7099

71100
def run_investigate(args: argparse.Namespace) -> int:
101+
import os
102+
from herald import config
103+
104+
if os.path.isfile(args.target):
105+
return run_batch_investigate(args)
106+
72107
pipeline = InvestigationPipeline(scorer_type=args.scorer)
108+
109+
do_visual = not args.no_visual
110+
if "screenshot" in config.get_config() and not args.no_visual:
111+
do_visual = config.get("screenshot")
112+
73113
try:
74-
result = execute_pipeline(pipeline, args.target, include_visual=not args.no_visual, allow_private=args.allow_private, quiet=args.json)
114+
result = execute_pipeline(pipeline, args.target, include_visual=do_visual, allow_private=args.allow_private, quiet=args.json)
75115
except SSRFProtectionError as exc:
76116
return emit_ssrf_error(exc, args.target, "investigate", as_json=args.json)
77117
return emit_result(result.to_dict(), as_json=args.json, explain=args.explain)
78118

119+
def run_batch_investigate(args: argparse.Namespace) -> int:
120+
from collections import Counter
121+
pipeline = InvestigationPipeline(scorer_type=args.scorer)
122+
pipeline.engine.preload()
123+
124+
with open(args.target, "r", encoding="utf-8") as f:
125+
targets = [line.strip() for line in f if line.strip() and not line.startswith("#")]
126+
127+
if not targets:
128+
console.print(f"[red]No targets found in {args.target}[/red]")
129+
return 1
130+
131+
console.print(f"[bold cyan]Starting batch investigation of {len(targets)} targets...[/bold cyan]")
132+
133+
stats = Counter()
134+
135+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as progress:
136+
task = progress.add_task(f"Processing 0/{len(targets)}...", total=len(targets))
137+
138+
for i, target in enumerate(targets, 1):
139+
progress.update(task, description=f"Processing {i}/{len(targets)}: {target}")
140+
try:
141+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
142+
res = pipeline.investigate(target, include_visual=not args.no_visual, allow_private=args.allow_private)
143+
stats[res.verdict] += 1
144+
except Exception:
145+
stats["Errors"] += 1
146+
147+
console.print("\n[bold]Batch Investigation Summary[/bold]")
148+
console.print(f"Total processed: {len(targets)}")
149+
console.print(f"Phishing (High Risk): [red]{stats.get('Phishing', 0)}[/red]")
150+
console.print(f"Suspected: [yellow]{stats.get('Suspected', 0)}[/yellow]")
151+
console.print(f"Clean: [green]{stats.get('Clean', 0)}[/green]")
152+
if stats.get("Errors"):
153+
console.print(f"Errors: [bright_black]{stats['Errors']}[/bright_black]")
154+
155+
return 0
156+
79157

80158
def run_analyze(args: argparse.Namespace) -> int:
81159
pipeline = InvestigationPipeline()
@@ -111,9 +189,97 @@ def run_report(args: argparse.Namespace) -> int:
111189
if not report:
112190
console.print(f"[red]No persisted report found for trace ID[/red] {args.trace_id}")
113191
return 1
192+
193+
if args.open:
194+
import subprocess
195+
import os
196+
evidence_dir = report.get("evidence_dir")
197+
if evidence_dir and os.path.exists(evidence_dir):
198+
if sys.platform == "win32":
199+
os.startfile(evidence_dir)
200+
elif sys.platform == "darwin":
201+
subprocess.Popen(["open", evidence_dir])
202+
else:
203+
subprocess.Popen(["xdg-open", evidence_dir])
204+
console.print(f"[green]Opened evidence directory:[/green] {evidence_dir}")
205+
return 0
206+
114207
return emit_result(report, as_json=args.json)
115208

116209

210+
def run_update(args: argparse.Namespace) -> int:
211+
import urllib.request
212+
import subprocess
213+
214+
console.print("[cyan]Checking for HERALD Investigator updates...[/cyan]")
215+
try:
216+
req = urllib.request.Request("https://pypi.org/pypi/herald-investigator/json")
217+
with urllib.request.urlopen(req, timeout=5) as response:
218+
data = json.loads(response.read().decode())
219+
latest_version = data["info"]["version"]
220+
except Exception as e:
221+
console.print(f"[red]Failed to check PyPI for updates:[/red] {e}")
222+
return 1
223+
224+
console.print(f"Current Version: [bold]{__version__}[/bold]")
225+
console.print(f"Latest Version: [bold green]{latest_version}[/bold green]\n")
226+
227+
# Basic string comparison (adequate for simple semver)
228+
if __version__ == latest_version:
229+
console.print("You are already on the latest version!")
230+
return 0
231+
232+
if Confirm.ask("Would you like to install the update now?"):
233+
try:
234+
subprocess.check_call([sys.executable, "-m", "pip", "install", "--upgrade", "herald-investigator"])
235+
console.print("\n[bold green]✅ Update successful![/bold green]")
236+
return 0
237+
except subprocess.CalledProcessError as e:
238+
console.print(f"\n[bold red]❌ Update failed:[/bold red] {e}")
239+
return 1
240+
return 0
241+
242+
243+
def run_config(args: argparse.Namespace) -> int:
244+
from herald import config
245+
if args.config_command == "show":
246+
c = config.get_config()
247+
if not c:
248+
console.print("Configuration is empty.")
249+
else:
250+
for k, v in c.items():
251+
console.print(f"[cyan]{k}[/cyan]: {v}")
252+
elif args.config_command == "set":
253+
config.set_val(args.key, args.value)
254+
console.print(f"[green]Set[/green] {args.key} = {args.value}")
255+
return 0
256+
257+
258+
def run_cleanup(args: argparse.Namespace) -> int:
259+
import time
260+
import shutil
261+
from pathlib import Path
262+
263+
evidence_dir = Path("evidence")
264+
if not evidence_dir.exists():
265+
console.print("No evidence directory found.")
266+
return 0
267+
268+
cutoff = time.time() - (args.older_than * 86400)
269+
count = 0
270+
271+
for path in evidence_dir.iterdir():
272+
if path.is_dir():
273+
json_file = path / "investigation.json"
274+
check_path = json_file if json_file.exists() else path
275+
if check_path.stat().st_mtime < cutoff:
276+
shutil.rmtree(path)
277+
count += 1
278+
279+
console.print(f"[green]Cleanup complete.[/green] Deleted {count} old investigations.")
280+
return 0
281+
282+
117283
def emit_result(result: dict, *, as_json: bool, explain: bool = False) -> int:
118284
if as_json:
119285
console.print_json(json.dumps(result))

herald/investigation/persistence.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ def save_investigation(result: dict[str, Any], evidence_dir: str) -> dict[str, s
1919
base.mkdir(parents=True, exist_ok=True)
2020
json_path = base / "investigation.json"
2121
md_path = base / "report.md"
22-
result.setdefault("visual", {}).setdefault("report_paths", {"json": os.fspath(json_path), "markdown": os.fspath(md_path)})
22+
html_path = base / "report.html"
23+
result.setdefault("visual", {}).setdefault("report_paths", {"json": os.fspath(json_path), "markdown": os.fspath(md_path), "html": os.fspath(html_path)})
2324

2425
json_path.write_text(json.dumps(result, indent=2, sort_keys=True), encoding="utf-8")
2526
md_path.write_text(render_markdown(result), encoding="utf-8")
27+
html_path.write_text(render_html(result), encoding="utf-8")
2628

2729
latest_dir = Path("evidence")
2830
latest_dir.mkdir(exist_ok=True)
@@ -37,7 +39,7 @@ def save_investigation(result: dict[str, Any], evidence_dir: str) -> dict[str, s
3739
"completed_at": result["completed_at"],
3840
}, sort_keys=True) + "\n")
3941

40-
return {"json": os.fspath(json_path), "markdown": os.fspath(md_path)}
42+
return {"json": os.fspath(json_path), "markdown": os.fspath(md_path), "html": os.fspath(html_path)}
4143

4244

4345
def find_report(trace_id: str, root: str = "evidence") -> dict[str, Any] | None:
@@ -137,3 +139,109 @@ def render_markdown(result: dict[str, Any]) -> str:
137139
lines.append(f"- {error}")
138140

139141
return "\n".join(lines) + "\n"
142+
143+
def render_html(result: dict[str, Any]) -> str:
144+
"""Generate a clean, professional HTML report from the investigation result."""
145+
dns = result.get("dns", {})
146+
tls = result.get("tls", {})
147+
visual = result.get("visual", {})
148+
summary = result.get("summary", {})
149+
risk_factors = result.get("risk_factors") or []
150+
151+
# Base styling
152+
html = [
153+
"<!DOCTYPE html>",
154+
"<html lang='en'>",
155+
"<head>",
156+
" <meta charset='UTF-8'>",
157+
f" <title>HERALD Report: {result.get('domain', 'Unknown')}</title>",
158+
" <style>",
159+
" body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 1000px; margin: 0 auto; padding: 2rem; background-color: #f9f9fb; }",
160+
" h1, h2, h3 { color: #1a1a2e; }",
161+
" .header { background-color: #ffffff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); margin-bottom: 2rem; border-top: 5px solid #2ecc71; }",
162+
" .header.Phishing { border-top-color: #e74c3c; }",
163+
" .header.Suspected { border-top-color: #f1c40f; }",
164+
" .section { background-color: #ffffff; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-bottom: 1.5rem; }",
165+
" table { width: 100%; border-collapse: collapse; margin-top: 1rem; }",
166+
" th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eaeaea; }",
167+
" th { background-color: #f8f9fa; font-weight: 600; color: #555; }",
168+
" .badge { display: inline-block; padding: 0.25em 0.6em; font-size: 0.85em; font-weight: 700; line-height: 1; text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: 0.25rem; }",
169+
" .badge.high { background-color: #fee2e2; color: #991b1b; }",
170+
" .badge.medium { background-color: #fef3c7; color: #92400e; }",
171+
" .badge.low { background-color: #f3f4f6; color: #374151; }",
172+
" .badge.info { background-color: #e0f2fe; color: #075985; }",
173+
" .screenshot-container { max-width: 100%; margin-top: 1rem; border: 1px solid #eee; border-radius: 4px; overflow: hidden; }",
174+
" .screenshot-container img { max-width: 100%; height: auto; display: block; }",
175+
" </style>",
176+
"</head>",
177+
"<body>",
178+
]
179+
180+
headline = summary.get('headline') or f"{result.get('domain')} is {result.get('verdict')} with score {result.get('phishing_score')}"
181+
182+
html.extend([
183+
f" <div class='header {result.get('verdict', 'Clean')}'>",
184+
" <h1>HERALD Investigation Report</h1>",
185+
f" <p style='font-size: 1.2rem;'><strong>{headline}</strong></p>",
186+
" <table style='width: 100%; background: #fdfdfd;'>",
187+
f" <tr><td><strong>Trace ID:</strong></td><td><code>{result.get('trace_id')}</code></td><td><strong>Verdict:</strong></td><td><strong>{result.get('verdict')}</strong></td></tr>",
188+
f" <tr><td><strong>Domain:</strong></td><td><code>{result.get('domain')}</code></td><td><strong>Score:</strong></td><td><code>{result.get('phishing_score')}</code></td></tr>",
189+
f" <tr><td><strong>Completed:</strong></td><td>{result.get('completed_at')}</td><td><strong>Target:</strong></td><td><code>{result.get('url')}</code></td></tr>",
190+
" </table>",
191+
" </div>",
192+
])
193+
194+
# Why Flagged
195+
html.append("<div class='section'><h2>Why Flagged</h2><ul>")
196+
for reason in summary.get("why_flagged", ["No strong phishing indicators were observed."]):
197+
html.append(f"<li>{reason}</li>")
198+
html.append("</ul></div>")
199+
200+
# Risk Factors
201+
html.append("<div class='section'><h2>Risk Factors</h2><table>")
202+
html.append("<tr><th>Factor</th><th>Severity</th><th>Impact</th><th>Explanation</th></tr>")
203+
if risk_factors:
204+
for factor in risk_factors:
205+
sev = factor.get('severity', 'info')
206+
html.append(f"<tr><td>{factor.get('name', 'Unknown')}</td><td><span class='badge {sev}'>{sev}</span></td><td>{factor.get('score_impact', 0)}</td><td>{factor.get('detail', '')}</td></tr>")
207+
else:
208+
html.append("<tr><td colspan='4'>No notable risk factors recorded.</td></tr>")
209+
html.append("</table></div>")
210+
211+
# DNS and TLS
212+
html.append("<div style='display: flex; gap: 1.5rem;'>")
213+
214+
# DNS
215+
html.append("<div class='section' style='flex: 1;'><h2>DNS & WHOIS</h2><table>")
216+
html.append(f"<tr><th>Registrar</th><td>{dns.get('registrar') or 'unknown'}</td></tr>")
217+
html.append(f"<tr><th>Domain Age</th><td>{dns.get('domain_age_days') if dns.get('domain_age_days') is not None else 'unknown'} days</td></tr>")
218+
html.append(f"<tr><th>A Records</th><td>{', '.join(dns.get('a_records') or []) or 'none'}</td></tr>")
219+
html.append(f"<tr><th>MX Records</th><td>{', '.join(dns.get('mx_records') or []) or 'none'}</td></tr>")
220+
html.append("</table></div>")
221+
222+
# TLS
223+
html.append("<div class='section' style='flex: 1;'><h2>TLS Certificate</h2><table>")
224+
html.append(f"<tr><th>Has TLS</th><td>{tls.get('has_tls', False)}</td></tr>")
225+
html.append(f"<tr><th>Issuer</th><td>{tls.get('issuer') or 'unknown'}</td></tr>")
226+
html.append(f"<tr><th>SAN Match</th><td>{tls.get('domain_in_san', False)}</td></tr>")
227+
html.append(f"<tr><th>Days Remaining</th><td>{tls.get('days_remaining') if tls.get('days_remaining') is not None else 'unknown'}</td></tr>")
228+
html.append("</table></div>")
229+
230+
html.append("</div>") # End flex
231+
232+
# Evidence / Screenshot
233+
html.append("<div class='section'><h2>Visual Evidence</h2>")
234+
ocr = ", ".join((visual.get('ocr_findings') or {}).get('phrases_found') or []) or "none"
235+
html.append(f"<p><strong>OCR Suspicious Phrases:</strong> {ocr}</p>")
236+
237+
screenshot_path = visual.get('screenshot_path')
238+
if screenshot_path:
239+
# Resolve path relative to the HTML report (which is in the same dir as the screenshots folder)
240+
rel_path = "screenshots/homepage.png" if "homepage.png" in screenshot_path else screenshot_path.split("/")[-1]
241+
html.append(f"<div class='screenshot-container'><img src='{rel_path}' alt='Screenshot of {result.get('domain')}'></div>")
242+
else:
243+
html.append("<p><em>Screenshot not captured.</em></p>")
244+
html.append("</div>")
245+
246+
html.append("</body></html>")
247+
return "\n".join(html)

herald/predict_with_fallback.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ class PhishingPredictorV3:
6464
Uses Lexical + Network (SSL/DNS) + WHOIS features with a two-stage pipeline.
6565
Maintains compatibility with v3 class name for system-wide integration.
6666
"""
67-
def __init__(self, model_path="models/ensemble_v7.joblib", config_path="config.yaml"):
67+
def __init__(self, model_path=None, config_path=None):
68+
base_dir = os.path.dirname(os.path.abspath(__file__))
69+
if model_path is None:
70+
model_path = os.path.join(base_dir, "models", "ensemble_v7.joblib")
71+
if config_path is None:
72+
config_path = os.path.join(base_dir, "config.yaml")
73+
6874
print(f"Loading HERALD v7 Phishing Predictor from {model_path}...")
69-
if not os.path.exists(model_path):
70-
# Fallback to absolute path or project root if needed
71-
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
72-
model_path = os.path.join(base_dir, model_path)
73-
75+
7476
if not os.path.exists(model_path):
7577
raise FileNotFoundError(f"Model file {model_path} not found.")
7678

0 commit comments

Comments
 (0)