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 +3 -0
- osintkit/cli.py +673 -0
- osintkit/collectors/__init__.py +24 -0
- osintkit/collectors/accounts.py +83 -0
- osintkit/collectors/base.py +138 -0
- osintkit/collectors/breaches.py +163 -0
- osintkit/collectors/ct.py +165 -0
- osintkit/collectors/dns.py +130 -0
- osintkit/collectors/github.py +190 -0
- osintkit/collectors/pastes.py +237 -0
- osintkit/collectors/ports.py +91 -0
- osintkit/collectors/subdomains.py +102 -0
- osintkit/collectors/whois_lookup.py +72 -0
- osintkit/config.py +151 -0
- osintkit/diff.py +96 -0
- osintkit/doctor.py +119 -0
- osintkit/errors.py +51 -0
- osintkit/http.py +272 -0
- osintkit/llm.py +91 -0
- osintkit/logging_store.py +206 -0
- osintkit/models.py +228 -0
- osintkit/normalise.py +131 -0
- osintkit/pipeline.py +237 -0
- osintkit/py.typed +0 -0
- osintkit/render.py +235 -0
- osintkit/retention.py +81 -0
- osintkit/synthesis.py +176 -0
- osintkit/targets.py +65 -0
- osintkit-0.1.1.dist-info/METADATA +176 -0
- osintkit-0.1.1.dist-info/RECORD +33 -0
- osintkit-0.1.1.dist-info/WHEEL +4 -0
- osintkit-0.1.1.dist-info/entry_points.txt +2 -0
- osintkit-0.1.1.dist-info/licenses/LICENSE +21 -0
osintkit/__init__.py
ADDED
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
|
+
]
|