osintkit 0.3.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.3.0 → osintkit-0.5.0}/CHANGELOG.md +24 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/PKG-INFO +29 -10
- {osintkit-0.3.0 → osintkit-0.5.0}/README.md +28 -9
- {osintkit-0.3.0 → osintkit-0.5.0}/docs/REFERENCE.md +26 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/examples/README.md +7 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/pyproject.toml +1 -1
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/__init__.py +1 -1
- osintkit-0.5.0/src/osintkit/catalogue.py +94 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/cli.py +161 -4
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/config.py +8 -1
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/doctor.py +7 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/llm.py +23 -4
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/normalise.py +18 -6
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/offline.py +7 -4
- osintkit-0.5.0/src/osintkit/passwords.py +66 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/pipeline.py +32 -2
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/synthesis.py +1 -1
- osintkit-0.5.0/src/osintkit/userconfig.py +94 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/conftest.py +1 -0
- osintkit-0.5.0/tests/test_catalogue.py +70 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_cli.py +135 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_e2e.py +1 -1
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_llm.py +33 -1
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_offline.py +6 -0
- osintkit-0.5.0/tests/test_passwords.py +58 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_pipeline.py +52 -1
- osintkit-0.5.0/tests/test_userconfig.py +59 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.editorconfig +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/CODEOWNERS +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/dependabot.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/pull_request_template.md +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/ci.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/codeql.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/publish.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/release.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/smoke.yml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.gitignore +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.mailmap +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/.pre-commit-config.yaml +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/CODE_OF_CONDUCT.md +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/CONTRIBUTING.md +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/LICENSE +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/SECURITY.md +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/docs/architecture.md +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/docs/demo.svg +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/docs/sample-output.md +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/docs/social-preview.png +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/examples/targets.csv +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/__init__.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/accounts.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/base.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/breaches.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/ct.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/dns.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/github.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/pastes.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/ports.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/subdomains.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/whois_lookup.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/diff.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/errors.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/http.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/logging_store.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/models.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/py.typed +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/render.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/retention.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/targets.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/fixtures/model_reply.json +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_accounts.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_breaches.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_collectors.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_config.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_ct.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_diff.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_doctor.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_http.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_normalise.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_pastes.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_render.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_retention.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_synthesis.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_targets.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_whois.py +0 -0
- {osintkit-0.3.0 → osintkit-0.5.0}/uv.lock +0 -0
|
@@ -6,6 +6,30 @@ 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
|
+
|
|
24
|
+
## 0.4.0 — 2026-11-02
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `osintkit init` stores a provider and key once, verifies them, and needs no flags afterwards
|
|
29
|
+
- `osintkit config` shows values masked, reveals the key, unsets keys, and prints the path
|
|
30
|
+
- stored config participates in provider/key precedence below flags and environment
|
|
31
|
+
- `doctor` reports whether a config file exists
|
|
32
|
+
|
|
9
33
|
## 0.3.0 — 2026-11-02
|
|
10
34
|
|
|
11
35
|
### 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
|
|
@@ -74,20 +74,24 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
|
|
|
74
74
|
## Quick start
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
|
-
|
|
78
|
-
osintkit doctor --deep # prove the key and endpoint work
|
|
77
|
+
osintkit init # pick a provider, paste a key once
|
|
79
78
|
osintkit lookup alice@example.com
|
|
80
79
|
```
|
|
81
80
|
|
|
81
|
+
`init` stores the provider and key in `~/.config/osintkit/config.toml` (mode 600), checks them
|
|
82
|
+
with a one-token request, and afterwards needs no flags and no exports. `osintkit config` shows
|
|
83
|
+
what is stored, masked; `osintkit lookup` just works.
|
|
84
|
+
|
|
85
|
+
Environment variables still win over the file, so `DEEPSEEK_API_KEY=... osintkit lookup ...`
|
|
86
|
+
overrides the stored provider for one run.
|
|
87
|
+
|
|
82
88
|
No key? `osintkit lookup example.com --offline` builds the whole report locally from rule checks,
|
|
83
89
|
no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
|
|
84
90
|
would receive.
|
|
85
91
|
|
|
86
|
-
Any OpenAI-compatible endpoint works
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
`ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
|
|
90
|
-
preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
|
|
92
|
+
Any OpenAI-compatible endpoint works. `init --provider ollama` needs no key for a local server;
|
|
93
|
+
`--provider`, `--base-url`, and `--model` cover everything else. The preset table, key variables,
|
|
94
|
+
and precedence order are in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
|
|
91
95
|
|
|
92
96
|
Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
|
|
93
97
|
5000 requests/hour. Everything else needs no credentials.
|
|
@@ -99,6 +103,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
|
|
|
99
103
|
| `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`) |
|
|
100
104
|
| `batch` | A CSV of targets, run concurrently, reported in order |
|
|
101
105
|
| `diff` | What changed between two archived runs |
|
|
106
|
+
| `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
|
|
102
107
|
| `history` | Past runs, replayable by id |
|
|
103
108
|
| `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
|
|
104
109
|
| `collectors` | Every collector and what it touches |
|
|
@@ -128,8 +133,11 @@ target -> type detection -> collectors (parallel, cached, retried)
|
|
|
128
133
|
Collectors degrade to a logged gap, never a dead run; a report is produced even when sources or
|
|
129
134
|
the model fail.
|
|
130
135
|
|
|
131
|
-
- **Merged breaches.**
|
|
132
|
-
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.
|
|
133
141
|
- **Risk floor.** Breach count and credential types set a floor the model may exceed but never
|
|
134
142
|
undercut.
|
|
135
143
|
- **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
|
|
@@ -144,6 +152,17 @@ connect probes, GitHub (profile, commits, user search, org repos), psbdmp with a
|
|
|
144
152
|
grep.app, Hacker News, Stack Exchange, and username presence. The full table is in
|
|
145
153
|
[docs/REFERENCE.md](docs/REFERENCE.md#collectors) or via `osintkit collectors`.
|
|
146
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
|
+
|
|
147
166
|
## Safety and legality
|
|
148
167
|
|
|
149
168
|
- Public data only. No logins, no scraping behind auth, no paid brokers.
|
|
@@ -42,20 +42,24 @@ checksums, and `pip install -e .` works from a clone. Python 3.12+ only.
|
|
|
42
42
|
## Quick start
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
46
|
-
osintkit doctor --deep # prove the key and endpoint work
|
|
45
|
+
osintkit init # pick a provider, paste a key once
|
|
47
46
|
osintkit lookup alice@example.com
|
|
48
47
|
```
|
|
49
48
|
|
|
49
|
+
`init` stores the provider and key in `~/.config/osintkit/config.toml` (mode 600), checks them
|
|
50
|
+
with a one-token request, and afterwards needs no flags and no exports. `osintkit config` shows
|
|
51
|
+
what is stored, masked; `osintkit lookup` just works.
|
|
52
|
+
|
|
53
|
+
Environment variables still win over the file, so `DEEPSEEK_API_KEY=... osintkit lookup ...`
|
|
54
|
+
overrides the stored provider for one run.
|
|
55
|
+
|
|
50
56
|
No key? `osintkit lookup example.com --offline` builds the whole report locally from rule checks,
|
|
51
57
|
no model involved. `--raw` gives the collected JSON and `--dry-run` prints the prompt a model
|
|
52
58
|
would receive.
|
|
53
59
|
|
|
54
|
-
Any OpenAI-compatible endpoint works
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
`ollama` for a local server with no key. `--base-url` and `--model` always win, and the full
|
|
58
|
-
preset table is in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
|
|
60
|
+
Any OpenAI-compatible endpoint works. `init --provider ollama` needs no key for a local server;
|
|
61
|
+
`--provider`, `--base-url`, and `--model` cover everything else. The preset table, key variables,
|
|
62
|
+
and precedence order are in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
|
|
59
63
|
|
|
60
64
|
Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
|
|
61
65
|
5000 requests/hour. Everything else needs no credentials.
|
|
@@ -67,6 +71,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
|
|
|
67
71
|
| `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`) |
|
|
68
72
|
| `batch` | A CSV of targets, run concurrently, reported in order |
|
|
69
73
|
| `diff` | What changed between two archived runs |
|
|
74
|
+
| `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
|
|
70
75
|
| `history` | Past runs, replayable by id |
|
|
71
76
|
| `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
|
|
72
77
|
| `collectors` | Every collector and what it touches |
|
|
@@ -96,8 +101,11 @@ target -> type detection -> collectors (parallel, cached, retried)
|
|
|
96
101
|
Collectors degrade to a logged gap, never a dead run; a report is produced even when sources or
|
|
97
102
|
the model fail.
|
|
98
103
|
|
|
99
|
-
- **Merged breaches.**
|
|
100
|
-
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.
|
|
101
109
|
- **Risk floor.** Breach count and credential types set a floor the model may exceed but never
|
|
102
110
|
undercut.
|
|
103
111
|
- **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
|
|
@@ -112,6 +120,17 @@ connect probes, GitHub (profile, commits, user search, org repos), psbdmp with a
|
|
|
112
120
|
grep.app, Hacker News, Stack Exchange, and username presence. The full table is in
|
|
113
121
|
[docs/REFERENCE.md](docs/REFERENCE.md#collectors) or via `osintkit collectors`.
|
|
114
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
|
+
|
|
115
134
|
## Safety and legality
|
|
116
135
|
|
|
117
136
|
- Public data only. No logins, no scraping behind auth, no paid brokers.
|
|
@@ -61,6 +61,32 @@ 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
|
+
|
|
77
|
+
## Configuration file
|
|
78
|
+
|
|
79
|
+
`osintkit init` writes `~/.config/osintkit/config.toml` (or `$XDG_CONFIG_HOME`) with mode 600 in
|
|
80
|
+
a 700 directory, after checking the key with a one-token request. Keys recognised:
|
|
81
|
+
`provider`, `model`, `base_url`, `api_key`, `resolvers`.
|
|
82
|
+
|
|
83
|
+
`osintkit config` prints them masked, `--reveal` shows the key, `--unset KEY` removes one, and
|
|
84
|
+
`--path` prints the location. Deleting the file is the same as `--unset` everything.
|
|
85
|
+
|
|
86
|
+
Precedence for each value: command-line flag, then the provider-specific environment variable,
|
|
87
|
+
then a key prefix, then the config file, then the built-in default. Stored values apply only when
|
|
88
|
+
the stored provider matches the one in use, so a one-off key export never gets the stored key.
|
|
89
|
+
|
|
64
90
|
## Offline mode
|
|
65
91
|
|
|
66
92
|
`--offline` skips the model: breaches, accounts, hosts, and policy gaps are rendered from the
|
|
@@ -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,14 +18,17 @@ 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
|
|
21
|
+
from . import __version__, diff, doctor, passwords, pipeline, render, retention, userconfig
|
|
22
22
|
from .collectors import REGISTRY
|
|
23
23
|
from .config import (
|
|
24
|
+
DEFAULT_RESOLVERS,
|
|
24
25
|
GENERIC_KEY_ENVS,
|
|
26
|
+
KEYLESS_PROVIDERS,
|
|
25
27
|
PROVIDERS,
|
|
26
28
|
Settings,
|
|
27
29
|
cache_clear,
|
|
28
30
|
cache_stats,
|
|
31
|
+
env_key_for,
|
|
29
32
|
provider_from_env,
|
|
30
33
|
provider_from_key,
|
|
31
34
|
)
|
|
@@ -71,7 +74,7 @@ def root(
|
|
|
71
74
|
model: str | None = typer.Option(None, "--model", help="Model name (overrides the provider default)."),
|
|
72
75
|
api_key: str | None = typer.Option(None, "--api-key", help="API key (or set API_KEY)."),
|
|
73
76
|
temperature: float = typer.Option(0.3, "--temperature", min=0.0, max=2.0),
|
|
74
|
-
max_tokens: int = typer.Option(
|
|
77
|
+
max_tokens: int = typer.Option(4000, "--max-tokens", min=256, max=16000),
|
|
75
78
|
timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds."),
|
|
76
79
|
max_connections: int = typer.Option(16, "--max-connections", min=1, max=64),
|
|
77
80
|
max_attempts: int = typer.Option(3, "--max-attempts", min=1, max=6),
|
|
@@ -90,19 +93,24 @@ def root(
|
|
|
90
93
|
) -> None:
|
|
91
94
|
if provider and provider not in PROVIDERS:
|
|
92
95
|
raise typer.BadParameter(f"unknown provider {provider!r}; choose from {', '.join(PROVIDERS)}")
|
|
96
|
+
stored = userconfig.load()
|
|
93
97
|
generic_env = next((os.environ[name] for name in GENERIC_KEY_ENVS if os.environ.get(name)), None)
|
|
94
98
|
chosen = (
|
|
95
99
|
provider
|
|
96
100
|
or provider_from_key(api_key)
|
|
97
101
|
or provider_from_env()
|
|
98
102
|
or provider_from_key(generic_env)
|
|
103
|
+
or stored.get("provider")
|
|
99
104
|
or "venice"
|
|
100
105
|
)
|
|
101
106
|
preset = PROVIDERS[chosen]
|
|
107
|
+
if not api_key and stored.get("provider") in (None, chosen):
|
|
108
|
+
api_key = stored.get("api_key")
|
|
109
|
+
config_applies = stored.get("provider") in (None, chosen)
|
|
102
110
|
settings = Settings(
|
|
103
111
|
provider=chosen,
|
|
104
|
-
base_url=base_url or preset[0],
|
|
105
|
-
model=model or preset[1],
|
|
112
|
+
base_url=base_url or (stored.get("base_url") if config_applies else None) or preset[0],
|
|
113
|
+
model=model or (stored.get("model") if config_applies else None) or preset[1],
|
|
106
114
|
api_key=api_key,
|
|
107
115
|
temperature=temperature,
|
|
108
116
|
max_tokens=max_tokens,
|
|
@@ -112,6 +120,7 @@ def root(
|
|
|
112
120
|
use_cache=not no_cache,
|
|
113
121
|
cache_ttl=cache_ttl,
|
|
114
122
|
verbose=verbose,
|
|
123
|
+
resolvers=stored.get("resolvers") or list(DEFAULT_RESOLVERS),
|
|
115
124
|
)
|
|
116
125
|
if resolver:
|
|
117
126
|
for entry in resolver:
|
|
@@ -547,6 +556,154 @@ def list_collectors(
|
|
|
547
556
|
render.console().print(table)
|
|
548
557
|
|
|
549
558
|
|
|
559
|
+
@app.command()
|
|
560
|
+
def init(
|
|
561
|
+
provider: str | None = typer.Option(None, "--provider", help="Provider to store."),
|
|
562
|
+
api_key: str | None = typer.Option(None, "--api-key", help="Key to store."),
|
|
563
|
+
model: str | None = typer.Option(None, "--model", help="Default model to store."),
|
|
564
|
+
base_url: str | None = typer.Option(None, "--base-url", help="Custom endpoint to store."),
|
|
565
|
+
force: bool = typer.Option(False, "--force", help="Replace an existing config."),
|
|
566
|
+
verify: bool = typer.Option(True, "--verify/--no-verify", help="Check the key afterwards."),
|
|
567
|
+
) -> None:
|
|
568
|
+
"""Store a provider and key once, then just run lookups."""
|
|
569
|
+
out = _console()
|
|
570
|
+
path = userconfig.config_path()
|
|
571
|
+
if path.exists() and not force:
|
|
572
|
+
out.print(f"[yellow]config already exists[/yellow] {path}; pass --force to replace")
|
|
573
|
+
raise typer.Exit(1)
|
|
574
|
+
|
|
575
|
+
stored = userconfig.load()
|
|
576
|
+
detected = (
|
|
577
|
+
provider
|
|
578
|
+
or provider_from_env()
|
|
579
|
+
or provider_from_key(os.environ.get("API_KEY"))
|
|
580
|
+
or stored.get("provider")
|
|
581
|
+
or "venice"
|
|
582
|
+
)
|
|
583
|
+
chosen = provider or typer.prompt("Provider", default=detected).strip()
|
|
584
|
+
if chosen not in PROVIDERS:
|
|
585
|
+
out.print(f"[red]unknown provider {chosen!r}[/red]; choose from {', '.join(PROVIDERS)}")
|
|
586
|
+
raise typer.Exit(2)
|
|
587
|
+
|
|
588
|
+
data: dict[str, object] = {"provider": chosen}
|
|
589
|
+
if base_url:
|
|
590
|
+
data["base_url"] = base_url
|
|
591
|
+
if model:
|
|
592
|
+
data["model"] = model
|
|
593
|
+
|
|
594
|
+
if chosen not in KEYLESS_PROVIDERS:
|
|
595
|
+
key = (
|
|
596
|
+
api_key
|
|
597
|
+
or env_key_for(chosen)
|
|
598
|
+
or (stored.get("api_key") if stored.get("provider") == chosen else None)
|
|
599
|
+
or typer.prompt("API key", hide_input=True).strip()
|
|
600
|
+
)
|
|
601
|
+
if not key:
|
|
602
|
+
out.print("[red]no key given[/red]")
|
|
603
|
+
raise typer.Exit(2)
|
|
604
|
+
data["api_key"] = key
|
|
605
|
+
|
|
606
|
+
written = userconfig.save(data)
|
|
607
|
+
out.print(f"[green]stored[/green] {written} (mode 600)")
|
|
608
|
+
out.print(f"provider: {chosen}")
|
|
609
|
+
if data.get("api_key"):
|
|
610
|
+
out.print(f"key: {userconfig.mask(str(data['api_key']))}")
|
|
611
|
+
|
|
612
|
+
if verify:
|
|
613
|
+
probe = Settings(
|
|
614
|
+
provider=chosen,
|
|
615
|
+
base_url=str(data.get("base_url") or PROVIDERS[chosen][0]),
|
|
616
|
+
model=str(data.get("model") or PROVIDERS[chosen][1]),
|
|
617
|
+
api_key=str(data["api_key"]) if data.get("api_key") else None,
|
|
618
|
+
use_cache=False,
|
|
619
|
+
)
|
|
620
|
+
checks = asyncio.run(doctor.diagnose(probe, deep=True))
|
|
621
|
+
endpoint = checks[-1]
|
|
622
|
+
if doctor.failed(checks):
|
|
623
|
+
out.print(f"[red]{endpoint.detail}[/red]")
|
|
624
|
+
out.print("[dim]config kept; fix it with osintkit init --force[/dim]")
|
|
625
|
+
raise typer.Exit(1)
|
|
626
|
+
out.print(f"[green]{endpoint.detail}[/green]")
|
|
627
|
+
out.print("[dim]try: osintkit lookup --domain example.com[/dim]")
|
|
628
|
+
|
|
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
|
+
|
|
674
|
+
@app.command("config")
|
|
675
|
+
def config_cmd(
|
|
676
|
+
reveal: bool = typer.Option(False, "--reveal", help="Show the key in full."),
|
|
677
|
+
unset: list[str] | None = typer.Option(None, "--unset", help="Remove a key."),
|
|
678
|
+
path_only: bool = typer.Option(False, "--path", help="Print the config path only."),
|
|
679
|
+
) -> None:
|
|
680
|
+
"""Show, reveal, or clear the stored configuration."""
|
|
681
|
+
out = _console()
|
|
682
|
+
if path_only:
|
|
683
|
+
print(userconfig.config_path())
|
|
684
|
+
return
|
|
685
|
+
stored = userconfig.load()
|
|
686
|
+
if unset:
|
|
687
|
+
for name in unset:
|
|
688
|
+
stored.pop(name, None)
|
|
689
|
+
userconfig.save(stored)
|
|
690
|
+
out.print(f"[green]removed[/green] {', '.join(unset)}")
|
|
691
|
+
stored = userconfig.load()
|
|
692
|
+
if not stored:
|
|
693
|
+
out.print(f"[dim]no config at {userconfig.config_path()}[/dim]")
|
|
694
|
+
return
|
|
695
|
+
out.print(f"[dim]{userconfig.config_path()}[/dim]")
|
|
696
|
+
table = Table(box=box.SIMPLE_HEAD, title="config")
|
|
697
|
+
table.add_column("key")
|
|
698
|
+
table.add_column("value")
|
|
699
|
+
for name, value in stored.items():
|
|
700
|
+
shown: object = value
|
|
701
|
+
if name == "api_key" and isinstance(value, str) and not reveal:
|
|
702
|
+
shown = userconfig.mask(value)
|
|
703
|
+
table.add_row(name, render.escape(str(shown)))
|
|
704
|
+
out.print(table)
|
|
705
|
+
|
|
706
|
+
|
|
550
707
|
@app.command("doctor")
|
|
551
708
|
def doctor_cmd(
|
|
552
709
|
json_out: bool = typer.Option(False, "--json", help="JSON output."),
|
|
@@ -60,6 +60,13 @@ def provider_from_key(api_key: str | None) -> str | None:
|
|
|
60
60
|
return None
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def env_key_for(provider: str) -> str | None:
|
|
64
|
+
for variable, name in PROVIDER_ENV.items():
|
|
65
|
+
if name == provider and os.environ.get(variable):
|
|
66
|
+
return os.environ[variable]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
63
70
|
def provider_from_env() -> str | None:
|
|
64
71
|
"""Infer a provider from whichever provider-specific variable is set."""
|
|
65
72
|
for variable, provider in PROVIDER_ENV.items():
|
|
@@ -103,7 +110,7 @@ class Settings:
|
|
|
103
110
|
model: str = DEFAULT_MODEL
|
|
104
111
|
api_key: str | None = None
|
|
105
112
|
temperature: float = DEFAULT_TEMPERATURE
|
|
106
|
-
max_tokens: int =
|
|
113
|
+
max_tokens: int = 4000
|
|
107
114
|
timeout: float = DEFAULT_TIMEOUT
|
|
108
115
|
max_connections: int = 16
|
|
109
116
|
max_attempts: int = DEFAULT_MAX_ATTEMPTS
|
|
@@ -7,6 +7,7 @@ import tempfile
|
|
|
7
7
|
from dataclasses import dataclass, replace
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
+
from . import userconfig
|
|
10
11
|
from .config import PROVIDER_ENV, Settings, cache_dir, ensure_dirs, log_dir
|
|
11
12
|
from .http import HttpClient
|
|
12
13
|
|
|
@@ -102,8 +103,14 @@ def collect_checks(settings: Settings) -> list[Check]:
|
|
|
102
103
|
key_check = Check("api key", "ok", f"from {source}")
|
|
103
104
|
else:
|
|
104
105
|
key_check = Check("api key", "warn", f"no key for {settings.provider}; set one or use --offline")
|
|
106
|
+
config = userconfig.config_path()
|
|
105
107
|
checks = [
|
|
106
108
|
Check("provider", "ok", settings.provider),
|
|
109
|
+
Check(
|
|
110
|
+
"config file",
|
|
111
|
+
"ok" if config.exists() else "warn",
|
|
112
|
+
str(config) if config.exists() else f"none at {config}; run osintkit init",
|
|
113
|
+
),
|
|
107
114
|
key_check,
|
|
108
115
|
Check(
|
|
109
116
|
"cache dir",
|
|
@@ -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
|