osintkit 0.4.0__tar.gz → 0.5.0__tar.gz

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 (88) hide show
  1. {osintkit-0.4.0 → osintkit-0.5.0}/CHANGELOG.md +15 -0
  2. {osintkit-0.4.0 → osintkit-0.5.0}/PKG-INFO +18 -3
  3. {osintkit-0.4.0 → osintkit-0.5.0}/README.md +17 -2
  4. {osintkit-0.4.0 → osintkit-0.5.0}/docs/REFERENCE.md +13 -0
  5. {osintkit-0.4.0 → osintkit-0.5.0}/pyproject.toml +1 -1
  6. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/__init__.py +1 -1
  7. osintkit-0.5.0/src/osintkit/catalogue.py +94 -0
  8. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/cli.py +46 -2
  9. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/config.py +1 -1
  10. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/llm.py +23 -4
  11. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/normalise.py +18 -6
  12. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/offline.py +7 -4
  13. osintkit-0.5.0/src/osintkit/passwords.py +66 -0
  14. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/pipeline.py +32 -2
  15. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/synthesis.py +1 -1
  16. osintkit-0.5.0/tests/test_catalogue.py +70 -0
  17. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_cli.py +25 -0
  18. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_e2e.py +1 -1
  19. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_llm.py +33 -1
  20. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_offline.py +6 -0
  21. osintkit-0.5.0/tests/test_passwords.py +58 -0
  22. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_pipeline.py +52 -1
  23. {osintkit-0.4.0 → osintkit-0.5.0}/.editorconfig +0 -0
  24. {osintkit-0.4.0 → osintkit-0.5.0}/.github/CODEOWNERS +0 -0
  25. {osintkit-0.4.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  26. {osintkit-0.4.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  27. {osintkit-0.4.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  28. {osintkit-0.4.0 → osintkit-0.5.0}/.github/dependabot.yml +0 -0
  29. {osintkit-0.4.0 → osintkit-0.5.0}/.github/pull_request_template.md +0 -0
  30. {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/ci.yml +0 -0
  31. {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/codeql.yml +0 -0
  32. {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/publish.yml +0 -0
  33. {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/release.yml +0 -0
  34. {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/smoke.yml +0 -0
  35. {osintkit-0.4.0 → osintkit-0.5.0}/.gitignore +0 -0
  36. {osintkit-0.4.0 → osintkit-0.5.0}/.mailmap +0 -0
  37. {osintkit-0.4.0 → osintkit-0.5.0}/.pre-commit-config.yaml +0 -0
  38. {osintkit-0.4.0 → osintkit-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  39. {osintkit-0.4.0 → osintkit-0.5.0}/CONTRIBUTING.md +0 -0
  40. {osintkit-0.4.0 → osintkit-0.5.0}/LICENSE +0 -0
  41. {osintkit-0.4.0 → osintkit-0.5.0}/SECURITY.md +0 -0
  42. {osintkit-0.4.0 → osintkit-0.5.0}/docs/architecture.md +0 -0
  43. {osintkit-0.4.0 → osintkit-0.5.0}/docs/demo.svg +0 -0
  44. {osintkit-0.4.0 → osintkit-0.5.0}/docs/sample-output.md +0 -0
  45. {osintkit-0.4.0 → osintkit-0.5.0}/docs/social-preview.png +0 -0
  46. {osintkit-0.4.0 → osintkit-0.5.0}/examples/README.md +0 -0
  47. {osintkit-0.4.0 → osintkit-0.5.0}/examples/targets.csv +0 -0
  48. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/__init__.py +0 -0
  49. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/accounts.py +0 -0
  50. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/base.py +0 -0
  51. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/breaches.py +0 -0
  52. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/ct.py +0 -0
  53. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/dns.py +0 -0
  54. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/github.py +0 -0
  55. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/pastes.py +0 -0
  56. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/ports.py +0 -0
  57. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/subdomains.py +0 -0
  58. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  59. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/diff.py +0 -0
  60. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/doctor.py +0 -0
  61. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/errors.py +0 -0
  62. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/http.py +0 -0
  63. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/logging_store.py +0 -0
  64. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/models.py +0 -0
  65. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/py.typed +0 -0
  66. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/render.py +0 -0
  67. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/retention.py +0 -0
  68. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/targets.py +0 -0
  69. {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/userconfig.py +0 -0
  70. {osintkit-0.4.0 → osintkit-0.5.0}/tests/conftest.py +0 -0
  71. {osintkit-0.4.0 → osintkit-0.5.0}/tests/fixtures/model_reply.json +0 -0
  72. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_accounts.py +0 -0
  73. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_breaches.py +0 -0
  74. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_collectors.py +0 -0
  75. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_config.py +0 -0
  76. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_ct.py +0 -0
  77. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_diff.py +0 -0
  78. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_doctor.py +0 -0
  79. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_http.py +0 -0
  80. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_normalise.py +0 -0
  81. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_pastes.py +0 -0
  82. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_render.py +0 -0
  83. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_retention.py +0 -0
  84. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_synthesis.py +0 -0
  85. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_targets.py +0 -0
  86. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_userconfig.py +0 -0
  87. {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_whois.py +0 -0
  88. {osintkit-0.4.0 → osintkit-0.5.0}/uv.lock +0 -0
@@ -6,6 +6,21 @@ All notable changes are recorded here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.5.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - breach rows are enriched with date, exposed fields, domain, and record count from the keyless
14
+ HIBP and XposedOrNot catalogues
15
+ - `osintkit password` checks Pwned Passwords by SHA-1 prefix; no key, no plaintext sent
16
+ - email lookups also collect the address domain's DNS, mail posture, and WHOIS, marked as derived
17
+ - a failed synthesis now falls back to a locally built report instead of an empty one
18
+
19
+ ### Fixed
20
+
21
+ - large prompts came back empty on reasoning models: token truncation is detected and retried,
22
+ and the prompt carries at most 15 breach rows
23
+
9
24
  ## 0.4.0 — 2026-11-02
10
25
 
11
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Public-data OSINT aggregator for emails, usernames, domains, and companies
5
5
  Project-URL: Homepage, https://github.com/sjdsjadjajdjsa/osintkit
6
6
  Project-URL: Issues, https://github.com/sjdsjadjajdjsa/osintkit/issues
@@ -103,6 +103,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
103
103
  | `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`) |
104
104
  | `batch` | A CSV of targets, run concurrently, reported in order |
105
105
  | `diff` | What changed between two archived runs |
106
+ | `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
106
107
  | `history` | Past runs, replayable by id |
107
108
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
108
109
  | `collectors` | Every collector and what it touches |
@@ -132,8 +133,11 @@ target -> type detection -> collectors (parallel, cached, retried)
132
133
  Collectors degrade to a logged gap, never a dead run; a report is produced even when sources or
133
134
  the model fail.
134
135
 
135
- - **Merged breaches.** Three indexes listing one incident collapse into one row with every
136
- source and the earliest date.
136
+ - **Merged, enriched breaches.** Records collapse into one row per incident, keeping every
137
+ source and the earliest date, then pick up dates and exposed-field lists from the public HIBP
138
+ and XposedOrNot catalogues. Those catalogues are free; only HIBP *account* checks need a key.
139
+ - **Email domains follow the address.** An email lookup also checks the domain's DNS, mail
140
+ posture, and WHOIS, marked as derived.
137
141
  - **Risk floor.** Breach count and credential types set a floor the model may exceed but never
138
142
  undercut.
139
143
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
@@ -148,6 +152,17 @@ connect probes, GitHub (profile, commits, user search, org repos), psbdmp with a
148
152
  grep.app, Hacker News, Stack Exchange, and username presence. The full table is in
149
153
  [docs/REFERENCE.md](docs/REFERENCE.md#collectors) or via `osintkit collectors`.
150
154
 
155
+ ## Breach depth
156
+
157
+ Breach name lists are free; breach *metadata* is what makes them useful. Every row is joined
158
+ against the public HIBP and XposedOrNot catalogues for date, exposed fields, and record count, so
159
+ the report can say "LinkedIn, 2021-06, emails and password hashes" instead of a bare name. Add a
160
+ `HIBP_API_KEY` (about $4/month) to get per-account membership with the same metadata, plus pastes.
161
+
162
+ Passwords are checked separately with `osintkit password`: Pwned Passwords covers hundreds of
163
+ millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
164
+ machine.
165
+
151
166
  ## Safety and legality
152
167
 
153
168
  - Public data only. No logins, no scraping behind auth, no paid brokers.
@@ -71,6 +71,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
71
71
  | `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`) |
72
72
  | `batch` | A CSV of targets, run concurrently, reported in order |
73
73
  | `diff` | What changed between two archived runs |
74
+ | `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
74
75
  | `history` | Past runs, replayable by id |
75
76
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
76
77
  | `collectors` | Every collector and what it touches |
@@ -100,8 +101,11 @@ target -> type detection -> collectors (parallel, cached, retried)
100
101
  Collectors degrade to a logged gap, never a dead run; a report is produced even when sources or
101
102
  the model fail.
102
103
 
103
- - **Merged breaches.** Three indexes listing one incident collapse into one row with every
104
- source and the earliest date.
104
+ - **Merged, enriched breaches.** Records collapse into one row per incident, keeping every
105
+ source and the earliest date, then pick up dates and exposed-field lists from the public HIBP
106
+ and XposedOrNot catalogues. Those catalogues are free; only HIBP *account* checks need a key.
107
+ - **Email domains follow the address.** An email lookup also checks the domain's DNS, mail
108
+ posture, and WHOIS, marked as derived.
105
109
  - **Risk floor.** Breach count and credential types set a floor the model may exceed but never
106
110
  undercut.
107
111
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
@@ -116,6 +120,17 @@ connect probes, GitHub (profile, commits, user search, org repos), psbdmp with a
116
120
  grep.app, Hacker News, Stack Exchange, and username presence. The full table is in
117
121
  [docs/REFERENCE.md](docs/REFERENCE.md#collectors) or via `osintkit collectors`.
118
122
 
123
+ ## Breach depth
124
+
125
+ Breach name lists are free; breach *metadata* is what makes them useful. Every row is joined
126
+ against the public HIBP and XposedOrNot catalogues for date, exposed fields, and record count, so
127
+ the report can say "LinkedIn, 2021-06, emails and password hashes" instead of a bare name. Add a
128
+ `HIBP_API_KEY` (about $4/month) to get per-account membership with the same metadata, plus pastes.
129
+
130
+ Passwords are checked separately with `osintkit password`: Pwned Passwords covers hundreds of
131
+ millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
132
+ machine.
133
+
119
134
  ## Safety and legality
120
135
 
121
136
  - Public data only. No logins, no scraping behind auth, no paid brokers.
@@ -61,6 +61,19 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
61
61
  Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
62
62
  `--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
63
63
 
64
+ ## Breach catalogue enrichment
65
+
66
+ Breach names from free indexes are joined against `haveibeenpwned.com/api/v3/breaches` (keyless
67
+ catalogue) with an XposedOrNot catalogue fallback, cached a week, to add dates, exposed fields,
68
+ domains, and record counts. At most 15 enriched rows go into the prompt; the true count stays in
69
+ `signals`.
70
+
71
+ ## Password checks
72
+
73
+ `osintkit password [VALUE]` hashes locally, sends only the five-character SHA-1 prefix to
74
+ `api.pwnedpasswords.com`, and reports the count and a commonness rating. Exit code `1` means
75
+ pwned. `--json` for machines. Nothing about the password beyond the prefix leaves the machine.
76
+
64
77
  ## Configuration file
65
78
 
66
79
  `osintkit init` writes `~/.config/osintkit/config.toml` (or `$XDG_CONFIG_HOME`) with mode 600 in
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Public-data OSINT aggregator for emails, usernames, domains, and companies"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """osintkit: a public-data OSINT aggregator."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -0,0 +1,94 @@
1
+ """Public breach catalogues used to enrich bare breach names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ from .errors import OsintKitError
9
+ from .http import HttpClient, cache_hit, cache_put
10
+
11
+ NAMESPACE = "catalogue"
12
+ TTL = 7 * 24 * 3600
13
+ KEY_RE = re.compile(r"[^a-z0-9]+")
14
+
15
+ HIBP = "https://haveibeenpwned.com/api/v3/breaches"
16
+ XPOSED = "https://api.xposedornot.com/v1/breaches"
17
+
18
+
19
+ def _key(name: str) -> str:
20
+ return KEY_RE.sub("", name.lower())
21
+
22
+
23
+ def _from_hibp(rows: Any) -> dict[str, dict[str, Any]]:
24
+ if not isinstance(rows, list):
25
+ return {}
26
+ out: dict[str, dict[str, Any]] = {}
27
+ for row in rows:
28
+ if not isinstance(row, dict):
29
+ continue
30
+ name = str(row.get("Name") or row.get("Title") or "").strip()
31
+ if not name:
32
+ continue
33
+ out[_key(name)] = {
34
+ "name": name,
35
+ "date": row.get("BreachDate"),
36
+ "data_types": [str(kind) for kind in row.get("DataClasses") or []],
37
+ "domain": row.get("Domain"),
38
+ "pwn_count": row.get("PwnCount"),
39
+ }
40
+ return out
41
+
42
+
43
+ def _from_xposed(body: Any) -> dict[str, dict[str, Any]]:
44
+ rows = body.get("exposedBreaches") if isinstance(body, dict) else None
45
+ if not isinstance(rows, list):
46
+ return {}
47
+ out: dict[str, dict[str, Any]] = {}
48
+ for row in rows:
49
+ if not isinstance(row, dict):
50
+ continue
51
+ name = str(row.get("breachID") or "").strip()
52
+ if not name:
53
+ continue
54
+ out[_key(name)] = {
55
+ "name": name,
56
+ "date": (row.get("breachedDate") or "")[:10] or None,
57
+ "data_types": [str(kind) for kind in row.get("exposedData") or []],
58
+ "domain": row.get("domain"),
59
+ "pwn_count": row.get("exposedRecords"),
60
+ }
61
+ return out
62
+
63
+
64
+ async def _download(http: HttpClient) -> dict[str, dict[str, Any]]:
65
+ try:
66
+ response = await http.get(HIBP, namespace=NAMESPACE, accept="application/json")
67
+ if response.status_code == 200:
68
+ data = _from_hibp(response.json())
69
+ if data:
70
+ return data
71
+ except (OsintKitError, ValueError):
72
+ pass
73
+
74
+ try:
75
+ response = await http.get(XPOSED, namespace=NAMESPACE, accept="application/json")
76
+ except OsintKitError:
77
+ return {}
78
+ if response.status_code != 200:
79
+ return {}
80
+ try:
81
+ return _from_xposed(response.json())
82
+ except ValueError:
83
+ return {}
84
+
85
+
86
+ async def fetch(http: HttpClient) -> dict[str, dict[str, Any]]:
87
+ """Return breach metadata keyed by normalised name, cached for a week."""
88
+ cached = cache_hit(http.settings, NAMESPACE, "index", TTL)
89
+ if cached is not None:
90
+ return cached
91
+ data = await _download(http)
92
+ if data:
93
+ cache_put(http.settings, NAMESPACE, "index", data)
94
+ return data
@@ -18,7 +18,7 @@ from rich.console import Console
18
18
  from rich.panel import Panel
19
19
  from rich.table import Table
20
20
 
21
- from . import __version__, diff, doctor, pipeline, render, retention, userconfig
21
+ from . import __version__, diff, doctor, passwords, pipeline, render, retention, userconfig
22
22
  from .collectors import REGISTRY
23
23
  from .config import (
24
24
  DEFAULT_RESOLVERS,
@@ -74,7 +74,7 @@ def root(
74
74
  model: str | None = typer.Option(None, "--model", help="Model name (overrides the provider default)."),
75
75
  api_key: str | None = typer.Option(None, "--api-key", help="API key (or set API_KEY)."),
76
76
  temperature: float = typer.Option(0.3, "--temperature", min=0.0, max=2.0),
77
- max_tokens: int = typer.Option(2400, "--max-tokens", min=256, max=16000),
77
+ max_tokens: int = typer.Option(4000, "--max-tokens", min=256, max=16000),
78
78
  timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds."),
79
79
  max_connections: int = typer.Option(16, "--max-connections", min=1, max=64),
80
80
  max_attempts: int = typer.Option(3, "--max-attempts", min=1, max=6),
@@ -627,6 +627,50 @@ def init(
627
627
  out.print("[dim]try: osintkit lookup --domain example.com[/dim]")
628
628
 
629
629
 
630
+ @app.command()
631
+ def password(
632
+ value: str | None = typer.Argument(None, help="Password to check; prompt if omitted."),
633
+ json_out: bool = typer.Option(False, "--json", help="JSON output."),
634
+ ) -> None:
635
+ """Check a password against Pwned Passwords, without sending it."""
636
+ out = _console()
637
+ secret = value
638
+ if not secret:
639
+ if not sys.stdin.isatty():
640
+ secret = sys.stdin.readline().rstrip("\n")
641
+ if not secret:
642
+ secret = typer.prompt("Password", hide_input=True)
643
+ if not secret:
644
+ out.print("[red]no password given[/red]")
645
+ raise typer.Exit(2)
646
+
647
+ settings: Settings = STATE["settings"] # type: ignore[assignment]
648
+
649
+ async def run() -> dict[str, object]:
650
+ from .http import HttpClient
651
+
652
+ async with HttpClient(settings) as http:
653
+ return await passwords.check(http, secret)
654
+
655
+ try:
656
+ result = asyncio.run(run())
657
+ except OsintKitError as exc:
658
+ out.print(f"[red]{exc}[/red]")
659
+ raise typer.Exit(1) from exc
660
+
661
+ if json_out:
662
+ print(json.dumps(result, indent=2))
663
+ return
664
+ if result["pwned"]:
665
+ out.print(
666
+ f"[red]pwned[/red] {result['count']:,} times "
667
+ f"({result['rating']}); still only the first 5 SHA-1 characters left this machine"
668
+ )
669
+ raise typer.Exit(1)
670
+ out.print(f"[green]not seen[/green] in Pwned Passwords (checked by hash prefix {result['prefix']})")
671
+ out.print("[yellow]absence is not proof of strength; length and uniqueness still matter[/yellow]")
672
+
673
+
630
674
  @app.command("config")
631
675
  def config_cmd(
632
676
  reveal: bool = typer.Option(False, "--reveal", help="Show the key in full."),
@@ -110,7 +110,7 @@ class Settings:
110
110
  model: str = DEFAULT_MODEL
111
111
  api_key: str | None = None
112
112
  temperature: float = DEFAULT_TEMPERATURE
113
- max_tokens: int = 2400
113
+ max_tokens: int = 4000
114
114
  timeout: float = DEFAULT_TIMEOUT
115
115
  max_connections: int = 16
116
116
  max_attempts: int = DEFAULT_MAX_ATTEMPTS
@@ -34,7 +34,7 @@ class LLMClient:
34
34
  messages: Sequence[Mapping[str, str]],
35
35
  *,
36
36
  temperature: float | None = None,
37
- max_tokens: int = 2400,
37
+ max_tokens: int = 4000,
38
38
  response_format: Mapping[str, Any] | None = None,
39
39
  ) -> str:
40
40
  api_key = self.settings.resolved_api_key()
@@ -51,6 +51,15 @@ class LLMClient:
51
51
  if response_format:
52
52
  payload["response_format"] = dict(response_format)
53
53
 
54
+ content, finish = await self._post(payload, api_key)
55
+ if finish == "length" and not content and max_tokens < 8000:
56
+ payload["max_tokens"] = min(8000, max_tokens * 2)
57
+ content, finish = await self._post(payload, api_key)
58
+ if finish == "length" and not content:
59
+ raise SynthesisError("model ran out of tokens before answering")
60
+ return content
61
+
62
+ async def _post(self, payload: dict[str, Any], api_key: str | None) -> tuple[str, str | None]:
54
63
  try:
55
64
  response = await self._http.post_json(
56
65
  self.endpoint(),
@@ -69,7 +78,17 @@ class LLMClient:
69
78
  body = response.json()
70
79
  except ValueError as exc:
71
80
  raise SynthesisError("Model endpoint returned non-JSON") from exc
72
- return extract_content(body)
81
+ return extract_content(body), extract_finish(body)
82
+
83
+
84
+ def extract_finish(body: Any) -> str | None:
85
+ if not isinstance(body, dict):
86
+ return None
87
+ choices = body.get("choices")
88
+ if not isinstance(choices, list) or not choices:
89
+ return None
90
+ finish = choices[0].get("finish_reason")
91
+ return finish if isinstance(finish, str) else None
73
92
 
74
93
 
75
94
  def extract_content(body: Any) -> str:
@@ -86,6 +105,6 @@ def extract_content(body: Any) -> str:
86
105
  if isinstance(content, list):
87
106
  parts = [c.get("text", "") for c in content if isinstance(c, dict)]
88
107
  content = "".join(parts)
89
- if not isinstance(content, str) or not content.strip():
90
- raise SynthesisError("Model returned empty content")
108
+ if not isinstance(content, str):
109
+ return ""
91
110
  return content
@@ -11,6 +11,7 @@ BREACH_SOURCES = frozenset({"hibp_breaches", "xposed_or_not", "leakcheck_public"
11
11
  KEY_RE = re.compile(r"[^a-z0-9]+")
12
12
  CREDENTIAL_RE = re.compile(r"pass|hash|credential|secret|token|api[ _-]?key", re.I)
13
13
  SEVERITY = ("low", "medium", "high")
14
+ MAX_PROMPT_BREACHES = 15
14
15
 
15
16
 
16
17
  def signals_for(merged: list[dict[str, Any]]) -> dict[str, Any]:
@@ -66,7 +67,10 @@ def _records(result: CollectorResult) -> list[dict[str, Any]]:
66
67
  return []
67
68
 
68
69
 
69
- def merge_breaches(results: list[CollectorResult]) -> tuple[list[CollectorResult], int]:
70
+ def merge_breaches(
71
+ results: list[CollectorResult],
72
+ catalogue: dict[str, dict[str, Any]] | None = None,
73
+ ) -> tuple[list[CollectorResult], int]:
70
74
  """Merge duplicate breach names across sources.
71
75
 
72
76
  Returns the results to prompt with (breach sources replaced by one index)
@@ -88,18 +92,23 @@ def merge_breaches(results: list[CollectorResult]) -> tuple[list[CollectorResult
88
92
  if not name:
89
93
  continue
90
94
  key = _key(name)
95
+ known = (catalogue or {}).get(key) or {}
96
+ types = set(_strings(entry.get("data_types"))) or set(known.get("data_types") or [])
97
+ date = entry.get("date") or known.get("date")
91
98
  existing = merged.get(key)
92
99
  if existing is None:
93
100
  merged[key] = {
94
101
  "name": name,
95
- "date": entry.get("date"),
96
- "data_types": set(_strings(entry.get("data_types"))),
102
+ "date": date,
103
+ "data_types": types,
97
104
  "sources": {source},
105
+ "domain": known.get("domain"),
106
+ "pwn_count": known.get("pwn_count"),
98
107
  }
99
108
  continue
100
109
  duplicates += 1
101
- if not existing["date"] and entry.get("date"):
102
- existing["date"] = entry["date"]
110
+ if not existing["date"] and date:
111
+ existing["date"] = date
103
112
  existing["data_types"].update(_strings(entry.get("data_types")))
104
113
  existing["sources"].add(source)
105
114
 
@@ -110,13 +119,16 @@ def merge_breaches(results: list[CollectorResult]) -> tuple[list[CollectorResult
110
119
  {
111
120
  "name": item["name"],
112
121
  "date": item["date"],
113
- "data_types": sorted(item["data_types"]),
122
+ "data_types": sorted(item["data_types"])[:8],
114
123
  "sources": sorted(item["sources"]),
115
124
  "source": ", ".join(sorted(item["sources"])),
125
+ "domain": item.get("domain"),
126
+ "pwn_count": item.get("pwn_count"),
116
127
  }
117
128
  for item in merged.values()
118
129
  ]
119
130
  rendered.sort(key=lambda item: (-len(item["data_types"]), item["name"].lower()))
131
+ rendered = rendered[:MAX_PROMPT_BREACHES]
120
132
 
121
133
  index = CollectorResult(
122
134
  collector="breach_index",
@@ -142,7 +142,9 @@ def _recommendations(
142
142
  return recs
143
143
 
144
144
 
145
- def build(results: list[CollectorResult], report_notes: list[str], target: str) -> Analysis:
145
+ def build(
146
+ results: list[CollectorResult], report_notes: list[str], target: str, *, narrated: bool = False
147
+ ) -> Analysis:
146
148
  breaches_raw = next((r.data for r in results if r.collector == "breach_index" and r.has_data), [])
147
149
  signals = signals_for(breaches_raw)
148
150
  accounts = _found_accounts(results)
@@ -158,8 +160,8 @@ def build(results: list[CollectorResult], report_notes: list[str], target: str)
158
160
  failed = [r.collector for r in results if not r.ok and not r.skipped]
159
161
 
160
162
  summary_parts = [
161
- f"Collected public data for {target} from {len(collected)} of "
162
- f"{len(results)} sources, with no model call."
163
+ f"Collected public data for {target} from {len(collected)} of {len(results)} sources"
164
+ + (", with no model call." if not narrated else "; the model returned no summary.")
163
165
  ]
164
166
  if signals["breach_count"]:
165
167
  summary_parts.append(
@@ -174,7 +176,8 @@ def build(results: list[CollectorResult], report_notes: list[str], target: str)
174
176
  summary_parts.append(f"Failed sources: {', '.join(failed)}.")
175
177
  if skipped:
176
178
  summary_parts.append(f"Skipped sources: {', '.join(skipped)}.")
177
- summary_parts.append("Risk is computed from counts and policy checks, not narrated.")
179
+ if not narrated:
180
+ summary_parts.append("Risk is computed from counts and policy checks, not narrated.")
178
181
 
179
182
  risk = apply_floor("low", signals["risk_floor"])
180
183
  if risky or (wildcard and isinstance(mail, dict) and not mail.get("dmarc_present")):
@@ -0,0 +1,66 @@
1
+ """Pwned Passwords range check (k-anonymity, no key, no plaintext sent)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from .errors import OsintKitError
8
+ from .http import HttpClient, cache_hit, cache_put
9
+
10
+ NAMESPACE = "pwnedpasswords"
11
+ TTL = 24 * 3600
12
+ RANGE = "https://api.pwnedpasswords.com/range/"
13
+
14
+
15
+ def digest(password: str) -> tuple[str, str]:
16
+ sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
17
+ return sha1[:5], sha1[5:]
18
+
19
+
20
+ def parse_range(text: str, suffix: str) -> int:
21
+ for line in text.splitlines():
22
+ hash_part, _, count = line.partition(":")
23
+ if hash_part.strip().upper() == suffix:
24
+ try:
25
+ return int(count.strip())
26
+ except ValueError:
27
+ return 0
28
+ return 0
29
+
30
+
31
+ def rating(count: int) -> str:
32
+ if count == 0:
33
+ return "not seen"
34
+ if count < 100:
35
+ return "rare"
36
+ if count < 10_000:
37
+ return "common"
38
+ if count < 1_000_000:
39
+ return "very common"
40
+ return "extremely common"
41
+
42
+
43
+ async def check(http: HttpClient, password: str) -> dict[str, object]:
44
+ prefix, suffix = digest(password)
45
+ key = f"{prefix}:{suffix}"
46
+ cached = cache_hit(http.settings, NAMESPACE, key, TTL)
47
+ if cached is not None:
48
+ count = int(cached)
49
+ else:
50
+ response = await http.get(
51
+ f"{RANGE}{prefix}",
52
+ namespace=NAMESPACE,
53
+ accept="text/plain",
54
+ headers={"Add-Padding": "true"},
55
+ )
56
+ if response.status_code != 200:
57
+ raise OsintKitError(f"pwned passwords returned HTTP {response.status_code}")
58
+ count = parse_range(response.text, suffix)
59
+ cache_put(http.settings, NAMESPACE, key, count)
60
+ return {
61
+ "pwned": count > 0,
62
+ "count": count,
63
+ "rating": rating(count),
64
+ "prefix": prefix,
65
+ "checked_by": "api.pwnedpasswords.com",
66
+ }
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
 
15
15
  from rich.console import Console
16
16
 
17
- from . import collectors, normalise, offline, render, retention, synthesis
17
+ from . import catalogue, collectors, normalise, offline, render, retention, synthesis
18
18
  from .config import Settings
19
19
  from .errors import MissingAPIKey, OsintKitError, SynthesisError, TargetParseError
20
20
  from .http import HttpClient
@@ -158,9 +158,29 @@ async def execute(
158
158
  async with HttpClient(settings) as http:
159
159
  with _status(progress, settings, f"[cyan]running collectors for {target}…[/cyan]"):
160
160
  results = await collect(target, target_type, settings, logger, http, only, skip)
161
+ if target_type == "email" and "@" in target:
162
+ derived = target.split("@", 1)[1].lower()
163
+ domain_ctx = collectors.Context(
164
+ target=derived, target_type="domain", settings=settings, http=http
165
+ )
166
+ for name in ("dns_records", "dns_mail_posture", "whois_registration"):
167
+ spec = next((c for c in collectors.REGISTRY if c.name == name), None)
168
+ if spec is None or (only and name not in only) or (skip and name in skip):
169
+ continue
170
+ extra = await collectors.run_collector(spec, domain_ctx)
171
+ extra.note = "derived from the email domain"
172
+ results.append(extra)
161
173
  report.collectors = results
162
174
 
163
- prompt_results, duplicates = normalise.merge_breaches(results)
175
+ if target_type == "email":
176
+ try:
177
+ known = await catalogue.fetch(http)
178
+ except Exception:
179
+ known = {}
180
+ else:
181
+ known = {}
182
+
183
+ prompt_results, duplicates = normalise.merge_breaches(results, known)
164
184
  if duplicates:
165
185
  report.notes.append(f"{duplicates} duplicate breach records were merged across sources")
166
186
  logger.event("breaches_merged", duplicates=duplicates)
@@ -224,9 +244,19 @@ async def execute(
224
244
  report.analysis.risk_level = normalise.apply_floor(
225
245
  report.analysis.risk_level, signals.get("risk_floor", "low")
226
246
  )
247
+ if not report.analysis.summary.strip():
248
+ fallback = offline.build(prompt_results, report.notes, target, narrated=True)
249
+ report.analysis.summary = fallback.summary
250
+ report.notes.append("summary filled locally: the model returned none")
227
251
  except (OsintKitError, SynthesisError) as exc:
228
252
  report.notes.append(f"synthesis failed: {exc}")
253
+ report.notes.append("report built locally instead")
229
254
  logger.event("run_error", error=str(exc))
255
+ report.model = None
256
+ report.analysis = offline.build(prompt_results, report.notes, target, narrated=True)
257
+ report.analysis.risk_level = normalise.apply_floor(
258
+ report.analysis.risk_level, signals.get("risk_floor", "low")
259
+ )
230
260
  report.elapsed_ms = int((time.perf_counter() - started) * 1000)
231
261
  path = archive_report(report.model_dump(mode="json"))
232
262
  return Outcome(report=report, error=str(exc), archived=path)
@@ -20,7 +20,7 @@ authorised research.
20
20
 
21
21
  Rules:
22
22
  - Cite the supplied source name in every breach entry. Mark dates as found; use null when unknown.
23
- - If a source reports more breaches than it listed, say so and keep the 15 most material, ordering
23
+ - If a source reports more breaches than it listed, say so and Take only the 15 most material, ordering
24
24
  credential-bearing and recent incidents first.
25
25
  - Distinguish confirmed findings from plausible inference in the summary and notes.
26
26
  - Risk level reflects exposure only: high means credential-bearing breaches or a wide,
@@ -0,0 +1,70 @@
1
+ import httpx
2
+ import respx
3
+
4
+ from osintkit import catalogue
5
+ from osintkit.http import HttpClient
6
+
7
+ HIBP = "https://haveibeenpwned.com/api/v3/breaches"
8
+ XPOSED = "https://api.xposedornot.com/v1/breaches"
9
+
10
+
11
+ @respx.mock
12
+ async def test_hibp_catalogue_is_parsed(http):
13
+ respx.get(HIBP).mock(
14
+ return_value=httpx.Response(
15
+ 200,
16
+ json=[
17
+ {
18
+ "Name": "Acme Corp",
19
+ "BreachDate": "2020-09-01",
20
+ "DataClasses": ["Emails"],
21
+ "Domain": "acme.test",
22
+ "PwnCount": 100,
23
+ }
24
+ ],
25
+ )
26
+ )
27
+ data = await catalogue.fetch(http)
28
+ assert data["acmecorp"]["date"] == "2020-09-01"
29
+ assert data["acmecorp"]["data_types"] == ["Emails"]
30
+
31
+
32
+ @respx.mock
33
+ async def test_xposed_is_the_fallback(http):
34
+ respx.get(HIBP).mock(return_value=httpx.Response(503))
35
+ respx.get(XPOSED).mock(
36
+ return_value=httpx.Response(
37
+ 200,
38
+ json={
39
+ "exposedBreaches": [
40
+ {"breachID": "Acme", "breachedDate": "2019-05-05T00:00:00+00:00", "exposedData": ["Emails"]}
41
+ ]
42
+ },
43
+ )
44
+ )
45
+ data = await catalogue.fetch(http)
46
+ assert data["acme"]["date"] == "2019-05-05"
47
+
48
+
49
+ @respx.mock
50
+ async def test_both_failing_is_empty(http):
51
+ respx.get(HIBP).mock(return_value=httpx.Response(503))
52
+ respx.get(XPOSED).mock(return_value=httpx.Response(503))
53
+ assert await catalogue.fetch(http) == {}
54
+
55
+
56
+ @respx.mock
57
+ async def test_result_is_cached(settings):
58
+ settings.use_cache = True
59
+ async with HttpClient(settings) as client:
60
+ route = respx.get(HIBP).mock(
61
+ return_value=httpx.Response(200, json=[{"Name": "Acme", "BreachDate": "2020-01-01"}])
62
+ )
63
+ await catalogue.fetch(client)
64
+ await catalogue.fetch(client)
65
+ assert route.call_count == 1
66
+
67
+
68
+ def test_null_rows_are_skipped():
69
+ assert catalogue._from_hibp([None, {}, {"Name": ""}]) == {}
70
+ assert catalogue._from_xposed({"exposedBreaches": [None, {"breachID": ""}]}) == {}
@@ -618,3 +618,28 @@ def test_flag_beats_stored_config(monkeypatch, xdg):
618
618
  monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
619
619
  result = runner.invoke(app, ["--model", "flag-model", "lookup", "--domain", "a.com", "--json"])
620
620
  assert jsonish(result.output)["model"] == "flag-model"
621
+
622
+
623
+ def test_password_command_hit_and_miss(monkeypatch):
624
+ import httpx
625
+ import respx
626
+
627
+ from osintkit import passwords
628
+
629
+ prefix, suffix = passwords.digest("hunter2")
630
+ miss_prefix = passwords.digest("hunter3")[0]
631
+ with respx.mock:
632
+ respx.get(f"https://api.pwnedpasswords.com/range/{prefix}").mock(
633
+ return_value=httpx.Response(200, text=f"{suffix}:5")
634
+ )
635
+ hit = runner.invoke(app, ["password", "hunter2"])
636
+ assert hit.exit_code == 1
637
+ assert "pwned" in hit.output
638
+
639
+ with respx.mock:
640
+ respx.get(f"https://api.pwnedpasswords.com/range/{miss_prefix}").mock(
641
+ return_value=httpx.Response(200, text="")
642
+ )
643
+ miss = runner.invoke(app, ["password", "hunter3", "--json"])
644
+ assert miss.exit_code == 0
645
+ assert jsonish(miss.output)["pwned"] is False
@@ -62,7 +62,7 @@ async def test_run_survives_wrapped_code_fence(settings, monkeypatch, console):
62
62
  def test_settings_defaults_match_spec():
63
63
  settings = Settings()
64
64
  assert settings.temperature == 0.3
65
- assert settings.max_tokens == 2400
65
+ assert settings.max_tokens == 4000
66
66
  assert settings.base_url == "https://api.venice.ai/api/v1"
67
67
  assert settings.model == "deepseek-v4-1-flash"
68
68
  assert settings.max_attempts == 3
@@ -74,12 +74,16 @@ def test_extract_content_surfaces_api_error():
74
74
  extract_content({"error": {"message": "quota exceeded"}})
75
75
 
76
76
 
77
- @pytest.mark.parametrize("body", [{}, {"choices": []}, {"choices": [{}]}, "nope"])
77
+ @pytest.mark.parametrize("body", [{}, {"choices": []}, "nope"])
78
78
  def test_extract_content_rejects_bad_shapes(body):
79
79
  with pytest.raises(SynthesisError):
80
80
  extract_content(body)
81
81
 
82
82
 
83
+ def test_extract_content_allows_empty_message():
84
+ assert extract_content({"choices": [{}]}) == ""
85
+
86
+
83
87
  @respx.mock
84
88
  async def test_rejected_key_becomes_actionable_error(settings):
85
89
  respx.post(URL).mock(return_value=httpx.Response(401))
@@ -109,3 +113,31 @@ async def test_keyless_provider_sends_no_auth_header(settings):
109
113
  async with LLMClient(settings) as llm:
110
114
  assert await llm.chat([{"role": "user", "content": "x"}]) == "ok"
111
115
  assert "authorization" not in route.calls[0].request.headers
116
+
117
+
118
+ @respx.mock
119
+ async def test_truncated_empty_reply_is_retried_with_more_tokens(settings):
120
+ route = respx.post(URL).mock(
121
+ side_effect=[
122
+ httpx.Response(200, json={"choices": [{"finish_reason": "length", "message": {"content": ""}}]}),
123
+ httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": "ok"}}]}),
124
+ ]
125
+ )
126
+ async with client(settings) as llm:
127
+ assert await llm.chat([{"role": "user", "content": "x"}], max_tokens=2400) == "ok"
128
+ import json as _json
129
+
130
+ budgets = [_json.loads(call.request.content)["max_tokens"] for call in route.calls]
131
+ assert budgets == [2400, 4800]
132
+
133
+
134
+ @respx.mock
135
+ async def test_truncation_without_content_gives_up(settings):
136
+ respx.post(URL).mock(
137
+ return_value=httpx.Response(
138
+ 200, json={"choices": [{"finish_reason": "length", "message": {"content": ""}}]}
139
+ )
140
+ )
141
+ async with client(settings) as llm:
142
+ with pytest.raises(SynthesisError, match="ran out of tokens"):
143
+ await llm.chat([{"role": "user", "content": "x"}], max_tokens=8000)
@@ -97,3 +97,9 @@ def test_empty_collection_still_yields_a_report():
97
97
  assert analysis.risk_level == "low"
98
98
  assert analysis.breaches == []
99
99
  assert analysis.recommendations
100
+
101
+
102
+ def test_narrated_summary_wording():
103
+ analysis = offline.build([], [], "example.com", narrated=True)
104
+ assert "model returned no summary" in analysis.summary
105
+ assert "not narrated" not in analysis.summary
@@ -0,0 +1,58 @@
1
+ import hashlib
2
+
3
+ import httpx
4
+ import respx
5
+
6
+ from osintkit import passwords
7
+
8
+ PASSWORD = "hunter2"
9
+ SHA = hashlib.sha1(PASSWORD.encode()).hexdigest().upper()
10
+
11
+
12
+ def test_digest_splits_the_hash():
13
+ prefix, suffix = passwords.digest(PASSWORD)
14
+ assert prefix == SHA[:5]
15
+ assert suffix == SHA[5:]
16
+
17
+
18
+ def test_parse_range_matches_suffix_only():
19
+ text = f"{'A' * 35}:5\r\n{SHA[5:]}:42\r\n"
20
+ assert passwords.parse_range(text, SHA[5:]) == 42
21
+ assert passwords.parse_range(text, "F" * 35) == 0
22
+
23
+
24
+ def test_rating_bands():
25
+ assert passwords.rating(0) == "not seen"
26
+ assert passwords.rating(10) == "rare"
27
+ assert passwords.rating(500) == "common"
28
+ assert passwords.rating(50_000) == "very common"
29
+ assert passwords.rating(9_000_000) == "extremely common"
30
+
31
+
32
+ @respx.mock
33
+ async def test_check_reports_hits(http):
34
+ respx.get(f"https://api.pwnedpasswords.com/range/{SHA[:5]}").mock(
35
+ return_value=httpx.Response(200, text=f"{SHA[5:]}:42\r\n{'B' * 35}:1\r\n")
36
+ )
37
+ result = await passwords.check(http, PASSWORD)
38
+ assert result["pwned"] is True
39
+ assert result["count"] == 42
40
+
41
+
42
+ @respx.mock
43
+ async def test_check_reports_misses(http):
44
+ respx.get(f"https://api.pwnedpasswords.com/range/{SHA[:5]}").mock(
45
+ return_value=httpx.Response(200, text=f"{'B' * 35}:1\r\n")
46
+ )
47
+ result = await passwords.check(http, PASSWORD)
48
+ assert result["pwned"] is False
49
+
50
+
51
+ @respx.mock
52
+ async def test_range_request_sends_only_the_prefix(http):
53
+ route = respx.get(f"https://api.pwnedpasswords.com/range/{SHA[:5]}").mock(
54
+ return_value=httpx.Response(200, text="")
55
+ )
56
+ await passwords.check(http, PASSWORD)
57
+ assert route.calls[0].request.headers["add-padding"] == "true"
58
+ assert PASSWORD not in str(route.calls[0].request.url)
@@ -18,6 +18,14 @@ VALID = {
18
18
  }
19
19
 
20
20
 
21
+ @pytest.fixture(autouse=True)
22
+ async def no_catalogue(monkeypatch):
23
+ async def empty(_http):
24
+ return {}
25
+
26
+ monkeypatch.setattr(pipeline.catalogue, "fetch", empty)
27
+
28
+
21
29
  @pytest.fixture
22
30
  def fake_collector(monkeypatch):
23
31
  async def fake(ctx):
@@ -158,7 +166,7 @@ async def test_duplicate_breaches_are_merged_before_prompting(settings, monkeypa
158
166
  assert "breach_index" in prompt["user"]
159
167
  assert prompt["user"].count('"name": "Acme"') == 1
160
168
  assert '"sources": [' in prompt["user"]
161
- assert [r.collector for r in outcome.report.collectors] == ["hibp_breaches", "xposed_or_not"]
169
+ assert [r.collector for r in outcome.report.collectors][:2] == ["hibp_breaches", "xposed_or_not"]
162
170
 
163
171
 
164
172
  async def test_risk_floor_overrides_a_timid_model(settings, monkeypatch, console):
@@ -194,3 +202,46 @@ async def test_offline_builds_analysis_without_model(settings, fake_collector, m
194
202
  assert outcome.report.analysis is not None
195
203
  assert outcome.report.model is None
196
204
  assert any("offline" in note for note in outcome.report.notes)
205
+
206
+
207
+ async def test_empty_summary_is_backfilled(settings, fake_collector, monkeypatch, console):
208
+ class Empty(FakeLLM):
209
+ async def chat(self, messages, **kwargs):
210
+ return json.dumps({**VALID, "summary": " "})
211
+
212
+ monkeypatch.setattr(pipeline, "LLMClient", Empty)
213
+ outcome = await pipeline.execute("example.com", settings, RunLogger("sum"), console)
214
+ assert outcome.report.analysis.summary
215
+ assert any("filled locally" in note for note in outcome.report.notes)
216
+
217
+
218
+ async def test_failed_synthesis_still_yields_a_local_report(settings, fake_collector, monkeypatch, console):
219
+ class Broken(FakeLLM):
220
+ async def chat(self, messages, **kwargs):
221
+ raise SynthesisError("no dice")
222
+
223
+ monkeypatch.setattr(pipeline, "LLMClient", Broken)
224
+ outcome = await pipeline.execute("example.com", settings, RunLogger("fb"), console)
225
+ assert outcome.error == "no dice"
226
+ assert outcome.report.analysis is not None
227
+ assert any("built locally" in note for note in outcome.report.notes)
228
+
229
+
230
+ async def test_email_pulls_its_domain(settings, monkeypatch, console):
231
+ calls = []
232
+
233
+ async def fake(ctx):
234
+ calls.append((ctx.target, ctx.target_type))
235
+ return {"A": ["1.2.3.4"]}
236
+
237
+ monkeypatch.setattr(
238
+ base,
239
+ "REGISTRY",
240
+ [base.Collector(name="dns_records", target_types=("domain",), func=fake)],
241
+ )
242
+ monkeypatch.setattr("osintkit.collectors.REGISTRY", base.REGISTRY)
243
+ monkeypatch.setattr(pipeline, "LLMClient", FakeLLM)
244
+ outcome = await pipeline.execute("alice@example.com", settings, RunLogger("dom"), console)
245
+ assert ("example.com", "domain") in calls
246
+ derived = [c for c in outcome.report.collectors if c.note == "derived from the email domain"]
247
+ assert derived and derived[0].ok
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes