1212from rich .panel import Panel
1313from rich .progress import Progress , SpinnerColumn , TextColumn
1414from rich .table import Table
15+ from rich .prompt import Confirm
1516
1617from herald .core .security import SSRFProtectionError
1718from herald .investigation .persistence import find_report
2324
2425def 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
4157def 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
71100def 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
80158def 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+
117283def emit_result (result : dict , * , as_json : bool , explain : bool = False ) -> int :
118284 if as_json :
119285 console .print_json (json .dumps (result ))
0 commit comments