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.
- {osintkit-0.4.0 → osintkit-0.5.0}/CHANGELOG.md +15 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/PKG-INFO +18 -3
- {osintkit-0.4.0 → osintkit-0.5.0}/README.md +17 -2
- {osintkit-0.4.0 → osintkit-0.5.0}/docs/REFERENCE.md +13 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/pyproject.toml +1 -1
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/__init__.py +1 -1
- osintkit-0.5.0/src/osintkit/catalogue.py +94 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/cli.py +46 -2
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/config.py +1 -1
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/llm.py +23 -4
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/normalise.py +18 -6
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/offline.py +7 -4
- osintkit-0.5.0/src/osintkit/passwords.py +66 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/pipeline.py +32 -2
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/synthesis.py +1 -1
- osintkit-0.5.0/tests/test_catalogue.py +70 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_cli.py +25 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_e2e.py +1 -1
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_llm.py +33 -1
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_offline.py +6 -0
- osintkit-0.5.0/tests/test_passwords.py +58 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_pipeline.py +52 -1
- {osintkit-0.4.0 → osintkit-0.5.0}/.editorconfig +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/CODEOWNERS +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/dependabot.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/pull_request_template.md +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/ci.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/codeql.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/publish.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/release.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.github/workflows/smoke.yml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.gitignore +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.mailmap +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/.pre-commit-config.yaml +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/CONTRIBUTING.md +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/LICENSE +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/SECURITY.md +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/docs/architecture.md +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/docs/demo.svg +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/docs/sample-output.md +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/docs/social-preview.png +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/examples/README.md +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/examples/targets.csv +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/__init__.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/accounts.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/base.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/breaches.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/ct.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/dns.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/github.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/pastes.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/ports.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/subdomains.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/collectors/whois_lookup.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/diff.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/doctor.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/errors.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/http.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/logging_store.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/models.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/py.typed +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/render.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/retention.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/targets.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/src/osintkit/userconfig.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/conftest.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/fixtures/model_reply.json +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_accounts.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_breaches.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_collectors.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_config.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_ct.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_diff.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_doctor.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_http.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_normalise.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_pastes.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_render.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_retention.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_synthesis.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_targets.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_userconfig.py +0 -0
- {osintkit-0.4.0 → osintkit-0.5.0}/tests/test_whois.py +0 -0
- {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.
|
|
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.**
|
|
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.**
|
|
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
|
|
@@ -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(
|
|
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 =
|
|
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 =
|
|
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)
|
|
90
|
-
|
|
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(
|
|
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":
|
|
96
|
-
"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
|
|
102
|
-
existing["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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 ==
|
|
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": []},
|
|
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
|
|
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
|
|
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
|