osintkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +86 -0
  2. package/bin/osintkit.js +7 -0
  3. package/osintkit/__init__.py +3 -0
  4. package/osintkit/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/osintkit/__pycache__/cli.cpython-311.pyc +0 -0
  6. package/osintkit/__pycache__/config.cpython-311.pyc +0 -0
  7. package/osintkit/__pycache__/profiles.cpython-311.pyc +0 -0
  8. package/osintkit/__pycache__/risk.cpython-311.pyc +0 -0
  9. package/osintkit/__pycache__/scanner.cpython-311.pyc +0 -0
  10. package/osintkit/cli.py +613 -0
  11. package/osintkit/config.py +51 -0
  12. package/osintkit/modules/__init__.py +6 -0
  13. package/osintkit/modules/__pycache__/__init__.cpython-311.pyc +0 -0
  14. package/osintkit/modules/__pycache__/breach.cpython-311.pyc +0 -0
  15. package/osintkit/modules/__pycache__/brokers.cpython-311.pyc +0 -0
  16. package/osintkit/modules/__pycache__/certs.cpython-311.pyc +0 -0
  17. package/osintkit/modules/__pycache__/dark_web.cpython-311.pyc +0 -0
  18. package/osintkit/modules/__pycache__/gravatar.cpython-311.pyc +0 -0
  19. package/osintkit/modules/__pycache__/harvester.cpython-311.pyc +0 -0
  20. package/osintkit/modules/__pycache__/hibp.cpython-311.pyc +0 -0
  21. package/osintkit/modules/__pycache__/hibp_kanon.cpython-311.pyc +0 -0
  22. package/osintkit/modules/__pycache__/holehe.cpython-311.pyc +0 -0
  23. package/osintkit/modules/__pycache__/libphonenumber_info.cpython-311.pyc +0 -0
  24. package/osintkit/modules/__pycache__/paste.cpython-311.pyc +0 -0
  25. package/osintkit/modules/__pycache__/phone.cpython-311.pyc +0 -0
  26. package/osintkit/modules/__pycache__/sherlock.cpython-311.pyc +0 -0
  27. package/osintkit/modules/__pycache__/social.cpython-311.pyc +0 -0
  28. package/osintkit/modules/__pycache__/wayback.cpython-311.pyc +0 -0
  29. package/osintkit/modules/breach.py +82 -0
  30. package/osintkit/modules/brokers.py +56 -0
  31. package/osintkit/modules/certs.py +42 -0
  32. package/osintkit/modules/dark_web.py +51 -0
  33. package/osintkit/modules/gravatar.py +50 -0
  34. package/osintkit/modules/harvester.py +56 -0
  35. package/osintkit/modules/hibp.py +40 -0
  36. package/osintkit/modules/hibp_kanon.py +66 -0
  37. package/osintkit/modules/holehe.py +39 -0
  38. package/osintkit/modules/libphonenumber_info.py +79 -0
  39. package/osintkit/modules/paste.py +55 -0
  40. package/osintkit/modules/phone.py +32 -0
  41. package/osintkit/modules/sherlock.py +48 -0
  42. package/osintkit/modules/social.py +58 -0
  43. package/osintkit/modules/stage2/__init__.py +1 -0
  44. package/osintkit/modules/stage2/github_api.py +65 -0
  45. package/osintkit/modules/stage2/hunter.py +64 -0
  46. package/osintkit/modules/stage2/leakcheck.py +58 -0
  47. package/osintkit/modules/stage2/numverify.py +62 -0
  48. package/osintkit/modules/stage2/securitytrails.py +65 -0
  49. package/osintkit/modules/wayback.py +70 -0
  50. package/osintkit/output/__init__.py +1 -0
  51. package/osintkit/output/__pycache__/__init__.cpython-311.pyc +0 -0
  52. package/osintkit/output/__pycache__/html_writer.cpython-311.pyc +0 -0
  53. package/osintkit/output/__pycache__/json_writer.cpython-311.pyc +0 -0
  54. package/osintkit/output/__pycache__/md_writer.cpython-311.pyc +0 -0
  55. package/osintkit/output/html_writer.py +36 -0
  56. package/osintkit/output/json_writer.py +31 -0
  57. package/osintkit/output/md_writer.py +115 -0
  58. package/osintkit/output/templates/report.html +74 -0
  59. package/osintkit/profiles.py +116 -0
  60. package/osintkit/risk.py +42 -0
  61. package/osintkit/scanner.py +240 -0
  62. package/osintkit/setup.py +157 -0
  63. package/package.json +25 -0
  64. package/pyproject.toml +44 -0
  65. package/requirements.txt +9 -0
