osintkit 0.7.0__tar.gz → 0.9.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.7.0 → osintkit-0.9.0}/CHANGELOG.md +16 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/PKG-INFO +20 -4
- {osintkit-0.7.0 → osintkit-0.9.0}/README.md +18 -3
- {osintkit-0.7.0 → osintkit-0.9.0}/docs/REFERENCE.md +17 -1
- {osintkit-0.7.0 → osintkit-0.9.0}/pyproject.toml +2 -1
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/__init__.py +1 -1
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/cli.py +101 -4
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/__init__.py +1 -0
- osintkit-0.9.0/src/osintkit/collectors/identifiers.py +65 -0
- osintkit-0.9.0/src/osintkit/confidence.py +55 -0
- osintkit-0.9.0/src/osintkit/data/common-passwords.txt +10001 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/models.py +1 -0
- osintkit-0.9.0/src/osintkit/passwords_offline.py +29 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/pipeline.py +16 -1
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/render.py +28 -4
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/synthesis.py +2 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/targets.py +13 -1
- osintkit-0.9.0/src/osintkit/watch.py +97 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_cli.py +33 -0
- osintkit-0.9.0/tests/test_confidence.py +42 -0
- osintkit-0.9.0/tests/test_identifiers.py +62 -0
- osintkit-0.9.0/tests/test_passwords_offline.py +17 -0
- osintkit-0.9.0/tests/test_watch.py +81 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/uv.lock +12 -1
- {osintkit-0.7.0 → osintkit-0.9.0}/.editorconfig +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/CODEOWNERS +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/dependabot.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/pull_request_template.md +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/ci.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/codeql.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/publish.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/release.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/smoke.yml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.gitignore +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.mailmap +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/.pre-commit-config.yaml +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/CODE_OF_CONDUCT.md +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/CONTRIBUTING.md +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/LICENSE +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/SECURITY.md +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/docs/architecture.md +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/docs/demo.svg +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/docs/sample-output.md +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/docs/social-preview.png +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/examples/README.md +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/examples/targets.csv +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/catalogue.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/accounts.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/base.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/breaches.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/ct.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/dns.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/github.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/infostealer.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/pastes.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/ports.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/subdomains.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/whois_lookup.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/xposed_analytics.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/config.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/diff.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/doctor.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/errors.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/http.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/llm.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/logging_store.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/normalise.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/offline.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/passwords.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/py.typed +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/retention.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/userconfig.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/verify.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/conftest.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/fixtures/model_reply.json +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_accounts.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_breaches.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_catalogue.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_collectors.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_config.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_ct.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_diff.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_doctor.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_e2e.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_http.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_infostealer.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_llm.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_normalise.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_offline.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_passwords.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_pastes.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_pipeline.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_render.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_retention.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_synthesis.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_targets.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_userconfig.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_verify.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_whois.py +0 -0
- {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_xposed_analytics.py +0 -0
|
@@ -6,6 +6,22 @@ All notable changes are recorded here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
## 0.9.0 — 2026-11-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- phone targets with offline libphonenumber metadata (region, carrier, line type, timezones)
|
|
14
|
+
- Bitcoin address targets with balance and transaction history from Blockstream
|
|
15
|
+
- per-section confidence, capped by the number of backing sources
|
|
16
|
+
- `password --offline` against a bundled 10k common-password list, no network
|
|
17
|
+
|
|
18
|
+
## 0.8.0 — 2026-11-02
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `watch`: interval re-runs that stay silent unless a diff shows change, with optional desktop
|
|
23
|
+
notification and bounded cycles
|
|
24
|
+
|
|
9
25
|
## 0.7.0 — 2026-11-02
|
|
10
26
|
|
|
11
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: osintkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -19,6 +19,7 @@ Classifier: Typing :: Typed
|
|
|
19
19
|
Requires-Python: >=3.12
|
|
20
20
|
Requires-Dist: dnspython>=2.6
|
|
21
21
|
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: phonenumbers>=8.13
|
|
22
23
|
Requires-Dist: pydantic>=2.7
|
|
23
24
|
Requires-Dist: python-whois>=0.9
|
|
24
25
|
Requires-Dist: rich>=13.7
|
|
@@ -38,7 +39,7 @@ Description-Content-Type: text/markdown
|
|
|
38
39
|
[](LICENSE)
|
|
39
40
|
[](https://github.com/astral-sh/ruff)
|
|
40
41
|
|
|
41
|
-
Public-data OSINT for one email, username, domain, or
|
|
42
|
+
Public-data OSINT for one email, username, domain, company, phone number, or Bitcoin address: gathered from free sources,
|
|
42
43
|
briefed by an LLM acting as a defensive analyst, rendered as a colour-coded report.
|
|
43
44
|
|
|
44
45
|
`osintkit` uses only public sources: certificate transparency, public DNS, WHOIS, public GitHub
|
|
@@ -102,10 +103,11 @@ Everything else needs no credentials.
|
|
|
102
103
|
|
|
103
104
|
| Command | Purpose |
|
|
104
105
|
| --- | --- |
|
|
105
|
-
| `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`) |
|
|
106
|
+
| `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`, `--phone`, `--crypto`) |
|
|
106
107
|
| `batch` | A CSV of targets, run concurrently, reported in order |
|
|
107
108
|
| `diff` | What changed between two archived runs |
|
|
108
|
-
| `password` | Is a password in Pwned Passwords (
|
|
109
|
+
| `password` | Is a password in Pwned Passwords (`--offline` uses a bundled list, no network) |
|
|
110
|
+
| `watch` | Re-check on an interval, print only changes, optionally notify |
|
|
109
111
|
| `history` | Past runs, replayable by id |
|
|
110
112
|
| `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
|
|
111
113
|
| `collectors` | Every collector and what it touches |
|
|
@@ -146,6 +148,8 @@ the model fail.
|
|
|
146
148
|
undercut.
|
|
147
149
|
- **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
|
|
148
150
|
- **Validated output.** Strict JSON schema, one repair round-trip, then a clean failure.
|
|
151
|
+
- **Confidence per section.** Each section states `low`, `medium`, or `high`, capped by how many
|
|
152
|
+
sources actually back it, so a single weak source never reads as certain.
|
|
149
153
|
- **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
|
|
150
154
|
collected data; anything unsupported is called out.
|
|
151
155
|
|
|
@@ -172,6 +176,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
|
|
|
172
176
|
millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
|
|
173
177
|
machine.
|
|
174
178
|
|
|
179
|
+
## Monitoring
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
osintkit watch --domain example.com --every 1d
|
|
183
|
+
osintkit watch --email you@example.com --every 6h --no-notify
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
|
|
187
|
+
breach, a new host, a source failing, or a risk change — and optionally raise a desktop
|
|
188
|
+
notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
|
|
189
|
+
is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
|
|
190
|
+
|
|
175
191
|
## Safety and legality
|
|
176
192
|
|
|
177
193
|
- Public data only. No logins, no scraping behind auth, no paid brokers.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/astral-sh/ruff)
|
|
8
8
|
|
|
9
|
-
Public-data OSINT for one email, username, domain, or
|
|
9
|
+
Public-data OSINT for one email, username, domain, company, phone number, or Bitcoin address: gathered from free sources,
|
|
10
10
|
briefed by an LLM acting as a defensive analyst, rendered as a colour-coded report.
|
|
11
11
|
|
|
12
12
|
`osintkit` uses only public sources: certificate transparency, public DNS, WHOIS, public GitHub
|
|
@@ -70,10 +70,11 @@ Everything else needs no credentials.
|
|
|
70
70
|
|
|
71
71
|
| Command | Purpose |
|
|
72
72
|
| --- | --- |
|
|
73
|
-
| `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`) |
|
|
73
|
+
| `lookup` | One target, one report (`--email`, `--username`, `--domain`, `--company`, `--phone`, `--crypto`) |
|
|
74
74
|
| `batch` | A CSV of targets, run concurrently, reported in order |
|
|
75
75
|
| `diff` | What changed between two archived runs |
|
|
76
|
-
| `password` | Is a password in Pwned Passwords (
|
|
76
|
+
| `password` | Is a password in Pwned Passwords (`--offline` uses a bundled list, no network) |
|
|
77
|
+
| `watch` | Re-check on an interval, print only changes, optionally notify |
|
|
77
78
|
| `history` | Past runs, replayable by id |
|
|
78
79
|
| `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
|
|
79
80
|
| `collectors` | Every collector and what it touches |
|
|
@@ -114,6 +115,8 @@ the model fail.
|
|
|
114
115
|
undercut.
|
|
115
116
|
- **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
|
|
116
117
|
- **Validated output.** Strict JSON schema, one repair round-trip, then a clean failure.
|
|
118
|
+
- **Confidence per section.** Each section states `low`, `medium`, or `high`, capped by how many
|
|
119
|
+
sources actually back it, so a single weak source never reads as certain.
|
|
117
120
|
- **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
|
|
118
121
|
collected data; anything unsupported is called out.
|
|
119
122
|
|
|
@@ -140,6 +143,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
|
|
|
140
143
|
millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
|
|
141
144
|
machine.
|
|
142
145
|
|
|
146
|
+
## Monitoring
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
osintkit watch --domain example.com --every 1d
|
|
150
|
+
osintkit watch --email you@example.com --every 6h --no-notify
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
|
|
154
|
+
breach, a new host, a source failing, or a risk change — and optionally raise a desktop
|
|
155
|
+
notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
|
|
156
|
+
is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
|
|
157
|
+
|
|
143
158
|
## Safety and legality
|
|
144
159
|
|
|
145
160
|
- Public data only. No logins, no scraping behind auth, no paid brokers.
|
|
@@ -7,6 +7,8 @@ Everything the README leaves out: every flag, every collector, storage, and fail
|
|
|
7
7
|
| Collector | Targets | Source | Needs key |
|
|
8
8
|
| --- | --- | --- | --- |
|
|
9
9
|
| `infostealer` | email / username / domain | Hudson Rock stealer logs | no |
|
|
10
|
+
| `phone_info` | phone | libphonenumber metadata, offline | no |
|
|
11
|
+
| `btc_address` | crypto | Blockstream address history | no |
|
|
10
12
|
| `hibp_breaches`, `hibp_pastes` | email | Have I Been Pwned | yes |
|
|
11
13
|
| `xposed_or_not`, `xposed_analytics`, `leakcheck_public` | email | free public breach indexes | no |
|
|
12
14
|
| `gravatar` | email | Gravatar | no |
|
|
@@ -62,6 +64,19 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
|
|
|
62
64
|
Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
|
|
63
65
|
`--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
|
|
64
66
|
|
|
67
|
+
## Confidence
|
|
68
|
+
|
|
69
|
+
Every section carries `low`, `medium`, or `high`. The value is derived from how many contributing
|
|
70
|
+
sources returned data (two or more is high, one is medium) and the model may be more pessimistic
|
|
71
|
+
but never more optimistic. A section backed by one index therefore cannot claim high confidence.
|
|
72
|
+
|
|
73
|
+
## Watching
|
|
74
|
+
|
|
75
|
+
`watch` repeats a lookup on an interval (`--every`, minimum 60s), prints a baseline, and then
|
|
76
|
+
only reports diffs: added breaches or hosts, risk direction, source failure and recovery.
|
|
77
|
+
`--notify` (default) calls `notify-send` when available; `--full` prints the whole report on a
|
|
78
|
+
change; `--cycles N` bounds the loop. Every cycle is archived and logged like a normal run.
|
|
79
|
+
|
|
65
80
|
## Self-check
|
|
66
81
|
|
|
67
82
|
After synthesis every breach name, date, subdomain, and account URL is compared with the supplied
|
|
@@ -87,7 +102,8 @@ domains, and record counts. At most 15 enriched rows go into the prompt; the tru
|
|
|
87
102
|
|
|
88
103
|
## Password checks
|
|
89
104
|
|
|
90
|
-
`osintkit password
|
|
105
|
+
`osintkit password --offline` checks the bundled 10k most-common list and never opens a socket;
|
|
106
|
+
without `--offline` it hashes locally, sends only the five-character SHA-1 prefix to
|
|
91
107
|
`api.pwnedpasswords.com`, and reports the count and a commonness rating. Exit code `1` means
|
|
92
108
|
pwned. `--json` for machines. Nothing about the password beyond the prefix leaves the machine.
|
|
93
109
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "osintkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.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"
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
"rich>=13.7",
|
|
30
30
|
"dnspython>=2.6",
|
|
31
31
|
"python-whois>=0.9",
|
|
32
|
+
"phonenumbers>=8.13",
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
[project.optional-dependencies]
|
|
@@ -18,7 +18,18 @@ from rich.console import Console
|
|
|
18
18
|
from rich.panel import Panel
|
|
19
19
|
from rich.table import Table
|
|
20
20
|
|
|
21
|
-
from . import
|
|
21
|
+
from . import (
|
|
22
|
+
__version__,
|
|
23
|
+
diff,
|
|
24
|
+
doctor,
|
|
25
|
+
passwords,
|
|
26
|
+
passwords_offline,
|
|
27
|
+
pipeline,
|
|
28
|
+
render,
|
|
29
|
+
retention,
|
|
30
|
+
userconfig,
|
|
31
|
+
watch,
|
|
32
|
+
)
|
|
22
33
|
from .collectors import REGISTRY
|
|
23
34
|
from .config import (
|
|
24
35
|
DEFAULT_RESOLVERS,
|
|
@@ -139,6 +150,8 @@ def _pick_target(
|
|
|
139
150
|
username: str | None,
|
|
140
151
|
domain: str | None,
|
|
141
152
|
company: str | None,
|
|
153
|
+
phone: str | None = None,
|
|
154
|
+
crypto: str | None = None,
|
|
142
155
|
) -> tuple[str, str | None]:
|
|
143
156
|
supplied = [
|
|
144
157
|
(name, value)
|
|
@@ -147,16 +160,18 @@ def _pick_target(
|
|
|
147
160
|
("username", username),
|
|
148
161
|
("domain", domain),
|
|
149
162
|
("company", company),
|
|
163
|
+
("phone", phone),
|
|
164
|
+
("crypto", crypto),
|
|
150
165
|
)
|
|
151
166
|
if value
|
|
152
167
|
]
|
|
153
168
|
if len(supplied) > 1:
|
|
154
|
-
raise TargetParseError("pass only one of --email/--username/--domain/--company")
|
|
169
|
+
raise TargetParseError("pass only one of --email/--username/--domain/--company/--phone/--crypto")
|
|
155
170
|
if supplied:
|
|
156
171
|
kind, value = supplied[0]
|
|
157
172
|
return value, kind
|
|
158
173
|
if not target:
|
|
159
|
-
raise TargetParseError("give a target or one of --email/--username/--domain/--company")
|
|
174
|
+
raise TargetParseError("give a target or one of --email/--username/--domain/--company/--phone/--crypto")
|
|
160
175
|
return target, None
|
|
161
176
|
|
|
162
177
|
|
|
@@ -249,6 +264,8 @@ def lookup(
|
|
|
249
264
|
username: str | None = typer.Option(None, "--username", help="Username target."),
|
|
250
265
|
domain: str | None = typer.Option(None, "--domain", help="Domain target."),
|
|
251
266
|
company: str | None = typer.Option(None, "--company", help="Company target."),
|
|
267
|
+
phone: str | None = typer.Option(None, "--phone", help="Phone target (E.164, e.g. +15551234567)."),
|
|
268
|
+
crypto: str | None = typer.Option(None, "--crypto", help="Bitcoin address target."),
|
|
252
269
|
json_out: bool = typer.Option(False, "--json", help="Raw structured JSON on stdout."),
|
|
253
270
|
markdown: bool = typer.Option(False, "--markdown", help="Write a Markdown report."),
|
|
254
271
|
out_path: Path | None = typer.Option(None, "--out", "-o", help="Markdown destination."),
|
|
@@ -277,7 +294,7 @@ def lookup(
|
|
|
277
294
|
if out_path:
|
|
278
295
|
settings.extra["markdown_path"] = str(out_path)
|
|
279
296
|
try:
|
|
280
|
-
raw, declared = _pick_target(target, email, username, domain, company)
|
|
297
|
+
raw, declared = _pick_target(target, email, username, domain, company, phone, crypto)
|
|
281
298
|
except TargetParseError as exc:
|
|
282
299
|
out.print(f"[red]{exc}[/red]")
|
|
283
300
|
raise typer.Exit(2) from exc
|
|
@@ -639,10 +656,64 @@ def init(
|
|
|
639
656
|
out.print("[dim]try: osintkit lookup --domain example.com[/dim]")
|
|
640
657
|
|
|
641
658
|
|
|
659
|
+
@app.command("watch")
|
|
660
|
+
def watch_cmd(
|
|
661
|
+
target: str | None = typer.Argument(None, help="Email, username, domain, or company."),
|
|
662
|
+
email: str | None = typer.Option(None, "--email", help="Email target."),
|
|
663
|
+
username: str | None = typer.Option(None, "--username", help="Username target."),
|
|
664
|
+
domain: str | None = typer.Option(None, "--domain", help="Domain target."),
|
|
665
|
+
company: str | None = typer.Option(None, "--company", help="Company target."),
|
|
666
|
+
phone: str | None = typer.Option(None, "--phone", help="Phone target (E.164, e.g. +15551234567)."),
|
|
667
|
+
crypto: str | None = typer.Option(None, "--crypto", help="Bitcoin address target."),
|
|
668
|
+
every: str = typer.Option("1d", "--every", help="Interval: 30m, 6h, 1d."),
|
|
669
|
+
cycles: int = typer.Option(0, "--cycles", min=0, help="Stop after N runs; 0 means forever."),
|
|
670
|
+
full: bool = typer.Option(False, "--full", help="Print the whole report on each change."),
|
|
671
|
+
notify_changes: bool = typer.Option(True, "--notify/--no-notify", help="Desktop notification."),
|
|
672
|
+
offline: bool = typer.Option(False, "--offline", help="Local report only; no model, no key."),
|
|
673
|
+
only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
|
|
674
|
+
skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Re-check a target on a schedule and report only changes."""
|
|
677
|
+
out = _console()
|
|
678
|
+
try:
|
|
679
|
+
interval = watch.parse_duration(every)
|
|
680
|
+
raw, declared = _pick_target(target, email, username, domain, company, phone, crypto)
|
|
681
|
+
except (ValueError, TargetParseError) as exc:
|
|
682
|
+
out.print(f"[red]{exc}[/red]")
|
|
683
|
+
raise typer.Exit(2) from exc
|
|
684
|
+
|
|
685
|
+
settings = _settings(offline=offline, output_json=False, output_markdown=False, raw=False)
|
|
686
|
+
|
|
687
|
+
async def run_once():
|
|
688
|
+
outcome = await pipeline.execute(
|
|
689
|
+
raw, settings, RunLogger(pipeline.new_run_id()), out, declared_type=declared
|
|
690
|
+
)
|
|
691
|
+
return outcome.report
|
|
692
|
+
|
|
693
|
+
def on_report(report, lines, baseline):
|
|
694
|
+
watch.render_event(report, lines, baseline, out, full=full)
|
|
695
|
+
if lines and notify_changes:
|
|
696
|
+
body = "; ".join(lines)[:180]
|
|
697
|
+
watch.notify(f"change for {report.target}", body)
|
|
698
|
+
|
|
699
|
+
out.print(
|
|
700
|
+
f"[dim]watching {render.escape(raw)} every {every}"
|
|
701
|
+
+ (f", {cycles} runs" if cycles else "")
|
|
702
|
+
+ " · ctrl-c stops[/dim]"
|
|
703
|
+
)
|
|
704
|
+
try:
|
|
705
|
+
changes = asyncio.run(watch.run(raw, run_once, on_report, every=interval, cycles=cycles))
|
|
706
|
+
except KeyboardInterrupt:
|
|
707
|
+
out.print("[yellow]stopped[/yellow]")
|
|
708
|
+
return
|
|
709
|
+
out.print(f"[dim]{changes} change event(s)[/dim]")
|
|
710
|
+
|
|
711
|
+
|
|
642
712
|
@app.command()
|
|
643
713
|
def password(
|
|
644
714
|
value: str | None = typer.Argument(None, help="Password to check; prompt if omitted."),
|
|
645
715
|
json_out: bool = typer.Option(False, "--json", help="JSON output."),
|
|
716
|
+
offline: bool = typer.Option(False, "--offline", help="Use only the bundled list; no network."),
|
|
646
717
|
) -> None:
|
|
647
718
|
"""Check a password against Pwned Passwords, without sending it."""
|
|
648
719
|
out = _console()
|
|
@@ -656,6 +727,32 @@ def password(
|
|
|
656
727
|
out.print("[red]no password given[/red]")
|
|
657
728
|
raise typer.Exit(2)
|
|
658
729
|
|
|
730
|
+
local_rank = passwords_offline.check(secret)
|
|
731
|
+
if offline:
|
|
732
|
+
if local_rank:
|
|
733
|
+
if json_out:
|
|
734
|
+
print(
|
|
735
|
+
json.dumps(
|
|
736
|
+
{
|
|
737
|
+
"pwned": True,
|
|
738
|
+
"rank": local_rank,
|
|
739
|
+
"list_size": passwords_offline.size(),
|
|
740
|
+
"checked_by": "bundled list",
|
|
741
|
+
}
|
|
742
|
+
)
|
|
743
|
+
)
|
|
744
|
+
else:
|
|
745
|
+
out.print(
|
|
746
|
+
f"[red]common[/red] rank #{local_rank:,} of {passwords_offline.size():,} "
|
|
747
|
+
"(bundled list, no network)"
|
|
748
|
+
)
|
|
749
|
+
raise typer.Exit(1)
|
|
750
|
+
if json_out:
|
|
751
|
+
print(json.dumps({"pwned": False, "checked_by": "bundled list"}))
|
|
752
|
+
else:
|
|
753
|
+
out.print(f"not in the bundled top {passwords_offline.size():,}; not proof of strength")
|
|
754
|
+
return
|
|
755
|
+
|
|
659
756
|
settings: Settings = STATE["settings"] # type: ignore[assignment]
|
|
660
757
|
|
|
661
758
|
async def run() -> dict[str, object]:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Phone metadata (offline) and Bitcoin address history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import phonenumbers
|
|
6
|
+
from phonenumbers import PhoneNumberType, carrier, geocoder, timezone
|
|
7
|
+
|
|
8
|
+
from .base import Context, collector
|
|
9
|
+
|
|
10
|
+
BTC = "https://blockstream.info/api/address"
|
|
11
|
+
LINE_TYPES = {
|
|
12
|
+
value: name
|
|
13
|
+
for name, value in vars(PhoneNumberType).items()
|
|
14
|
+
if isinstance(value, int) and not name.startswith("_")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@collector("phone_info", ("phone",), "Phone region, carrier, and line type", order=15, ttl=2592000)
|
|
19
|
+
async def phone_info(ctx: Context) -> dict:
|
|
20
|
+
try:
|
|
21
|
+
parsed = phonenumbers.parse(ctx.target, None)
|
|
22
|
+
except phonenumbers.NumberParseException as exc:
|
|
23
|
+
return {"valid": False, "note": str(exc)}
|
|
24
|
+
return {
|
|
25
|
+
"valid": phonenumbers.is_valid_number(parsed),
|
|
26
|
+
"possible": phonenumbers.is_possible_number(parsed),
|
|
27
|
+
"region": phonenumbers.region_code_for_number(parsed),
|
|
28
|
+
"country": geocoder.description_for_number(parsed, "en") or None,
|
|
29
|
+
"carrier": carrier.name_for_number(parsed, "en") or None,
|
|
30
|
+
"line_type": LINE_TYPES.get(phonenumbers.number_type(parsed), "UNKNOWN"),
|
|
31
|
+
"timezones": list(timezone.time_zones_for_number(parsed)),
|
|
32
|
+
"e164": phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164),
|
|
33
|
+
"international": phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.INTERNATIONAL),
|
|
34
|
+
"source": "libphonenumber",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@collector("btc_address", ("crypto",), "Bitcoin address balances and transaction history", order=16, ttl=3600)
|
|
39
|
+
async def btc_address(ctx: Context) -> dict:
|
|
40
|
+
response = await ctx.http.get(
|
|
41
|
+
f"{BTC}/{ctx.target}",
|
|
42
|
+
namespace="blockstream",
|
|
43
|
+
empty_on=(400, 404),
|
|
44
|
+
timeout=ctx.http_timeout or 30,
|
|
45
|
+
)
|
|
46
|
+
if response.status_code != 200:
|
|
47
|
+
return {"found": False, "note": f"HTTP {response.status_code}"}
|
|
48
|
+
try:
|
|
49
|
+
body = response.json()
|
|
50
|
+
except ValueError:
|
|
51
|
+
return {"found": False, "note": "non-JSON"}
|
|
52
|
+
|
|
53
|
+
chain = body.get("chain_stats") or {}
|
|
54
|
+
mempool = body.get("mempool_stats") or {}
|
|
55
|
+
funded = int(chain.get("funded_txo_sum") or 0)
|
|
56
|
+
spent = int(chain.get("spent_txo_sum") or 0)
|
|
57
|
+
return {
|
|
58
|
+
"found": True,
|
|
59
|
+
"balance_sats": funded - spent,
|
|
60
|
+
"balance_btc": round((funded - spent) / 100_000_000, 8),
|
|
61
|
+
"tx_count": int(chain.get("tx_count") or 0),
|
|
62
|
+
"received_sats": funded,
|
|
63
|
+
"mempool_tx_count": int(mempool.get("tx_count") or 0),
|
|
64
|
+
"source": "blockstream.info",
|
|
65
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Per-section confidence, capped by how many sources actually back a section."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .models import CollectorResult
|
|
8
|
+
|
|
9
|
+
SECTIONS = ("breaches", "accounts", "infrastructure", "subdomains")
|
|
10
|
+
ORDER = ("low", "medium", "high")
|
|
11
|
+
|
|
12
|
+
CONTRIBUTORS: dict[str, frozenset[str]] = {
|
|
13
|
+
"breaches": frozenset({"breach_index"}),
|
|
14
|
+
"accounts": frozenset({"username_presence", "github_profile", "github_commit_search"}),
|
|
15
|
+
"infrastructure": frozenset(
|
|
16
|
+
{
|
|
17
|
+
"dns_records",
|
|
18
|
+
"dns_mail_posture",
|
|
19
|
+
"dns_wildcard",
|
|
20
|
+
"whois_registration",
|
|
21
|
+
"crt_sh_certificates",
|
|
22
|
+
"tcp_ports",
|
|
23
|
+
"infostealer",
|
|
24
|
+
"phone_info",
|
|
25
|
+
"btc_address",
|
|
26
|
+
}
|
|
27
|
+
),
|
|
28
|
+
"subdomains": frozenset({"crt_sh_subdomains", "passive_subdomains"}),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rank(value: str) -> int:
|
|
33
|
+
return ORDER.index(value) if value in ORDER else 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def derive(results: list[CollectorResult]) -> dict[str, str]:
|
|
37
|
+
"""Confidence implied by the data: 2+ contributing sources is high, 1 medium, 0 low."""
|
|
38
|
+
out: dict[str, str] = {}
|
|
39
|
+
for section, names in CONTRIBUTORS.items():
|
|
40
|
+
count = sum(1 for result in results if result.collector in names and result.has_data)
|
|
41
|
+
if section == "breaches":
|
|
42
|
+
rows = next((r.data for r in results if r.collector == "breach_index" and r.has_data), [])
|
|
43
|
+
count = min(len(rows), 2) if rows else 0
|
|
44
|
+
out[section] = "high" if count >= 2 else "medium" if count else "low"
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def apply(chosen: dict[str, Any] | None, derived: dict[str, str]) -> dict[str, str]:
|
|
49
|
+
"""Keep the model's honest pessimism, but never let it outrank the evidence."""
|
|
50
|
+
final: dict[str, str] = {}
|
|
51
|
+
for section in SECTIONS:
|
|
52
|
+
claimed = str((chosen or {}).get(section) or "").strip().lower()
|
|
53
|
+
floor = derived.get(section, "low")
|
|
54
|
+
final[section] = claimed if claimed in ORDER and _rank(claimed) <= _rank(floor) else floor
|
|
55
|
+
return final
|