osintkit 0.1.1__py3-none-any.whl

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.
osintkit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """osintkit: a public-data OSINT aggregator."""
2
+
3
+ __version__ = "0.1.1"
osintkit/cli.py ADDED
@@ -0,0 +1,673 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import csv
7
+ import ipaddress
8
+ import json
9
+ import os
10
+ import sys
11
+ from dataclasses import asdict
12
+ from difflib import get_close_matches
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from rich import box
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+ from . import __version__, diff, doctor, pipeline, render, retention
22
+ from .collectors import REGISTRY
23
+ from .config import (
24
+ PROVIDERS,
25
+ Settings,
26
+ cache_clear,
27
+ cache_stats,
28
+ provider_from_key,
29
+ )
30
+ from .errors import OsintKitError, TargetParseError
31
+ from .logging_store import RunLogger, find_report_for_run, read_report, read_runs, run_exists
32
+ from .targets import VALID_TYPES
33
+
34
+ app = typer.Typer(
35
+ add_completion=False,
36
+ no_args_is_help=True,
37
+ help="Public-data OSINT aggregator for emails, usernames, domains, and companies.",
38
+ rich_markup_mode="rich",
39
+ )
40
+
41
+ STATE: dict[str, object] = {}
42
+
43
+
44
+ def _settings(**overrides: object) -> Settings:
45
+ base: Settings = STATE["settings"] # type: ignore[assignment]
46
+ for key, value in overrides.items():
47
+ setattr(base, key, value)
48
+ return base
49
+
50
+
51
+ def _console():
52
+ return render.console()
53
+
54
+
55
+ def _version_callback(value: bool) -> None:
56
+ if value:
57
+ typer.echo(f"osintkit {__version__}")
58
+ raise typer.Exit()
59
+
60
+
61
+ @app.callback()
62
+ def root(
63
+ provider: str | None = typer.Option(
64
+ None, "--provider", help="Endpoint preset: venice, openrouter, or openai."
65
+ ),
66
+ base_url: str | None = typer.Option(
67
+ None, "--base-url", help="OpenAI-compatible base URL (overrides the provider)."
68
+ ),
69
+ model: str | None = typer.Option(None, "--model", help="Model name (overrides the provider default)."),
70
+ api_key: str | None = typer.Option(None, "--api-key", help="API key (or set API_KEY)."),
71
+ temperature: float = typer.Option(0.3, "--temperature", min=0.0, max=2.0),
72
+ max_tokens: int = typer.Option(2400, "--max-tokens", min=256, max=16000),
73
+ timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds."),
74
+ max_connections: int = typer.Option(16, "--max-connections", min=1, max=64),
75
+ max_attempts: int = typer.Option(3, "--max-attempts", min=1, max=6),
76
+ no_cache: bool = typer.Option(False, "--no-cache", help="Bypass the 24h cache."),
77
+ cache_ttl: int = typer.Option(86400, "--cache-ttl", help="Cache lifetime in seconds."),
78
+ resolver: list[str] | None = typer.Option(None, "--resolver", help="DNS resolver IP to use, repeatable."),
79
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show collector detail."),
80
+ _version: bool = typer.Option(
81
+ False,
82
+ "--version",
83
+ "-V",
84
+ help="Print version and exit.",
85
+ callback=_version_callback,
86
+ is_eager=True,
87
+ ),
88
+ ) -> None:
89
+ if provider and provider not in PROVIDERS:
90
+ raise typer.BadParameter(f"unknown provider {provider!r}; choose from {', '.join(PROVIDERS)}")
91
+ effective_key = api_key or os.environ.get("API_KEY") or os.environ.get("OPENAI_API_KEY")
92
+ chosen = provider or provider_from_key(effective_key) or "venice"
93
+ preset = PROVIDERS[chosen]
94
+ settings = Settings(
95
+ base_url=base_url or preset[0],
96
+ model=model or preset[1],
97
+ api_key=api_key,
98
+ temperature=temperature,
99
+ max_tokens=max_tokens,
100
+ timeout=timeout,
101
+ max_connections=max_connections,
102
+ max_attempts=max_attempts,
103
+ use_cache=not no_cache,
104
+ cache_ttl=cache_ttl,
105
+ verbose=verbose,
106
+ )
107
+ if resolver:
108
+ for entry in resolver:
109
+ try:
110
+ ipaddress.ip_address(entry)
111
+ except ValueError:
112
+ raise typer.BadParameter(f"{entry!r} is not an IP address") from None
113
+ settings.resolvers = list(resolver)
114
+ STATE["settings"] = settings
115
+
116
+
117
+ def _pick_target(
118
+ target: str | None,
119
+ email: str | None,
120
+ username: str | None,
121
+ domain: str | None,
122
+ company: str | None,
123
+ ) -> tuple[str, str | None]:
124
+ supplied = [
125
+ (name, value)
126
+ for name, value in (
127
+ ("email", email),
128
+ ("username", username),
129
+ ("domain", domain),
130
+ ("company", company),
131
+ )
132
+ if value
133
+ ]
134
+ if len(supplied) > 1:
135
+ raise TargetParseError("pass only one of --email/--username/--domain/--company")
136
+ if supplied:
137
+ kind, value = supplied[0]
138
+ return value, kind
139
+ if not target:
140
+ raise TargetParseError("give a target or one of --email/--username/--domain/--company")
141
+ return target, None
142
+
143
+
144
+ def _parse_sets(values: list[str] | None, flag: str = "--only") -> set[str] | None:
145
+ if not values:
146
+ return None
147
+ items: set[str] = set()
148
+ for value in values:
149
+ items.update(part.strip() for part in value.split(",") if part.strip())
150
+ if not items:
151
+ return None
152
+ known = {spec.name for spec in REGISTRY}
153
+ unknown = sorted(items - known)
154
+ if unknown:
155
+ hint = ""
156
+ close = get_close_matches(unknown[0], sorted(known), n=3)
157
+ if close:
158
+ hint = f"; did you mean {', '.join(close)}?"
159
+ raise typer.BadParameter(f"{flag} got unknown collector {unknown[0]!r}{hint}")
160
+ return items
161
+
162
+
163
+ def _parse_ports(values: list[str] | None) -> list[str] | None:
164
+ if not values:
165
+ return None
166
+ ports: list[str] = []
167
+ for value in values:
168
+ for part in value.split(","):
169
+ part = part.strip()
170
+ if not part:
171
+ continue
172
+ try:
173
+ port = int(part)
174
+ except ValueError:
175
+ raise typer.BadParameter(f"{part!r} is not a port number") from None
176
+ if not 1 <= port <= 65535:
177
+ raise typer.BadParameter(f"port {port} is out of range")
178
+ if str(port) not in ports:
179
+ ports.append(str(port))
180
+ return ports or None
181
+
182
+
183
+ def _finish(outcome: pipeline.Outcome, settings: Settings, out) -> int:
184
+ report = outcome.report
185
+ if settings.output_markdown:
186
+ destination = Path(settings.extra.get("markdown_path") or pipeline.default_markdown_path(report.target))
187
+ if destination.is_dir() or str(destination).endswith(("/", os.sep)):
188
+ destination = destination / pipeline.default_markdown_path(report.target).name
189
+ path = pipeline.write_markdown(report, destination)
190
+ out.print(f"[green]markdown written[/green] {path}")
191
+
192
+ if settings.raw:
193
+ bundle = outcome.extra["bundle"]
194
+ print(json.dumps(bundle.model_dump(mode="json"), indent=2, ensure_ascii=False, default=str))
195
+ elif settings.dry_run and settings.output_json:
196
+ print(
197
+ json.dumps(
198
+ {
199
+ "target": report.target,
200
+ "target_type": report.target_type,
201
+ "prompt": outcome.extra.get("prompt"),
202
+ },
203
+ indent=2,
204
+ )
205
+ )
206
+ elif settings.output_json:
207
+ render.print_json(report)
208
+ elif settings.output_markdown:
209
+ destination = Path(settings.extra.get("markdown_path") or pipeline.default_markdown_path(report.target))
210
+ if destination.is_dir() or str(destination).endswith(("/", os.sep)):
211
+ destination = destination / pipeline.default_markdown_path(report.target).name
212
+ path = pipeline.write_markdown(report, destination)
213
+ out.print(f"[green]markdown written[/green] {path}")
214
+ else:
215
+ render.render_report(report, out, verbose=settings.verbose)
216
+
217
+ if outcome.archived:
218
+ sink = Console(stderr=True) if (settings.output_json or settings.raw) else out
219
+ sink.print(f"[dim]archived: {outcome.archived}[/dim]", highlight=False)
220
+ if outcome.error:
221
+ out.print(Panel(render.escape(outcome.error), title="synthesis failed", border_style="red"))
222
+ return 1
223
+ return 0
224
+
225
+
226
+ @app.command()
227
+ def lookup(
228
+ target: str | None = typer.Argument(None, help="Email, username, domain, or company."),
229
+ email: str | None = typer.Option(None, "--email", help="Email target."),
230
+ username: str | None = typer.Option(None, "--username", help="Username target."),
231
+ domain: str | None = typer.Option(None, "--domain", help="Domain target."),
232
+ company: str | None = typer.Option(None, "--company", help="Company target."),
233
+ json_out: bool = typer.Option(False, "--json", help="Raw structured JSON on stdout."),
234
+ markdown: bool = typer.Option(False, "--markdown", help="Write a Markdown report."),
235
+ out_path: Path | None = typer.Option(None, "--out", "-o", help="Markdown destination."),
236
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print the prompt, skip the model."),
237
+ raw: bool = typer.Option(False, "--raw", help="Dump collected data as JSON, skip the model."),
238
+ only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
239
+ skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
240
+ ports: list[str] | None = typer.Option(None, "--ports", help="Ports to probe, e.g. 22,443."),
241
+ ) -> None:
242
+ """Look up one target."""
243
+ out = _console()
244
+ settings = _settings(output_json=json_out, output_markdown=markdown, dry_run=dry_run, raw=raw)
245
+ try:
246
+ settings.extra["ports"] = _parse_ports(ports)
247
+ except typer.BadParameter as exc:
248
+ out.print(f"[red]{exc.message}[/red]")
249
+ raise typer.Exit(2) from exc
250
+
251
+ if out_path:
252
+ settings.extra["markdown_path"] = str(out_path)
253
+ try:
254
+ raw, declared = _pick_target(target, email, username, domain, company)
255
+ except TargetParseError as exc:
256
+ out.print(f"[red]{exc}[/red]")
257
+ raise typer.Exit(2) from exc
258
+
259
+ logger = RunLogger(pipeline.new_run_id())
260
+ try:
261
+ outcome = asyncio.run(
262
+ pipeline.execute(
263
+ raw,
264
+ settings,
265
+ logger,
266
+ out,
267
+ declared_type=declared,
268
+ only=_parse_sets(only, "--only"),
269
+ skip=_parse_sets(skip, "--skip"),
270
+ )
271
+ )
272
+ except TargetParseError as exc:
273
+ out.print(f"[red]{exc}[/red]")
274
+ raise typer.Exit(2) from exc
275
+ except KeyboardInterrupt: # pragma: no cover
276
+ out.print("[yellow]interrupted[/yellow]")
277
+ raise typer.Exit(130) from None
278
+
279
+ raise typer.Exit(_finish(outcome, settings, out))
280
+
281
+
282
+ def _read_batch(path: Path) -> list[tuple[str, str | None]]:
283
+ rows: list[tuple[str, str | None]] = []
284
+ with path.open(newline="", encoding="utf-8-sig") as handle:
285
+ sample = handle.read(2048)
286
+ handle.seek(0)
287
+ has_header = "target" in sample.splitlines()[0].lower() if sample.splitlines() else False
288
+ if has_header:
289
+ reader = csv.DictReader(handle)
290
+ for row in reader:
291
+ value = (row.get("target") or "").strip()
292
+ if not value or value.startswith("#"):
293
+ continue
294
+ kind = (row.get("type") or row.get("target_type") or "").strip().lower() or None
295
+ if kind and kind not in VALID_TYPES:
296
+ kind = None
297
+ rows.append((value, kind))
298
+ else:
299
+ for row in csv.reader(handle):
300
+ if not row:
301
+ continue
302
+ value = row[0].strip()
303
+ if not value or value.startswith("#"):
304
+ continue
305
+ kind = row[1].strip().lower() if len(row) > 1 and row[1].strip() in VALID_TYPES else None
306
+ rows.append((value, kind))
307
+ return rows
308
+
309
+
310
+ @app.command()
311
+ def batch(
312
+ file: Path = typer.Option(
313
+ ..., "--file", "-f", exists=True, dir_okay=False, help="CSV with target[,type] rows."
314
+ ),
315
+ json_out: bool = typer.Option(False, "--json", help="JSON array of reports on stdout."),
316
+ markdown: bool = typer.Option(False, "--markdown", help="Write one Markdown file per target."),
317
+ out_dir: Path | None = typer.Option(None, "--out", "-o", help="Markdown output directory."),
318
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print prompts, skip the model."),
319
+ raw: bool = typer.Option(False, "--raw", help="JSON array of collected data, skip the model."),
320
+ fail_fast: bool = typer.Option(False, "--fail-fast", help="Stop reporting at the first error."),
321
+ concurrency: int = typer.Option(3, "--concurrency", "-c", min=1, max=10, help="Targets to run at once."),
322
+ only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
323
+ skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
324
+ ports: list[str] | None = typer.Option(None, "--ports", help="Ports to probe, e.g. 22,443."),
325
+ ) -> None:
326
+ """Run every target in a CSV."""
327
+ out = _console()
328
+ settings = _settings(output_json=json_out, output_markdown=markdown, dry_run=dry_run, raw=raw)
329
+ try:
330
+ settings.extra["ports"] = _parse_ports(ports)
331
+ except typer.BadParameter as exc:
332
+ out.print(f"[red]{exc.message}[/red]")
333
+ raise typer.Exit(2) from exc
334
+
335
+ settings.extra["quiet_progress"] = True
336
+ try:
337
+ settings.extra["ports"] = _parse_ports(ports)
338
+ except typer.BadParameter as exc:
339
+ out.print(f"[red]{exc.message}[/red]")
340
+ raise typer.Exit(2) from exc
341
+ try:
342
+ rows = _read_batch(file)
343
+ except OSError as exc:
344
+ out.print(f"[red]{exc}[/red]")
345
+ raise typer.Exit(2) from exc
346
+ if not rows:
347
+ out.print("[yellow]no targets in file[/yellow]")
348
+ raise typer.Exit(2)
349
+
350
+ target_dir = Path(out_dir) if out_dir else Path.cwd()
351
+ try:
352
+ only_set, skip_set = _parse_sets(only, "--only"), _parse_sets(skip, "--skip")
353
+ except typer.BadParameter as exc:
354
+ out.print(f"[red]{exc.message}[/red]")
355
+ raise typer.Exit(2) from exc
356
+
357
+ async def run_all():
358
+ gate = asyncio.Semaphore(concurrency)
359
+
360
+ async def one(item):
361
+ index, value, declared = item
362
+ logger = RunLogger(pipeline.new_run_id())
363
+ async with gate:
364
+ try:
365
+ outcome = await pipeline.execute(
366
+ value,
367
+ settings,
368
+ logger,
369
+ out,
370
+ declared_type=declared,
371
+ only=only_set,
372
+ skip=skip_set,
373
+ )
374
+ except TargetParseError as exc:
375
+ return index, value, exc
376
+ return index, value, outcome
377
+
378
+ return await asyncio.gather(*(one((i, v, d)) for i, (v, d) in enumerate(rows, start=1)))
379
+
380
+ reports = []
381
+ bundles = []
382
+ failures = 0
383
+ for index, value, outcome in asyncio.run(run_all()):
384
+ if not json_out and not raw:
385
+ out.rule(f"[dim]{index}/{len(rows)} · {value}[/dim]")
386
+ if isinstance(outcome, TargetParseError):
387
+ failures += 1
388
+ out.print(f"[red]skipped {value}: {outcome}[/red]")
389
+ if fail_fast:
390
+ break
391
+ continue
392
+ report = outcome.report
393
+ if raw:
394
+ bundles.append(outcome.extra["bundle"].model_dump(mode="json"))
395
+ else:
396
+ reports.append(report.model_dump(mode="json"))
397
+ if outcome.error:
398
+ failures += 1
399
+ if not json_out and not raw:
400
+ if markdown:
401
+ name = pipeline.default_markdown_path(report.target).name
402
+ path = pipeline.write_markdown(report, target_dir / name)
403
+ out.print(f"[green]markdown written[/green] {path}")
404
+ else:
405
+ render.render_report(report, out, verbose=settings.verbose)
406
+ if fail_fast and outcome.error:
407
+ break
408
+
409
+ if raw:
410
+ print(json.dumps(bundles, indent=2, ensure_ascii=False))
411
+ raise typer.Exit(1 if failures else 0)
412
+ if json_out:
413
+ print(json.dumps(reports, indent=2, ensure_ascii=False))
414
+
415
+ table = Table(title="batch summary", box=box.SIMPLE_HEAVY)
416
+ table.add_column("target")
417
+ table.add_column("risk")
418
+ table.add_column("breaches")
419
+ for entry in reports:
420
+ analysis = entry.get("analysis") or {}
421
+ table.add_row(
422
+ render.escape(str(entry.get("target"))),
423
+ str(analysis.get("risk_level") or "-"),
424
+ str(len(analysis.get("breaches") or [])),
425
+ )
426
+ if not json_out:
427
+ out.print(table)
428
+ raise typer.Exit(1 if failures else 0)
429
+
430
+
431
+ @app.command()
432
+ def history(
433
+ limit: int = typer.Option(20, "--limit", "-n", min=1, max=200),
434
+ show: str | None = typer.Option(None, "--show", help="Replay a stored report by archive path or run id."),
435
+ json_out: bool = typer.Option(False, "--json", help="JSON output."),
436
+ ) -> None:
437
+ """Show past runs, or replay one archived report."""
438
+ out = _console()
439
+ if show:
440
+ payload = _load_report(show)
441
+ if payload is None and run_exists(show):
442
+ out.print(f"[red]run {show} has no archived report (dry run or early failure)[/red]")
443
+ raise typer.Exit(1)
444
+ if payload is None:
445
+ out.print(f"[red]cannot read report {show}[/red]")
446
+ raise typer.Exit(1)
447
+ if json_out:
448
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
449
+ return
450
+ from .models import Report
451
+
452
+ try:
453
+ render.render_report(Report.model_validate(payload), out)
454
+ except ValueError as exc:
455
+ out.print(f"[red]unreadable report: {exc}[/red]")
456
+ raise typer.Exit(1) from exc
457
+ return
458
+
459
+ runs = read_runs(limit)
460
+ if json_out:
461
+ print(json.dumps(runs, indent=2, ensure_ascii=False))
462
+ return
463
+ if not runs:
464
+ out.print("[dim]no runs logged yet[/dim]")
465
+ return
466
+
467
+ table = Table(title="run history", box=box.SIMPLE_HEAVY)
468
+ table.add_column("run", no_wrap=True)
469
+ table.add_column("started", no_wrap=True)
470
+ table.add_column("target")
471
+ table.add_column("type")
472
+ table.add_column("sources")
473
+ table.add_column("risk")
474
+ for run in runs:
475
+ table.add_row(
476
+ str(run.get("run_id")),
477
+ str(run.get("started", ""))[:19].replace("T", " "),
478
+ render.escape(str(run.get("target") or "-")),
479
+ str(run.get("target_type") or "-"),
480
+ f"{run['collectors_ok']} ok / {run['collectors_failed']} failed / "
481
+ f"{run['collectors_skipped']} skipped",
482
+ str(run.get("risk") or "-"),
483
+ )
484
+ out.print(table)
485
+ out.print("[dim]replay one with: osintkit history --show <run id>[/dim]")
486
+
487
+
488
+ @app.command("collectors")
489
+ def list_collectors(
490
+ json_out: bool = typer.Option(False, "--json", help="JSON output."),
491
+ ) -> None:
492
+ """List available collectors and the target types they serve."""
493
+ from .collectors import REGISTRY
494
+
495
+ if json_out:
496
+ print(
497
+ json.dumps(
498
+ [
499
+ {
500
+ "name": spec.name,
501
+ "targets": list(spec.target_types),
502
+ "requires": spec.requires,
503
+ "description": spec.description,
504
+ }
505
+ for spec in sorted(REGISTRY, key=lambda c: (c.order, c.name))
506
+ ],
507
+ indent=2,
508
+ )
509
+ )
510
+ return
511
+
512
+ table = Table(box=box.SIMPLE_HEAD)
513
+ table.add_column("collector", style="bold")
514
+ table.add_column("targets")
515
+ table.add_column("requires")
516
+ table.add_column("what it does")
517
+ for spec in sorted(REGISTRY, key=lambda c: (c.order, c.name)):
518
+ table.add_row(
519
+ spec.name,
520
+ ", ".join(spec.target_types),
521
+ spec.requires or "-",
522
+ spec.description,
523
+ )
524
+ render.console().print(table)
525
+
526
+
527
+ @app.command("doctor")
528
+ def doctor_cmd(
529
+ json_out: bool = typer.Option(False, "--json", help="JSON output."),
530
+ deep: bool = typer.Option(False, "--deep", help="Send a 1-token completion to verify the key."),
531
+ ) -> None:
532
+ """Check the key, endpoint, and writable directories."""
533
+ out = _console()
534
+ settings: Settings = STATE["settings"] # type: ignore[assignment]
535
+ checks = asyncio.run(doctor.diagnose(settings, deep=deep))
536
+ if json_out:
537
+ print(json.dumps([asdict(check) for check in checks], indent=2))
538
+ else:
539
+ table = Table(title="doctor", box=box.SIMPLE_HEAVY)
540
+ table.add_column("check")
541
+ table.add_column("status")
542
+ table.add_column("detail", overflow="fold")
543
+ style = {"ok": "green", "warn": "yellow", "fail": "red"}
544
+ for check in checks:
545
+ table.add_row(check.name, f"[{style[check.status]}]{check.status}[/]", render.escape(check.detail))
546
+ out.print(table)
547
+ raise typer.Exit(1 if doctor.failed(checks) else 0)
548
+
549
+
550
+ @app.command()
551
+ def prune(
552
+ days: int = typer.Option(30, "--days", min=1, help="Delete anything older than this."),
553
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would go."),
554
+ ) -> None:
555
+ """Delete old cache entries, logs, and archived reports."""
556
+ out = _console()
557
+ counts = retention.prune(days, dry_run=dry_run)
558
+ verb = "would remove" if dry_run else "removed"
559
+ out.print(f"{verb}: {counts['cache']} cached, {counts['logs']} log, {counts['reports']} report files")
560
+
561
+
562
+ def _load_report(ref: str) -> dict | None:
563
+ target = ref
564
+ if not Path(ref).exists():
565
+ found = find_report_for_run(ref)
566
+ if found:
567
+ target = found
568
+ return read_report(target)
569
+
570
+
571
+ @app.command("diff")
572
+ def diff_cmd(
573
+ old_ref: str = typer.Argument(..., help="Older run id or report path."),
574
+ new_ref: str = typer.Argument(..., help="Newer run id or report path."),
575
+ json_out: bool = typer.Option(False, "--json", help="JSON output."),
576
+ ) -> None:
577
+ """Compare two archived runs."""
578
+ from .models import Report
579
+
580
+ out = _console()
581
+ loaded = []
582
+ for ref in (old_ref, new_ref):
583
+ payload = _load_report(ref)
584
+ if payload is None:
585
+ out.print(f"[red]cannot read report {ref}[/red]")
586
+ raise typer.Exit(1)
587
+ try:
588
+ loaded.append(Report.model_validate(payload))
589
+ except ValueError as exc:
590
+ out.print(f"[red]unreadable report {ref}: {exc}[/red]")
591
+ raise typer.Exit(1) from exc
592
+
593
+ result = diff.compare(loaded[0], loaded[1])
594
+ if json_out:
595
+ print(json.dumps(result, indent=2))
596
+ raise typer.Exit(0)
597
+
598
+ if result["target_changed"]:
599
+ out.print(
600
+ f"[yellow]different targets: {render.escape(result['old_target'])} -> "
601
+ f"{render.escape(result['new_target'])}[/yellow]"
602
+ )
603
+ old_risk, new_risk = result["risk"]["old"] or "-", result["risk"]["new"] or "-"
604
+ style = "red" if result["risk_rose"] else "green" if result["risk_fell"] else "dim"
605
+ out.print(f"risk: {old_risk} -> [{style}]{new_risk}[/]")
606
+ if not diff.is_empty(result):
607
+ table = Table(box=box.SIMPLE_HEAD, title="changes")
608
+ table.add_column("area")
609
+ table.add_column("added/failing")
610
+ table.add_column("removed/recovered")
611
+ table.add_row(
612
+ "breaches",
613
+ render.escape(", ".join(result["breaches"]["added"]) or "-"),
614
+ render.escape(", ".join(result["breaches"]["removed"]) or "-"),
615
+ )
616
+ table.add_row(
617
+ "subdomains",
618
+ render.escape(", ".join(result["subdomains"]["added"]) or "-"),
619
+ render.escape(", ".join(result["subdomains"]["removed"]) or "-"),
620
+ )
621
+ table.add_row(
622
+ "collectors",
623
+ render.escape(", ".join(result["collectors"]["started_failing"]) or "-"),
624
+ render.escape(", ".join(result["collectors"]["started_working"]) or "-"),
625
+ )
626
+ out.print(table)
627
+ out.print(f"[dim]{result['breaches']['unchanged']} breaches unchanged[/dim]")
628
+ else:
629
+ out.print("[dim]no material changes[/dim]")
630
+
631
+
632
+ @app.command()
633
+ def schema(out: Path | None = typer.Option(None, "--out", "-o", help="Write to a file.")) -> None:
634
+ """Print the JSON schema the model must satisfy."""
635
+ from .models import Analysis
636
+
637
+ payload = json.dumps(Analysis.model_json_schema(), indent=2)
638
+ if out:
639
+ out.write_text(payload + "\n", "utf-8")
640
+ _console().print(f"[green]schema written[/green] {out}")
641
+ return
642
+ print(payload)
643
+
644
+
645
+ @app.command("cache")
646
+ def cache_cmd(
647
+ clear: bool = typer.Option(False, "--clear", help="Delete every cached response."),
648
+ ) -> None:
649
+ """Inspect or clear the response cache."""
650
+ out = _console()
651
+ if clear:
652
+ removed = cache_clear()
653
+ out.print(f"[green]cleared {removed} cached responses[/green]")
654
+ return
655
+ count, size = cache_stats()
656
+ usage = retention.disk_usage()
657
+ out.print(f"{count} files, {size / 1024:.1f} KiB in the cache")
658
+ out.print(
659
+ f"total on disk: {usage['total'] / 1024:.1f} KiB "
660
+ f"({usage['logs'] / 1024:.1f} KiB logs, {usage['reports'] / 1024:.1f} KiB reports)"
661
+ )
662
+
663
+
664
+ def main() -> None: # pragma: no cover - console script shim
665
+ try:
666
+ app()
667
+ except OsintKitError as exc:
668
+ render.console().print(f"[red]{exc}[/red]")
669
+ sys.exit(1)
670
+
671
+
672
+ if __name__ == "__main__": # pragma: no cover
673
+ main()
@@ -0,0 +1,24 @@
1
+ """Collector package: importing it registers every collector."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import ( # noqa: F401 (import order matters: side-effect registration)
6
+ accounts,
7
+ breaches,
8
+ ct,
9
+ dns,
10
+ github,
11
+ pastes,
12
+ ports,
13
+ subdomains,
14
+ whois_lookup,
15
+ )
16
+ from .base import REGISTRY, Collector, Context, collectors_for, run_collector
17
+
18
+ __all__ = [
19
+ "REGISTRY",
20
+ "Collector",
21
+ "Context",
22
+ "collectors_for",
23
+ "run_collector",
24
+ ]