@@ -0,0 +1,613 @@
1
+ """osintkit CLI - OSINT tool for personal digital footprint analysis."""
2
+
3
+ import sys
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from datetime import datetime
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.prompt import Prompt, Confirm
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn
16
+
17
+ from osintkit.scanner import Scanner
18
+ from osintkit.config import load_config, Config, APIKeys
19
+ from osintkit.profiles import Profile, ProfileStore, ScanHistory
20
+
21
+ app = typer.Typer(help="OSINT CLI for personal digital footprint analysis")
22
+ console = Console()
23
+ store = ProfileStore()
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _print_ethics_banner():
28
+ """Print the ethics and legal notice before every scan."""
29
+ console.print(Panel(
30
+ "[yellow]Only use osintkit on targets you have explicit permission to investigate.\n"
31
+ "GDPR applies to EU subjects. Unauthorized use may be illegal.[/yellow]",
32
+ title="[bold red]Ethics Notice[/bold red]",
33
+ border_style="red",
34
+ ))
35
+
36
+
37
+ def validate_and_format_phone(phone_str: str) -> Optional[str]:
38
+ """Parse and validate a phone number string, returning E.164 format.
39
+
40
+ Args:
41
+ phone_str: Raw phone number string entered by the user.
42
+
43
+ Returns:
44
+ E.164 formatted string (e.g. '+15555550100') if valid, None otherwise.
45
+ """
46
+ if not phone_str:
47
+ return None
48
+ try:
49
+ import phonenumbers
50
+ parsed = phonenumbers.parse(phone_str, None)
51
+ if phonenumbers.is_valid_number(parsed):
52
+ return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
53
+ # Try with US as default region
54
+ parsed = phonenumbers.parse(phone_str, "US")
55
+ if phonenumbers.is_valid_number(parsed):
56
+ return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
57
+ logger.warning(f"Phone number appears invalid: {phone_str!r}")
58
+ return None
59
+ except Exception:
60
+ try:
61
+ import phonenumbers
62
+ parsed = phonenumbers.parse(phone_str, "US")
63
+ if phonenumbers.is_valid_number(parsed):
64
+ return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
65
+ except Exception:
66
+ pass
67
+ logger.warning(f"Could not parse phone number: {phone_str!r}")
68
+ return None
69
+
70
+ # ============ SETUP ============
71
+
72
+ def check_first_time():
73
+ """Check if this is first run and prompt for API keys."""
74
+ config_path = Path.home() / ".osintkit" / "config.yaml"
75
+
76
+ if not config_path.exists():
77
+ console.print("\n[bold cyan]═══ First Time Setup ═══[/bold cyan]\n")
78
+ console.print("osintkit needs API keys for full functionality.")
79
+ console.print("[dim]You can skip this and add keys later.[/dim]\n")
80
+ console.print(
81
+ "[dim]Tip: for social profile enumeration install optional tools:\n"
82
+ " pip install -r requirements-tools.txt (maigret, holehe, sherlock)[/dim]\n"
83
+ )
84
+
85
+ keys = {}
86
+ api_key_list = [
87
+ ("hibp", "HaveIBeenPwned", "Free tier available"),
88
+ ("breachdirectory", "BreachDirectory", "Via RapidAPI"),
89
+ ("leakcheck", "LeakCheck", "Free tier available"),
90
+ ("intelbase", "Intelbase", "Dark web + paste search"),
91
+ ("google_cse_key", "Google CSE Key", "Data broker search"),
92
+ ("google_cse_cx", "Google CSE CX ID", "Engine ID"),
93
+ ("numverify", "NumVerify", "Phone validation"),
94
+ ("emailrep", "EmailRep", "Email reputation"),
95
+ ("hunter", "Hunter.io (email finder)", "https://hunter.io"),
96
+ ("github", "GitHub Personal Access Token", "https://github.com/settings/tokens"),
97
+ ("securitytrails", "SecurityTrails", "https://securitytrails.com"),
98
+ ]
99
+
100
+ for key_name, service_name, note in api_key_list:
101
+ value = Prompt.ask(f"{service_name} ({note})", default="")
102
+ keys[key_name] = value.strip()
103
+
104
+ # Create config
105
+ config_path.parent.mkdir(parents=True, exist_ok=True)
106
+ config_content = f"""# osintkit Configuration
107
+ output_dir: ~/osint-results
108
+ timeout_seconds: 120
109
+
110
+ api_keys:
111
+ hibp: "{keys.get('hibp', '')}"
112
+ breachdirectory: "{keys.get('breachdirectory', '')}"
113
+ leakcheck: "{keys.get('leakcheck', '')}"
114
+ intelbase: "{keys.get('intelbase', '')}"
115
+ google_cse_key: "{keys.get('google_cse_key', '')}"
116
+ google_cse_cx: "{keys.get('google_cse_cx', '')}"
117
+ numverify: "{keys.get('numverify', '')}"
118
+ emailrep: "{keys.get('emailrep', '')}"
119
+ resend: ""
120
+ hunter: "{keys.get('hunter', '')}"
121
+ github: "{keys.get('github', '')}"
122
+ securitytrails: "{keys.get('securitytrails', '')}"
123
+ epieos: ""
124
+ """
125
+ config_path.write_text(config_content)
126
+ console.print(f"\n[green]✓[/green] Config saved to {config_path}")
127
+ return True
128
+
129
+ return False
130
+
131
+
132
+ def get_profile_by_identifier(name=None, email=None, username=None, phone=None) -> Optional[Profile]:
133
+ """Find profile by any identifier."""
134
+ profiles = store.list()
135
+ for p in profiles:
136
+ if name and p.name and name.lower().strip() == p.name.lower().strip():
137
+ return p
138
+ if email and p.email and email.lower().strip() == p.email.lower().strip():
139
+ return p
140
+ if username and p.username and username.lower().strip() == p.username.lower().strip():
141
+ return p
142
+ if phone and p.phone and phone.strip() == p.phone.strip():
143
+ return p
144
+ return None
145
+
146
+
147
+ def select_profile() -> Optional[Profile]:
148
+ """Let user select a profile from list."""
149
+ profiles = store.list()
150
+
151
+ if not profiles:
152
+ console.print("[yellow]No profiles found. Use 'osintkit new' to create one.[/yellow]")
153
+ return None
154
+
155
+ console.print("\n[bold]Select a profile:[/bold]\n")
156
+
157
+ for i, p in enumerate(profiles, 1):
158
+ scan_info = f"{len(p.scan_history)} scans" if p.scan_history else "no scans"
159
+ console.print(f" [cyan]{i}.[/cyan] {p.name or 'Unnamed'} - {p.email or p.username or 'no email'} ({scan_info})")
160
+
161
+ console.print()
162
+ choice = Prompt.ask("Enter number (or Enter to cancel)", default="")
163
+
164
+ if not choice:
165
+ return None
166
+
167
+ try:
168
+ idx = int(choice) - 1
169
+ if 0 <= idx < len(profiles):
170
+ return profiles[idx]
171
+ except ValueError:
172
+ pass
173
+
174
+ console.print("[red]Invalid selection[/red]")
175
+ return None
176
+
177
+
178
+ def run_scan_for_profile(profile: Profile) -> dict:
179
+ """Execute scan for a profile with progress display."""
180
+ _print_ethics_banner()
181
+
182
+ config_path = Path.home() / ".osintkit" / "config.yaml"
183
+ cfg = load_config(config_path)
184
+
185
+ # Create output directory (use ~/osint-results or config setting)
186
+ target = "_".join(filter(None, [profile.name, profile.email, profile.username, profile.phone])).replace(" ", "_")
187
+ date_str = datetime.now().strftime("%Y-%m-%d_%H%M%S")
188
+
189
+ # Use config output_dir or default to ~/osint-results
190
+ base_dir = Path(cfg.output_dir).expanduser() if cfg.output_dir else Path.home() / "osint-results"
191
+ output_dir = base_dir / f"{target}_{date_str}"
192
+ output_dir.mkdir(parents=True, exist_ok=True)
193
+
194
+ console.print(f"\n[bold]Scanning: {profile.name or profile.email or profile.username}[/bold]")
195
+ console.print(f"[dim]Output: {output_dir}[/dim]\n")
196
+
197
+ # Run scanner
198
+ scanner = Scanner(config=cfg, output_dir=output_dir, console=console)
199
+ inputs = {
200
+ "name": profile.name,
201
+ "email": profile.email,
202
+ "username": profile.username,
203
+ "phone": profile.phone,
204
+ }
205
+
206
+ findings = scanner.run(inputs)
207
+
208
+ # Write outputs
209
+ json_path = scanner.write_json(findings)
210
+ html_path = scanner.write_html(findings)
211
+ md_path = scanner.write_md(findings)
212
+
213
+ # Show results
214
+ score = findings.get("risk_score", 0)
215
+ color = "red" if score >= 70 else "yellow" if score >= 40 else "green"
216
+
217
+ total_findings = sum(len(f) for f in findings.get("findings", {}).values())
218
+
219
+ console.print(f"\n[bold {color}]Risk Score: {score}/100[/bold {color}]")
220
+ console.print(f"Total findings: {total_findings}")
221
+ console.print(f"\n[green]✓[/green] JSON: {json_path}")
222
+ console.print(f"[green]✓[/green] HTML: {html_path}")
223
+ console.print(f"[green]✓[/green] Markdown: {str(md_path)}")
224
+
225
+ return {
226
+ "findings": findings,
227
+ "json_path": json_path,
228
+ "html_path": html_path,
229
+ "md_path": md_path,
230
+ "score": score,
231
+ "total": total_findings,
232
+ }
233
+
234
+
235
+ # ============ COMMANDS ============
236
+
237
+ @app.command()
238
+ def setup():
239
+ """Configure API keys."""
240
+ config_path = Path.home() / ".osintkit" / "config.yaml"
241
+
242
+ if config_path.exists():
243
+ console.print(f"\n[yellow]Config already exists: {config_path}[/yellow]")
244
+ if not Confirm.ask("Overwrite?"):
245
+ return
246
+
247
+ console.print("\n[bold cyan]═══ API Key Setup ═══[/bold cyan]\n")
248
+ console.print("Enter your API keys (press Enter to skip).\n")
249
+
250
+ keys = {}
251
+ api_key_list = [
252
+ ("hibp", "HaveIBeenPwned (hibp)", "https://haveibeenpwned.com/API/Key"),
253
+ ("breachdirectory", "BreachDirectory", "https://rapidapi.com/"),
254
+ ("leakcheck", "LeakCheck", "https://leakcheck.io/"),
255
+ ("intelbase", "Intelbase", "https://intelbase.is/"),
256
+ ("google_cse_key", "Google CSE API Key", "https://developers.google.com/custom-search/"),
257
+ ("google_cse_cx", "Google CSE Engine ID", ""),
258
+ ("numverify", "NumVerify", "https://numverify.com/"),
259
+ ("emailrep", "EmailRep", "https://emailrep.io/"),
260
+ ("hunter", "Hunter.io (email finder)", "https://hunter.io"),
261
+ ("github", "GitHub Personal Access Token", "https://github.com/settings/tokens"),
262
+ ("securitytrails", "SecurityTrails", "https://securitytrails.com"),
263
+ ]
264
+
265
+ for key_name, label, url in api_key_list:
266
+ hint = f" ({url})" if url else ""
267
+ value = Prompt.ask(f"{label}{hint}", default="")
268
+ keys[key_name] = value.strip()
269
+
270
+ config_path.parent.mkdir(parents=True, exist_ok=True)
271
+ config_content = f"""# osintkit Configuration
272
+ output_dir: ~/osint-results
273
+ timeout_seconds: 120
274
+
275
+ api_keys:
276
+ hibp: "{keys.get('hibp', '')}"
277
+ breachdirectory: "{keys.get('breachdirectory', '')}"
278
+ leakcheck: "{keys.get('leakcheck', '')}"
279
+ intelbase: "{keys.get('intelbase', '')}"
280
+ google_cse_key: "{keys.get('google_cse_key', '')}"
281
+ google_cse_cx: "{keys.get('google_cse_cx', '')}"
282
+ numverify: "{keys.get('numverify', '')}"
283
+ emailrep: "{keys.get('emailrep', '')}"
284
+ resend: ""
285
+ hunter: "{keys.get('hunter', '')}"
286
+ github: "{keys.get('github', '')}"
287
+ securitytrails: "{keys.get('securitytrails', '')}"
288
+ epieos: ""
289
+ """
290
+ config_path.write_text(config_content)
291
+ console.print(f"\n[green]✓[/green] Config saved: {config_path}")
292
+
293
+
294
+ @app.command()
295
+ def new():
296
+ """Create a new person profile."""
297
+ check_first_time()
298
+
299
+ console.print("\n[bold cyan]═══ New Profile ═══[/bold cyan]\n")
300
+
301
+ # Step-by-step input
302
+ name = Prompt.ask("1. Full name").strip()
303
+ email = Prompt.ask("2. Email (optional)", default="").strip()
304
+ username = Prompt.ask("3. Username (optional)", default="").strip()
305
+ phone_raw = Prompt.ask("4. Phone (optional)", default="").strip()
306
+ if phone_raw:
307
+ phone = validate_and_format_phone(phone_raw)
308
+ if phone is None:
309
+ console.print(f"[yellow]Warning: Could not parse phone '{phone_raw}' — storing as-is[/yellow]")
310
+ phone = phone_raw
311
+ else:
312
+ phone = ""
313
+
314
+ if not any([name, email, username, phone]):
315
+ console.print("[red]Error: Need at least name, email, username, or phone[/red]")
316
+ raise typer.Exit(1)
317
+
318
+ # Check for existing
319
+ existing = get_profile_by_identifier(name, email, username, phone)
320
+ if existing:
321
+ console.print(f"\n[yellow]⚠ Profile already exists:[/yellow] {existing.id}")
322
+ console.print(f" Name: {existing.name or '—'}")
323
+ console.print(f" Email: {existing.email or '—'}")
324
+ if Confirm.ask("\nUpdate this profile?"):
325
+ profile = existing
326
+ if name:
327
+ profile.name = name
328
+ if email:
329
+ profile.email = email
330
+ if username:
331
+ profile.username = username
332
+ if phone:
333
+ profile.phone = phone
334
+ store.update(profile)
335
+ console.print(f"[green]✓[/green] Updated profile: {profile.id}")
336
+ else:
337
+ console.print("[red]Cancelled[/red]")
338
+ return
339
+ else:
340
+ profile = Profile(
341
+ name=name or None,
342
+ email=email or None,
343
+ username=username or None,
344
+ phone=phone or None,
345
+ )
346
+ store.create(profile)
347
+ console.print(f"\n[green]✓[/green] Created profile: {profile.id}")
348
+ console.print(f" Name: {name or '—'}")
349
+ console.print(f" Email: {email or '—'}")
350
+ console.print(f" Username: {username or '—'}")
351
+ console.print(f" Phone: {phone or '—'}")
352
+
353
+ # Ask to scan
354
+ if Confirm.ask("\nRun scan now?"):
355
+ result = run_scan_for_profile(profile)
356
+
357
+ # Save to history
358
+ scan_record = ScanHistory(
359
+ scan_id=datetime.now().strftime("%Y%m%d_%H%M%S"),
360
+ timestamp=datetime.now().isoformat(),
361
+ inputs={"name": profile.name, "email": profile.email, "username": profile.username, "phone": profile.phone},
362
+ risk_score=result["score"],
363
+ findings_count=result["total"],
364
+ findings_file=str(result["json_path"]),
365
+ html_file=str(result["html_path"]),
366
+ )
367
+ store.add_scan_result(profile.id, scan_record)
368
+
369
+
370
+ @app.command()
371
+ def list():
372
+ """List all profiles."""
373
+ profiles = store.list()
374
+
375
+ if not profiles:
376
+ console.print("\n[yellow]No profiles found.[/yellow]")
377
+ console.print("Use [cyan]osintkit new[/cyan] to create one.\n")
378
+ return
379
+
380
+ console.print(f"\n[bold]Profiles ({len(profiles)}):[/bold]\n")
381
+
382
+ table = Table()
383
+ table.add_column("#", width=3)
384
+ table.add_column("ID", style="cyan")
385
+ table.add_column("Name")
386
+ table.add_column("Email")
387
+ table.add_column("Username")
388
+ table.add_column("Scans")
389
+ table.add_column("Risk")
390
+
391
+ for i, p in enumerate(profiles, 1):
392
+ last_scan = p.scan_history[-1] if p.scan_history else None
393
+ table.add_row(
394
+ str(i),
395
+ p.id,
396
+ p.name or "—",
397
+ p.email or "—",
398
+ p.username or "—",
399
+ str(len(p.scan_history)),
400
+ str(last_scan.risk_score) if last_scan else "—",
401
+ )
402
+
403
+ console.print(table)
404
+ console.print()
405
+
406
+
407
+ @app.command()
408
+ def refresh(profile_ref: str = typer.Argument(None, help="Profile ID or name")):
409
+ """Refresh scan for a profile."""
410
+ if profile_ref:
411
+ # Try to find by ID or name
412
+ profile = store.get(profile_ref)
413
+ if not profile:
414
+ profiles = store.list()
415
+ for p in profiles:
416
+ if p.name and p.name.lower() == profile_ref.lower():
417
+ profile = p
418
+ break
419
+ if not profile:
420
+ console.print(f"[red]Profile not found: {profile_ref}[/red]")
421
+ raise typer.Exit(1)
422
+ else:
423
+ profile = select_profile()
424
+ if not profile:
425
+ return
426
+
427
+ console.print(f"\n[bold]Refreshing: {profile.name or profile.email or profile.id}[/bold]")
428
+
429
+ result = run_scan_for_profile(profile)
430
+
431
+ # Save to history
432
+ scan_record = ScanHistory(
433
+ scan_id=datetime.now().strftime("%Y%m%d_%H%M%S"),
434
+ timestamp=datetime.now().isoformat(),
435
+ inputs={"name": profile.name, "email": profile.email, "username": profile.username, "phone": profile.phone},
436
+ risk_score=result["score"],
437
+ findings_count=result["total"],
438
+ findings_file=str(result["json_path"]),
439
+ html_file=str(result["html_path"]),
440
+ )
441
+ store.add_scan_result(profile.id, scan_record)
442
+ console.print(f"\n[green]✓[/green] Scan saved to profile history")
443
+
444
+
445
+ @app.command()
446
+ def open(profile_ref: str = typer.Argument(None, help="Profile ID or name")):
447
+ """Show profile details."""
448
+ if profile_ref:
449
+ profile = store.get(profile_ref)
450
+ if not profile:
451
+ profiles = store.list()
452
+ for p in profiles:
453
+ if p.name and p.name.lower() == profile_ref.lower():
454
+ profile = p
455
+ break
456
+ if not profile:
457
+ console.print(f"[red]Profile not found: {profile_ref}[/red]")
458
+ raise typer.Exit(1)
459
+ else:
460
+ profile = select_profile()
461
+ if not profile:
462
+ return
463
+
464
+ console.print(f"\n[bold cyan]═══ Profile: {profile.name or profile.id} ═══[/bold cyan]\n")
465
+ console.print(f" [bold]ID:[/bold] {profile.id}")
466
+ console.print(f" [bold]Name:[/bold] {profile.name or '—'}")
467
+ console.print(f" [bold]Email:[/bold] {profile.email or '—'}")
468
+ console.print(f" [bold]Username:[/bold] {profile.username or '—'}")
469
+ console.print(f" [bold]Phone:[/bold] {profile.phone or '—'}")
470
+ console.print(f" [bold]Notes:[/bold] {profile.notes or '—'}")
471
+ console.print(f" [bold]Created:[/bold] {profile.created_at[:10] if profile.created_at else '—'}")
472
+
473
+ if profile.scan_history:
474
+ console.print(f"\n[bold]Scan History ({len(profile.scan_history)} scans):[/bold]\n")
475
+
476
+ table = Table()
477
+ table.add_column("Date")
478
+ table.add_column("Risk")
479
+ table.add_column("Findings")
480
+ table.add_column("Report")
481
+
482
+ for scan in reversed(profile.scan_history[-10:]):
483
+ report_name = Path(scan.html_file).name if scan.html_file else "—"
484
+ table.add_row(
485
+ scan.timestamp[:10],
486
+ str(scan.risk_score),
487
+ str(scan.findings_count),
488
+ report_name,
489
+ )
490
+
491
+ console.print(table)
492
+
493
+ # Open latest report
494
+ if profile.scan_history and profile.scan_history[-1].html_file:
495
+ latest = profile.scan_history[-1]
496
+ if Confirm.ask(f"\nOpen latest report?"):
497
+ import webbrowser
498
+ webbrowser.open(f"file://{latest.html_file}")
499
+ else:
500
+ console.print("\n[yellow]No scans yet. Use 'osintkit refresh' to run a scan.[/yellow]")
501
+
502
+ console.print()
503
+
504
+
505
+ @app.command()
506
+ def export(
507
+ profile_ref: str = typer.Argument(None, help="Profile ID or name"),
508
+ format: str = typer.Option("json", "--format", "-f", help="Output format: json or md"),
509
+ output: Path = typer.Option(None, "--output", "-o", help="Output file path"),
510
+ ):
511
+ """Export profile data."""
512
+ if profile_ref:
513
+ profile = store.get(profile_ref)
514
+ if not profile:
515
+ profiles = store.list()
516
+ for p in profiles:
517
+ if p.name and p.name.lower() == profile_ref.lower():
518
+ profile = p
519
+ break
520
+ if not profile:
521
+ console.print(f"[red]Profile not found: {profile_ref}[/red]")
522
+ raise typer.Exit(1)
523
+ else:
524
+ profile = select_profile()
525
+ if not profile:
526
+ return
527
+
528
+ # Load latest findings
529
+ if not profile.scan_history:
530
+ console.print("[yellow]No scans to export. Run 'osintkit refresh' first.[/yellow]")
531
+ return
532
+
533
+ latest_scan = profile.scan_history[-1]
534
+
535
+ if format == "json":
536
+ if output is None:
537
+ output = Path.cwd() / f"{profile.name or profile.id}_export.json"
538
+
539
+ # Load the findings
540
+ if latest_scan.findings_file and Path(latest_scan.findings_file).exists():
541
+ import shutil
542
+ shutil.copy(latest_scan.findings_file, output)
543
+ console.print(f"[green]✓[/green] Exported JSON: {output}")
544
+ else:
545
+ console.print("[red]Findings file not found[/red]")
546
+
547
+ elif format == "md":
548
+ if output is None:
549
+ output = Path.cwd() / f"{profile.name or profile.id}_export.md"
550
+
551
+ # Create markdown report
552
+ findings_data = {}
553
+ if latest_scan.findings_file and Path(latest_scan.findings_file).exists():
554
+ with open(latest_scan.findings_file) as f:
555
+ findings_data = json.load(f)
556
+
557
+ md_content = f"""# osintkit Report: {profile.name or profile.id}
558
+
559
+ **Generated:** {datetime.now().isoformat()}
560
+
561
+ ## Profile Information
562
+
563
+ - **Name:** {profile.name or '—'}
564
+ - **Email:** {profile.email or '—'}
565
+ - **Username:** {profile.username or '—'}
566
+ - **Phone:** {profile.phone or '—'}
567
+
568
+ ## Risk Score: {latest_scan.risk_score}/100
569
+
570
+ ## Findings Summary
571
+
572
+ """
573
+ for module_name, findings in findings_data.get("findings", {}).items():
574
+ if findings:
575
+ md_content += f"### {module_name}\n\n"
576
+ for finding in findings:
577
+ md_content += f"- **{finding.get('type', 'Unknown')}** via {finding.get('source', 'unknown')}\n"
578
+ if finding.get('url'):
579
+ md_content += f" - URL: {finding['url']}\n"
580
+ md_content += "\n"
581
+
582
+ output.write_text(md_content)
583
+ console.print(f"[green]✓[/green] Exported Markdown: {output}")
584
+
585
+ else:
586
+ console.print(f"[red]Unknown format: {format}[/red]")
587
+
588
+
589
+ @app.command()
590
+ def delete(profile_ref: str = typer.Argument(None, help="Profile ID or name")):
591
+ """Delete a profile."""
592
+ if profile_ref:
593
+ profile = store.get(profile_ref)
594
+ else:
595
+ profile = select_profile()
596
+
597
+ if not profile:
598
+ return
599
+
600
+ if Confirm.ask(f"\nDelete profile '{profile.name or profile.id}'?"):
601
+ store.delete(profile.id)
602
+ console.print(f"[green]✓[/green] Deleted")
603
+
604
+
605
+ @app.command()
606
+ def version():
607
+ """Show version."""
608
+ from osintkit import __version__
609
+ console.print(f"osintkit v{__version__}")
610
+
611
+
612
+ if __name__ == "__main__":
613
+ app()
@@ -0,0 +1,51 @@
1
+ """Configuration loader for osintkit."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+ import yaml
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class APIKeys(BaseModel):
10
+ """Optional API keys for premium modules."""
11
+
12
+ emailrep: str = ""
13
+ breachdirectory: str = ""
14
+ leakcheck: str = ""
15
+ google_cse_key: str = ""
16
+ google_cse_cx: str = ""
17
+ intelbase: str = ""
18
+ numverify: str = ""
19
+ resend: str = ""
20
+ hibp: str = ""
21
+ hunter: str = ""
22
+ github: str = ""
23
+ securitytrails: str = ""
24
+ epieos: str = ""
25
+
26
+
27
+ class Config(BaseModel):
28
+ """osintkit configuration."""
29
+
30
+ output_dir: str = "~/osint-results"
31
+ timeout_seconds: int = 120
32
+ api_keys: APIKeys = Field(default_factory=APIKeys)
33
+
34
+
35
+ def load_config(config_path: Path) -> Config:
36
+ """Load configuration from YAML file.
37
+
38
+ Returns default config if file doesn't exist.
39
+ """
40
+ if not config_path.exists():
41
+ return Config()
42
+
43
+ with open(config_path) as f:
44
+ data = yaml.safe_load(f) or {}
45
+
46
+ api_keys_data = data.pop("api_keys", {})
47
+
48
+ return Config(
49
+ **data,
50
+ api_keys=APIKeys(**api_keys_data) if api_keys_data else APIKeys(),
51
+ )
@@ -0,0 +1,6 @@
1
+ """osintkit OSINT modules."""
2
+
3
+
4
+ class ModuleError(Exception):
5
+ """Base exception for module failures."""
6
+ pass