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.
Files changed (88) hide show
  1. {osintkit-0.3.0 → osintkit-0.5.0}/CHANGELOG.md +24 -0
  2. {osintkit-0.3.0 → osintkit-0.5.0}/PKG-INFO +29 -10
  3. {osintkit-0.3.0 → osintkit-0.5.0}/README.md +28 -9
  4. {osintkit-0.3.0 → osintkit-0.5.0}/docs/REFERENCE.md +26 -0
  5. {osintkit-0.3.0 → osintkit-0.5.0}/examples/README.md +7 -0
  6. {osintkit-0.3.0 → osintkit-0.5.0}/pyproject.toml +1 -1
  7. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/__init__.py +1 -1
  8. osintkit-0.5.0/src/osintkit/catalogue.py +94 -0
  9. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/cli.py +161 -4
  10. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/config.py +8 -1
  11. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/doctor.py +7 -0
  12. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/llm.py +23 -4
  13. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/normalise.py +18 -6
  14. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/offline.py +7 -4
  15. osintkit-0.5.0/src/osintkit/passwords.py +66 -0
  16. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/pipeline.py +32 -2
  17. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/synthesis.py +1 -1
  18. osintkit-0.5.0/src/osintkit/userconfig.py +94 -0
  19. {osintkit-0.3.0 → osintkit-0.5.0}/tests/conftest.py +1 -0
  20. osintkit-0.5.0/tests/test_catalogue.py +70 -0
  21. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_cli.py +135 -0
  22. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_e2e.py +1 -1
  23. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_llm.py +33 -1
  24. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_offline.py +6 -0
  25. osintkit-0.5.0/tests/test_passwords.py +58 -0
  26. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_pipeline.py +52 -1
  27. osintkit-0.5.0/tests/test_userconfig.py +59 -0
  28. {osintkit-0.3.0 → osintkit-0.5.0}/.editorconfig +0 -0
  29. {osintkit-0.3.0 → osintkit-0.5.0}/.github/CODEOWNERS +0 -0
  30. {osintkit-0.3.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  31. {osintkit-0.3.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  32. {osintkit-0.3.0 → osintkit-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  33. {osintkit-0.3.0 → osintkit-0.5.0}/.github/dependabot.yml +0 -0
  34. {osintkit-0.3.0 → osintkit-0.5.0}/.github/pull_request_template.md +0 -0
  35. {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/ci.yml +0 -0
  36. {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/codeql.yml +0 -0
  37. {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/publish.yml +0 -0
  38. {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/release.yml +0 -0
  39. {osintkit-0.3.0 → osintkit-0.5.0}/.github/workflows/smoke.yml +0 -0
  40. {osintkit-0.3.0 → osintkit-0.5.0}/.gitignore +0 -0
  41. {osintkit-0.3.0 → osintkit-0.5.0}/.mailmap +0 -0
  42. {osintkit-0.3.0 → osintkit-0.5.0}/.pre-commit-config.yaml +0 -0
  43. {osintkit-0.3.0 → osintkit-0.5.0}/CODE_OF_CONDUCT.md +0 -0
  44. {osintkit-0.3.0 → osintkit-0.5.0}/CONTRIBUTING.md +0 -0
  45. {osintkit-0.3.0 → osintkit-0.5.0}/LICENSE +0 -0
  46. {osintkit-0.3.0 → osintkit-0.5.0}/SECURITY.md +0 -0
  47. {osintkit-0.3.0 → osintkit-0.5.0}/docs/architecture.md +0 -0
  48. {osintkit-0.3.0 → osintkit-0.5.0}/docs/demo.svg +0 -0
  49. {osintkit-0.3.0 → osintkit-0.5.0}/docs/sample-output.md +0 -0
  50. {osintkit-0.3.0 → osintkit-0.5.0}/docs/social-preview.png +0 -0
  51. {osintkit-0.3.0 → osintkit-0.5.0}/examples/targets.csv +0 -0
  52. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/__init__.py +0 -0
  53. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/accounts.py +0 -0
  54. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/base.py +0 -0
  55. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/breaches.py +0 -0
  56. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/ct.py +0 -0
  57. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/dns.py +0 -0
  58. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/github.py +0 -0
  59. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/pastes.py +0 -0
  60. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/ports.py +0 -0
  61. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/subdomains.py +0 -0
  62. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  63. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/diff.py +0 -0
  64. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/errors.py +0 -0
  65. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/http.py +0 -0
  66. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/logging_store.py +0 -0
  67. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/models.py +0 -0
  68. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/py.typed +0 -0
  69. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/render.py +0 -0
  70. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/retention.py +0 -0
  71. {osintkit-0.3.0 → osintkit-0.5.0}/src/osintkit/targets.py +0 -0
  72. {osintkit-0.3.0 → osintkit-0.5.0}/tests/fixtures/model_reply.json +0 -0
  73. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_accounts.py +0 -0
  74. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_breaches.py +0 -0
  75. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_collectors.py +0 -0
  76. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_config.py +0 -0
  77. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_ct.py +0 -0
  78. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_diff.py +0 -0
  79. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_doctor.py +0 -0
  80. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_http.py +0 -0
  81. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_normalise.py +0 -0
  82. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_pastes.py +0 -0
  83. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_render.py +0 -0
  84. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_retention.py +0 -0
  85. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_synthesis.py +0 -0
  86. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_targets.py +0 -0
  87. {osintkit-0.3.0 → osintkit-0.5.0}/tests/test_whois.py +0 -0
  88. {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.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
- export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
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: export the provider's own variable (`DEEPSEEK_API_KEY`,
87
- `MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
88
- `TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
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.** Three indexes listing one incident collapse into one row with every
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
- export API_KEY=... # or DEEPSEEK_API_KEY, MISTRAL_API_KEY, ...
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: export the provider's own variable (`DEEPSEEK_API_KEY`,
55
- `MISTRAL_API_KEY`, `GROQ_API_KEY`, `VENICE_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`,
56
- `TOGETHER_API_KEY`, `ANTHROPIC_API_KEY`) and that provider is used. `--provider` also accepts
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.** Three indexes listing one incident collapse into one row with every
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
@@ -43,3 +43,10 @@ No key at all:
43
43
  osintkit lookup --domain example.com --offline
44
44
  osintkit batch --file targets.csv --offline --json
45
45
  ```
46
+
47
+ Set a key once and forget flags:
48
+
49
+ ```bash
50
+ osintkit init
51
+ osintkit lookup --domain example.com
52
+ ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.3.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.3.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,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(2400, "--max-tokens", min=256, max=16000),
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 = 2400
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 = 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