osintkit 0.8.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.8.0 → osintkit-0.9.0}/CHANGELOG.md +9 -0
  2. {osintkit-0.8.0 → osintkit-0.9.0}/PKG-INFO +7 -4
  3. {osintkit-0.8.0 → osintkit-0.9.0}/README.md +5 -3
  4. {osintkit-0.8.0 → osintkit-0.9.0}/docs/REFERENCE.md +10 -1
  5. {osintkit-0.8.0 → osintkit-0.9.0}/pyproject.toml +2 -1
  6. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/__init__.py +1 -1
  7. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/cli.py +51 -5
  8. {osintkit-0.8.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.8.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.8.0 → osintkit-0.9.0}/src/osintkit/pipeline.py +16 -1
  15. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/render.py +28 -4
  16. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/synthesis.py +2 -0
  17. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/targets.py +13 -1
  18. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_cli.py +23 -0
  19. osintkit-0.9.0/tests/test_confidence.py +42 -0
  20. osintkit-0.9.0/tests/test_identifiers.py +62 -0
  21. osintkit-0.9.0/tests/test_passwords_offline.py +17 -0
  22. {osintkit-0.8.0 → osintkit-0.9.0}/uv.lock +12 -1
  23. {osintkit-0.8.0 → osintkit-0.9.0}/.editorconfig +0 -0
  24. {osintkit-0.8.0 → osintkit-0.9.0}/.github/CODEOWNERS +0 -0
  25. {osintkit-0.8.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  26. {osintkit-0.8.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  27. {osintkit-0.8.0 → osintkit-0.9.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  28. {osintkit-0.8.0 → osintkit-0.9.0}/.github/dependabot.yml +0 -0
  29. {osintkit-0.8.0 → osintkit-0.9.0}/.github/pull_request_template.md +0 -0
  30. {osintkit-0.8.0 → osintkit-0.9.0}/.github/workflows/ci.yml +0 -0
  31. {osintkit-0.8.0 → osintkit-0.9.0}/.github/workflows/codeql.yml +0 -0
  32. {osintkit-0.8.0 → osintkit-0.9.0}/.github/workflows/publish.yml +0 -0
  33. {osintkit-0.8.0 → osintkit-0.9.0}/.github/workflows/release.yml +0 -0
  34. {osintkit-0.8.0 → osintkit-0.9.0}/.github/workflows/smoke.yml +0 -0
  35. {osintkit-0.8.0 → osintkit-0.9.0}/.gitignore +0 -0
  36. {osintkit-0.8.0 → osintkit-0.9.0}/.mailmap +0 -0
  37. {osintkit-0.8.0 → osintkit-0.9.0}/.pre-commit-config.yaml +0 -0
  38. {osintkit-0.8.0 → osintkit-0.9.0}/CODE_OF_CONDUCT.md +0 -0
  39. {osintkit-0.8.0 → osintkit-0.9.0}/CONTRIBUTING.md +0 -0
  40. {osintkit-0.8.0 → osintkit-0.9.0}/LICENSE +0 -0
  41. {osintkit-0.8.0 → osintkit-0.9.0}/SECURITY.md +0 -0
  42. {osintkit-0.8.0 → osintkit-0.9.0}/docs/architecture.md +0 -0
  43. {osintkit-0.8.0 → osintkit-0.9.0}/docs/demo.svg +0 -0
  44. {osintkit-0.8.0 → osintkit-0.9.0}/docs/sample-output.md +0 -0
  45. {osintkit-0.8.0 → osintkit-0.9.0}/docs/social-preview.png +0 -0
  46. {osintkit-0.8.0 → osintkit-0.9.0}/examples/README.md +0 -0
  47. {osintkit-0.8.0 → osintkit-0.9.0}/examples/targets.csv +0 -0
  48. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/catalogue.py +0 -0
  49. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/accounts.py +0 -0
  50. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/base.py +0 -0
  51. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/breaches.py +0 -0
  52. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/ct.py +0 -0
  53. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/dns.py +0 -0
  54. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/github.py +0 -0
  55. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/infostealer.py +0 -0
  56. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/pastes.py +0 -0
  57. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/ports.py +0 -0
  58. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/subdomains.py +0 -0
  59. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  60. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/collectors/xposed_analytics.py +0 -0
  61. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/config.py +0 -0
  62. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/diff.py +0 -0
  63. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/doctor.py +0 -0
  64. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/errors.py +0 -0
  65. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/http.py +0 -0
  66. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/llm.py +0 -0
  67. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/logging_store.py +0 -0
  68. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/normalise.py +0 -0
  69. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/offline.py +0 -0
  70. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/passwords.py +0 -0
  71. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/py.typed +0 -0
  72. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/retention.py +0 -0
  73. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/userconfig.py +0 -0
  74. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/verify.py +0 -0
  75. {osintkit-0.8.0 → osintkit-0.9.0}/src/osintkit/watch.py +0 -0
  76. {osintkit-0.8.0 → osintkit-0.9.0}/tests/conftest.py +0 -0
  77. {osintkit-0.8.0 → osintkit-0.9.0}/tests/fixtures/model_reply.json +0 -0
  78. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_accounts.py +0 -0
  79. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_breaches.py +0 -0
  80. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_catalogue.py +0 -0
  81. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_collectors.py +0 -0
  82. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_config.py +0 -0
  83. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_ct.py +0 -0
  84. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_diff.py +0 -0
  85. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_doctor.py +0 -0
  86. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_e2e.py +0 -0
  87. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_http.py +0 -0
  88. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_infostealer.py +0 -0
  89. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_llm.py +0 -0
  90. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_normalise.py +0 -0
  91. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_offline.py +0 -0
  92. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_passwords.py +0 -0
  93. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_pastes.py +0 -0
  94. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_pipeline.py +0 -0
  95. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_render.py +0 -0
  96. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_retention.py +0 -0
  97. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_synthesis.py +0 -0
  98. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_targets.py +0 -0
  99. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_userconfig.py +0 -0
  100. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_verify.py +0 -0
  101. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_watch.py +0 -0
  102. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_whois.py +0 -0
  103. {osintkit-0.8.0 → osintkit-0.9.0}/tests/test_xposed_analytics.py +0 -0
@@ -6,6 +6,15 @@ 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
+
9
18
  ## 0.8.0 — 2026-11-02
10
19
 
11
20
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.8.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,10 @@ 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) |
109
110
  | `watch` | Re-check on an interval, print only changes, optionally notify |
110
111
  | `history` | Past runs, replayable by id |
111
112
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
@@ -147,6 +148,8 @@ the model fail.
147
148
  undercut.
148
149
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
149
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.
150
153
  - **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
151
154
  collected data; anything unsupported is called out.
152
155
 
@@ -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,10 @@ 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
77
  | `watch` | Re-check on an interval, print only changes, optionally notify |
78
78
  | `history` | Past runs, replayable by id |
79
79
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
@@ -115,6 +115,8 @@ the model fail.
115
115
  undercut.
116
116
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
117
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.
118
120
  - **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
119
121
  collected data; anything unsupported is called out.
120
122
 
@@ -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,12 @@ 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
+
65
73
  ## Watching
66
74
 
67
75
  `watch` repeats a lookup on an interval (`--every`, minimum 60s), prints a baseline, and then
@@ -94,7 +102,8 @@ domains, and record counts. At most 15 enriched rows go into the prompt; the tru
94
102
 
95
103
  ## Password checks
96
104
 
97
- `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
98
107
  `api.pwnedpasswords.com`, and reports the count and a commonness rating. Exit code `1` means
99
108
  pwned. `--json` for machines. Nothing about the password beyond the prefix leaves the machine.
100
109
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.8.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.8.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, watch
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
@@ -646,6 +663,8 @@ def watch_cmd(
646
663
  username: str | None = typer.Option(None, "--username", help="Username target."),
647
664
  domain: str | None = typer.Option(None, "--domain", help="Domain target."),
648
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."),
649
668
  every: str = typer.Option("1d", "--every", help="Interval: 30m, 6h, 1d."),
650
669
  cycles: int = typer.Option(0, "--cycles", min=0, help="Stop after N runs; 0 means forever."),
651
670
  full: bool = typer.Option(False, "--full", help="Print the whole report on each change."),
@@ -658,7 +677,7 @@ def watch_cmd(
658
677
  out = _console()
659
678
  try:
660
679
  interval = watch.parse_duration(every)
661
- raw, declared = _pick_target(target, email, username, domain, company)
680
+ raw, declared = _pick_target(target, email, username, domain, company, phone, crypto)
662
681
  except (ValueError, TargetParseError) as exc:
663
682
  out.print(f"[red]{exc}[/red]")
664
683
  raise typer.Exit(2) from exc
@@ -694,6 +713,7 @@ def watch_cmd(
694
713
  def password(
695
714
  value: str | None = typer.Argument(None, help="Password to check; prompt if omitted."),
696
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."),
697
717
  ) -> None:
698
718
  """Check a password against Pwned Passwords, without sending it."""
699
719
  out = _console()
@@ -707,6 +727,32 @@ def password(
707
727
  out.print("[red]no password given[/red]")
708
728
  raise typer.Exit(2)
709
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
+
710
756
  settings: Settings = STATE["settings"] # type: ignore[assignment]
711
757
 
712
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