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.
Files changed (103) hide show
  1. {osintkit-0.7.0 → osintkit-0.9.0}/CHANGELOG.md +16 -0
  2. {osintkit-0.7.0 → osintkit-0.9.0}/PKG-INFO +20 -4
  3. {osintkit-0.7.0 → osintkit-0.9.0}/README.md +18 -3
  4. {osintkit-0.7.0 → osintkit-0.9.0}/docs/REFERENCE.md +17 -1
  5. {osintkit-0.7.0 → osintkit-0.9.0}/pyproject.toml +2 -1
  6. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/__init__.py +1 -1
  7. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/cli.py +101 -4
  8. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/__init__.py +1 -0
  9. osintkit-0.9.0/src/osintkit/collectors/identifiers.py +65 -0
  10. osintkit-0.9.0/src/osintkit/confidence.py +55 -0
  11. osintkit-0.9.0/src/osintkit/data/common-passwords.txt +10001 -0
  12. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/models.py +1 -0
  13. osintkit-0.9.0/src/osintkit/passwords_offline.py +29 -0
  14. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/pipeline.py +16 -1
  15. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/render.py +28 -4
  16. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/synthesis.py +2 -0
  17. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/targets.py +13 -1
  18. osintkit-0.9.0/src/osintkit/watch.py +97 -0
  19. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_cli.py +33 -0
  20. osintkit-0.9.0/tests/test_confidence.py +42 -0
  21. osintkit-0.9.0/tests/test_identifiers.py +62 -0
  22. osintkit-0.9.0/tests/test_passwords_offline.py +17 -0
  23. osintkit-0.9.0/tests/test_watch.py +81 -0
  24. {osintkit-0.7.0 → osintkit-0.9.0}/uv.lock +12 -1
  25. {osintkit-0.7.0 → osintkit-0.9.0}/.editorconfig +0 -0
  26. {osintkit-0.7.0 → osintkit-0.9.0}/.github/CODEOWNERS +0 -0
  27. {osintkit-0.7.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  28. {osintkit-0.7.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {osintkit-0.7.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {osintkit-0.7.0 → osintkit-0.9.0}/.github/dependabot.yml +0 -0
  31. {osintkit-0.7.0 → osintkit-0.9.0}/.github/pull_request_template.md +0 -0
  32. {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/ci.yml +0 -0
  33. {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/codeql.yml +0 -0
  34. {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/publish.yml +0 -0
  35. {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/release.yml +0 -0
  36. {osintkit-0.7.0 → osintkit-0.9.0}/.github/workflows/smoke.yml +0 -0
  37. {osintkit-0.7.0 → osintkit-0.9.0}/.gitignore +0 -0
  38. {osintkit-0.7.0 → osintkit-0.9.0}/.mailmap +0 -0
  39. {osintkit-0.7.0 → osintkit-0.9.0}/.pre-commit-config.yaml +0 -0
  40. {osintkit-0.7.0 → osintkit-0.9.0}/CODE_OF_CONDUCT.md +0 -0
  41. {osintkit-0.7.0 → osintkit-0.9.0}/CONTRIBUTING.md +0 -0
  42. {osintkit-0.7.0 → osintkit-0.9.0}/LICENSE +0 -0
  43. {osintkit-0.7.0 → osintkit-0.9.0}/SECURITY.md +0 -0
  44. {osintkit-0.7.0 → osintkit-0.9.0}/docs/architecture.md +0 -0
  45. {osintkit-0.7.0 → osintkit-0.9.0}/docs/demo.svg +0 -0
  46. {osintkit-0.7.0 → osintkit-0.9.0}/docs/sample-output.md +0 -0
  47. {osintkit-0.7.0 → osintkit-0.9.0}/docs/social-preview.png +0 -0
  48. {osintkit-0.7.0 → osintkit-0.9.0}/examples/README.md +0 -0
  49. {osintkit-0.7.0 → osintkit-0.9.0}/examples/targets.csv +0 -0
  50. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/catalogue.py +0 -0
  51. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/accounts.py +0 -0
  52. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/base.py +0 -0
  53. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/breaches.py +0 -0
  54. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/ct.py +0 -0
  55. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/dns.py +0 -0
  56. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/github.py +0 -0
  57. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/infostealer.py +0 -0
  58. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/pastes.py +0 -0
  59. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/ports.py +0 -0
  60. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/subdomains.py +0 -0
  61. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  62. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/collectors/xposed_analytics.py +0 -0
  63. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/config.py +0 -0
  64. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/diff.py +0 -0
  65. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/doctor.py +0 -0
  66. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/errors.py +0 -0
  67. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/http.py +0 -0
  68. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/llm.py +0 -0
  69. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/logging_store.py +0 -0
  70. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/normalise.py +0 -0
  71. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/offline.py +0 -0
  72. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/passwords.py +0 -0
  73. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/py.typed +0 -0
  74. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/retention.py +0 -0
  75. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/userconfig.py +0 -0
  76. {osintkit-0.7.0 → osintkit-0.9.0}/src/osintkit/verify.py +0 -0
  77. {osintkit-0.7.0 → osintkit-0.9.0}/tests/conftest.py +0 -0
  78. {osintkit-0.7.0 → osintkit-0.9.0}/tests/fixtures/model_reply.json +0 -0
  79. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_accounts.py +0 -0
  80. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_breaches.py +0 -0
  81. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_catalogue.py +0 -0
  82. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_collectors.py +0 -0
  83. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_config.py +0 -0
  84. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_ct.py +0 -0
  85. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_diff.py +0 -0
  86. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_doctor.py +0 -0
  87. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_e2e.py +0 -0
  88. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_http.py +0 -0
  89. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_infostealer.py +0 -0
  90. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_llm.py +0 -0
  91. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_normalise.py +0 -0
  92. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_offline.py +0 -0
  93. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_passwords.py +0 -0
  94. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_pastes.py +0 -0
  95. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_pipeline.py +0 -0
  96. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_render.py +0 -0
  97. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_retention.py +0 -0
  98. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_synthesis.py +0 -0
  99. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_targets.py +0 -0
  100. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_userconfig.py +0 -0
  101. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_verify.py +0 -0
  102. {osintkit-0.7.0 → osintkit-0.9.0}/tests/test_whois.py +0 -0
  103. {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.7.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](https://img.shields.io/badge/license-MIT-green)](LICENSE)
39
40
  [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
40
41
 
41
- Public-data OSINT for one email, username, domain, or company: gathered from free sources,
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 (free, no key, hash-prefix only) |
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](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
8
8
 
9
- Public-data OSINT for one email, username, domain, or company: gathered from free sources,
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 (free, no key, hash-prefix only) |
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 [VALUE]` hashes locally, sends only the five-character SHA-1 prefix to
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.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]
@@ -1,3 +1,3 @@
1
1
  """osintkit: a public-data OSINT aggregator."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.9.0"
@@ -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 __version__, diff, doctor, passwords, pipeline, render, retention, userconfig
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]:
@@ -8,6 +8,7 @@ from . import ( # noqa: F401 (import order matters: side-effect registration)
8
8
  ct,
9
9
  dns,
10
10
  github,
11
+ identifiers,
11
12
  infostealer,
12
13
  pastes,
13
14
  ports,
@@ -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