osintkit 0.5.0__tar.gz → 0.7.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 (94) hide show
  1. {osintkit-0.5.0 → osintkit-0.7.0}/CHANGELOG.md +15 -0
  2. {osintkit-0.5.0 → osintkit-0.7.0}/PKG-INFO +12 -3
  3. {osintkit-0.5.0 → osintkit-0.7.0}/README.md +11 -2
  4. {osintkit-0.5.0 → osintkit-0.7.0}/docs/REFERENCE.md +20 -2
  5. {osintkit-0.5.0 → osintkit-0.7.0}/pyproject.toml +1 -1
  6. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/__init__.py +1 -1
  7. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/cli.py +13 -1
  8. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/__init__.py +2 -0
  9. osintkit-0.7.0/src/osintkit/collectors/infostealer.py +71 -0
  10. osintkit-0.7.0/src/osintkit/collectors/xposed_analytics.py +73 -0
  11. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/config.py +2 -1
  12. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/doctor.py +2 -2
  13. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/normalise.py +5 -3
  14. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/offline.py +18 -1
  15. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/pipeline.py +9 -2
  16. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/userconfig.py +2 -2
  17. osintkit-0.7.0/src/osintkit/verify.py +77 -0
  18. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_cli.py +45 -0
  19. osintkit-0.7.0/tests/test_infostealer.py +64 -0
  20. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_normalise.py +9 -0
  21. osintkit-0.7.0/tests/test_verify.py +54 -0
  22. osintkit-0.7.0/tests/test_xposed_analytics.py +61 -0
  23. {osintkit-0.5.0 → osintkit-0.7.0}/.editorconfig +0 -0
  24. {osintkit-0.5.0 → osintkit-0.7.0}/.github/CODEOWNERS +0 -0
  25. {osintkit-0.5.0 → osintkit-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  26. {osintkit-0.5.0 → osintkit-0.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  27. {osintkit-0.5.0 → osintkit-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  28. {osintkit-0.5.0 → osintkit-0.7.0}/.github/dependabot.yml +0 -0
  29. {osintkit-0.5.0 → osintkit-0.7.0}/.github/pull_request_template.md +0 -0
  30. {osintkit-0.5.0 → osintkit-0.7.0}/.github/workflows/ci.yml +0 -0
  31. {osintkit-0.5.0 → osintkit-0.7.0}/.github/workflows/codeql.yml +0 -0
  32. {osintkit-0.5.0 → osintkit-0.7.0}/.github/workflows/publish.yml +0 -0
  33. {osintkit-0.5.0 → osintkit-0.7.0}/.github/workflows/release.yml +0 -0
  34. {osintkit-0.5.0 → osintkit-0.7.0}/.github/workflows/smoke.yml +0 -0
  35. {osintkit-0.5.0 → osintkit-0.7.0}/.gitignore +0 -0
  36. {osintkit-0.5.0 → osintkit-0.7.0}/.mailmap +0 -0
  37. {osintkit-0.5.0 → osintkit-0.7.0}/.pre-commit-config.yaml +0 -0
  38. {osintkit-0.5.0 → osintkit-0.7.0}/CODE_OF_CONDUCT.md +0 -0
  39. {osintkit-0.5.0 → osintkit-0.7.0}/CONTRIBUTING.md +0 -0
  40. {osintkit-0.5.0 → osintkit-0.7.0}/LICENSE +0 -0
  41. {osintkit-0.5.0 → osintkit-0.7.0}/SECURITY.md +0 -0
  42. {osintkit-0.5.0 → osintkit-0.7.0}/docs/architecture.md +0 -0
  43. {osintkit-0.5.0 → osintkit-0.7.0}/docs/demo.svg +0 -0
  44. {osintkit-0.5.0 → osintkit-0.7.0}/docs/sample-output.md +0 -0
  45. {osintkit-0.5.0 → osintkit-0.7.0}/docs/social-preview.png +0 -0
  46. {osintkit-0.5.0 → osintkit-0.7.0}/examples/README.md +0 -0
  47. {osintkit-0.5.0 → osintkit-0.7.0}/examples/targets.csv +0 -0
  48. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/catalogue.py +0 -0
  49. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/accounts.py +0 -0
  50. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/base.py +0 -0
  51. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/breaches.py +0 -0
  52. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/ct.py +0 -0
  53. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/dns.py +0 -0
  54. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/github.py +0 -0
  55. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/pastes.py +0 -0
  56. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/ports.py +0 -0
  57. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/subdomains.py +0 -0
  58. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  59. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/diff.py +0 -0
  60. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/errors.py +0 -0
  61. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/http.py +0 -0
  62. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/llm.py +0 -0
  63. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/logging_store.py +0 -0
  64. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/models.py +0 -0
  65. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/passwords.py +0 -0
  66. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/py.typed +0 -0
  67. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/render.py +0 -0
  68. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/retention.py +0 -0
  69. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/synthesis.py +0 -0
  70. {osintkit-0.5.0 → osintkit-0.7.0}/src/osintkit/targets.py +0 -0
  71. {osintkit-0.5.0 → osintkit-0.7.0}/tests/conftest.py +0 -0
  72. {osintkit-0.5.0 → osintkit-0.7.0}/tests/fixtures/model_reply.json +0 -0
  73. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_accounts.py +0 -0
  74. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_breaches.py +0 -0
  75. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_catalogue.py +0 -0
  76. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_collectors.py +0 -0
  77. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_config.py +0 -0
  78. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_ct.py +0 -0
  79. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_diff.py +0 -0
  80. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_doctor.py +0 -0
  81. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_e2e.py +0 -0
  82. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_http.py +0 -0
  83. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_llm.py +0 -0
  84. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_offline.py +0 -0
  85. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_passwords.py +0 -0
  86. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_pastes.py +0 -0
  87. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_pipeline.py +0 -0
  88. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_render.py +0 -0
  89. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_retention.py +0 -0
  90. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_synthesis.py +0 -0
  91. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_targets.py +0 -0
  92. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_userconfig.py +0 -0
  93. {osintkit-0.5.0 → osintkit-0.7.0}/tests/test_whois.py +0 -0
  94. {osintkit-0.5.0 → osintkit-0.7.0}/uv.lock +0 -0
@@ -6,6 +6,21 @@ All notable changes are recorded here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.7.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `xposed_analytics`: dated, typed, verified breach membership and pastes, keyless
14
+ - post-synthesis self-check listing any claimed breach, date, host, or account URL absent from
15
+ the supplied data
16
+
17
+ ## 0.6.0 — 2026-11-02
18
+
19
+ ### Added
20
+
21
+ - `infostealer` collector for Hudson Rock stealer logs across email, username, and domain
22
+ - any stealer infection forces a high risk floor and a rotate-everything recommendation
23
+
9
24
  ## 0.5.0 — 2026-11-02
10
25
 
11
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.5.0
3
+ Version: 0.7.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
@@ -93,8 +93,10 @@ Any OpenAI-compatible endpoint works. `init --provider ollama` needs no key for
93
93
  `--provider`, `--base-url`, and `--model` cover everything else. The preset table, key variables,
94
94
  and precedence order are in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
95
95
 
96
- Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
97
- 5000 requests/hour. Everything else needs no credentials.
96
+ Optional keys: a Have I Been Pwned subscription (`--hibp-key` on init, or `HIBP_API_KEY`) adds
97
+ per-account breach and paste membership; `GITHUB_TOKEN` lifts GitHub from 60 to 5000
98
+ requests/hour. Both stay local — the key is yours, and every user of the tool brings their own.
99
+ Everything else needs no credentials.
98
100
 
99
101
  ## Commands
100
102
 
@@ -138,10 +140,14 @@ the model fail.
138
140
  and XposedOrNot catalogues. Those catalogues are free; only HIBP *account* checks need a key.
139
141
  - **Email domains follow the address.** An email lookup also checks the domain's DNS, mail
140
142
  posture, and WHOIS, marked as derived.
143
+ - **Info-stealer logs.** Hudson Rock reports machines infected by stealers that saved
144
+ credentials, with dates and hostnames; one infection forces a high risk floor.
141
145
  - **Risk floor.** Breach count and credential types set a floor the model may exceed but never
142
146
  undercut.
143
147
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
144
148
  - **Validated output.** Strict JSON schema, one repair round-trip, then a clean failure.
149
+ - **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
150
+ collected data; anything unsupported is called out.
145
151
 
146
152
  ## Collectors
147
153
 
@@ -159,6 +165,9 @@ against the public HIBP and XposedOrNot catalogues for date, exposed fields, and
159
165
  the report can say "LinkedIn, 2021-06, emails and password hashes" instead of a bare name. Add a
160
166
  `HIBP_API_KEY` (about $4/month) to get per-account membership with the same metadata, plus pastes.
161
167
 
168
+ Stealer logs matter more than most breaches: the credentials were live on an infected machine,
169
+ not just leaked years ago. They are queried for every target type, keyless.
170
+
162
171
  Passwords are checked separately with `osintkit password`: Pwned Passwords covers hundreds of
163
172
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
164
173
  machine.
@@ -61,8 +61,10 @@ Any OpenAI-compatible endpoint works. `init --provider ollama` needs no key for
61
61
  `--provider`, `--base-url`, and `--model` cover everything else. The preset table, key variables,
62
62
  and precedence order are in [docs/REFERENCE.md](docs/REFERENCE.md#providers).
63
63
 
64
- Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts GitHub from 60 to
65
- 5000 requests/hour. Everything else needs no credentials.
64
+ Optional keys: a Have I Been Pwned subscription (`--hibp-key` on init, or `HIBP_API_KEY`) adds
65
+ per-account breach and paste membership; `GITHUB_TOKEN` lifts GitHub from 60 to 5000
66
+ requests/hour. Both stay local — the key is yours, and every user of the tool brings their own.
67
+ Everything else needs no credentials.
66
68
 
67
69
  ## Commands
68
70
 
@@ -106,10 +108,14 @@ the model fail.
106
108
  and XposedOrNot catalogues. Those catalogues are free; only HIBP *account* checks need a key.
107
109
  - **Email domains follow the address.** An email lookup also checks the domain's DNS, mail
108
110
  posture, and WHOIS, marked as derived.
111
+ - **Info-stealer logs.** Hudson Rock reports machines infected by stealers that saved
112
+ credentials, with dates and hostnames; one infection forces a high risk floor.
109
113
  - **Risk floor.** Breach count and credential types set a floor the model may exceed but never
110
114
  undercut.
111
115
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
112
116
  - **Validated output.** Strict JSON schema, one repair round-trip, then a clean failure.
117
+ - **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
118
+ collected data; anything unsupported is called out.
113
119
 
114
120
  ## Collectors
115
121
 
@@ -127,6 +133,9 @@ against the public HIBP and XposedOrNot catalogues for date, exposed fields, and
127
133
  the report can say "LinkedIn, 2021-06, emails and password hashes" instead of a bare name. Add a
128
134
  `HIBP_API_KEY` (about $4/month) to get per-account membership with the same metadata, plus pastes.
129
135
 
136
+ Stealer logs matter more than most breaches: the credentials were live on an infected machine,
137
+ not just leaked years ago. They are queried for every target type, keyless.
138
+
130
139
  Passwords are checked separately with `osintkit password`: Pwned Passwords covers hundreds of
131
140
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
132
141
  machine.
@@ -6,8 +6,9 @@ Everything the README leaves out: every flag, every collector, storage, and fail
6
6
 
7
7
  | Collector | Targets | Source | Needs key |
8
8
  | --- | --- | --- | --- |
9
+ | `infostealer` | email / username / domain | Hudson Rock stealer logs | no |
9
10
  | `hibp_breaches`, `hibp_pastes` | email | Have I Been Pwned | yes |
10
- | `xposed_or_not`, `leakcheck_public` | email | free public breach indexes | no |
11
+ | `xposed_or_not`, `xposed_analytics`, `leakcheck_public` | email | free public breach indexes | no |
11
12
  | `gravatar` | email | Gravatar | no |
12
13
  | `crt_sh_subdomains`, `crt_sh_certificates` | domain | crt.sh | no |
13
14
  | `crt_sh_organization` | company | crt.sh organisation search, with apex-domain identity fallback | no |
@@ -61,6 +62,22 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
61
62
  Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
62
63
  `--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
63
64
 
65
+ ## Self-check
66
+
67
+ After synthesis every breach name, date, subdomain, and account URL is compared with the supplied
68
+ data. Anything the model asserted that no source returned is listed under a "not found in source
69
+ data" note, so drift is visible instead of plausible. Checks only run for source families that
70
+ were actually collected.
71
+
72
+ ## Info-stealer logs
73
+
74
+ `infostealer` queries Hudson Rock's keyless endpoint for infections that saved credentials on a
75
+ compromised machine. It returns the infection count, dates, hostnames, IPs, antivirus state, and
76
+ masked logins and passwords. Any infection forces a `high` risk floor; masked values are shown as
77
+ the source provides them and are not usable as credentials.
78
+
79
+ The address goes to Hudson Rock; nothing else does.
80
+
64
81
  ## Breach catalogue enrichment
65
82
 
66
83
  Breach names from free indexes are joined against `haveibeenpwned.com/api/v3/breaches` (keyless
@@ -78,7 +95,7 @@ pwned. `--json` for machines. Nothing about the password beyond the prefix leave
78
95
 
79
96
  `osintkit init` writes `~/.config/osintkit/config.toml` (or `$XDG_CONFIG_HOME`) with mode 600 in
80
97
  a 700 directory, after checking the key with a one-token request. Keys recognised:
81
- `provider`, `model`, `base_url`, `api_key`, `resolvers`.
98
+ `provider`, `model`, `base_url`, `api_key`, `hibp_api_key`, `resolvers`.
82
99
 
83
100
  `osintkit config` prints them masked, `--reveal` shows the key, `--unset KEY` removes one, and
84
101
  `--path` prints the location. Deleting the file is the same as `--unset` everything.
@@ -86,6 +103,7 @@ a 700 directory, after checking the key with a one-token request. Keys recognise
86
103
  Precedence for each value: command-line flag, then the provider-specific environment variable,
87
104
  then a key prefix, then the config file, then the built-in default. Stored values apply only when
88
105
  the stored provider matches the one in use, so a one-off key export never gets the stored key.
106
+ `HIBP_API_KEY` in the environment beats a stored `hibp_api_key`.
89
107
 
90
108
  ## Offline mode
91
109
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.5.0"
7
+ version = "0.7.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.5.0"
3
+ __version__ = "0.7.0"
@@ -112,6 +112,7 @@ def root(
112
112
  base_url=base_url or (stored.get("base_url") if config_applies else None) or preset[0],
113
113
  model=model or (stored.get("model") if config_applies else None) or preset[1],
114
114
  api_key=api_key,
115
+ hibp_api_key_value=stored.get("hibp_api_key"),
115
116
  temperature=temperature,
116
117
  max_tokens=max_tokens,
117
118
  timeout=timeout,
@@ -561,6 +562,9 @@ def init(
561
562
  provider: str | None = typer.Option(None, "--provider", help="Provider to store."),
562
563
  api_key: str | None = typer.Option(None, "--api-key", help="Key to store."),
563
564
  model: str | None = typer.Option(None, "--model", help="Default model to store."),
565
+ hibp_key: str | None = typer.Option(
566
+ None, "--hibp-key", help="Optional Have I Been Pwned key for breach and paste checks."
567
+ ),
564
568
  base_url: str | None = typer.Option(None, "--base-url", help="Custom endpoint to store."),
565
569
  force: bool = typer.Option(False, "--force", help="Replace an existing config."),
566
570
  verify: bool = typer.Option(True, "--verify/--no-verify", help="Check the key afterwards."),
@@ -603,11 +607,19 @@ def init(
603
607
  raise typer.Exit(2)
604
608
  data["api_key"] = key
605
609
 
610
+ stored_hibp = stored.get("hibp_api_key")
611
+ if hibp_key:
612
+ data["hibp_api_key"] = hibp_key
613
+ elif stored_hibp and not force:
614
+ data["hibp_api_key"] = stored_hibp
615
+
606
616
  written = userconfig.save(data)
607
617
  out.print(f"[green]stored[/green] {written} (mode 600)")
608
618
  out.print(f"provider: {chosen}")
609
619
  if data.get("api_key"):
610
620
  out.print(f"key: {userconfig.mask(str(data['api_key']))}")
621
+ if data.get("hibp_api_key"):
622
+ out.print(f"HIBP key: {userconfig.mask(str(data['hibp_api_key']))}")
611
623
 
612
624
  if verify:
613
625
  probe = Settings(
@@ -698,7 +710,7 @@ def config_cmd(
698
710
  table.add_column("value")
699
711
  for name, value in stored.items():
700
712
  shown: object = value
701
- if name == "api_key" and isinstance(value, str) and not reveal:
713
+ if name in {"api_key", "hibp_api_key"} and isinstance(value, str) and not reveal:
702
714
  shown = userconfig.mask(value)
703
715
  table.add_row(name, render.escape(str(shown)))
704
716
  out.print(table)
@@ -8,10 +8,12 @@ from . import ( # noqa: F401 (import order matters: side-effect registration)
8
8
  ct,
9
9
  dns,
10
10
  github,
11
+ infostealer,
11
12
  pastes,
12
13
  ports,
13
14
  subdomains,
14
15
  whois_lookup,
16
+ xposed_analytics,
15
17
  )
16
18
  from .base import REGISTRY, Collector, Context, collectors_for, run_collector
17
19
 
@@ -0,0 +1,71 @@
1
+ """Info-stealer log exposure via Hudson Rock's keyless endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+ from .base import Context, collector
8
+
9
+ BASE = "https://cavalier.hudsonrock.com/api/json/v2/osint-tools/search-by"
10
+ MAX_STEALERS = 5
11
+
12
+
13
+ def _clean(value: object) -> str | None:
14
+ text = str(value or "").strip()
15
+ if not text or text.lower() == "not found":
16
+ return None
17
+ return text[:120]
18
+
19
+
20
+ def _summary(body: object) -> dict:
21
+ if not isinstance(body, dict):
22
+ return {"infected": False, "infections": 0, "stealers": []}
23
+ raw = body.get("stealers") or []
24
+ stealers = []
25
+ for row in raw[:MAX_STEALERS]:
26
+ if not isinstance(row, dict):
27
+ continue
28
+ stealers.append(
29
+ {
30
+ "date": (row.get("date_compromised") or "")[:10] or None,
31
+ "computer": _clean(row.get("computer_name")),
32
+ "os": _clean(row.get("operating_system")),
33
+ "ip": _clean(row.get("ip")),
34
+ "antiviruses": [av for av in row.get("antiviruses") or [] if av != "None"][:5],
35
+ "logins": [str(item) for item in (row.get("top_logins") or [])[:5]],
36
+ "passwords": [str(item) for item in (row.get("top_passwords") or [])[:5]],
37
+ }
38
+ )
39
+ return {
40
+ "infected": bool(stealers),
41
+ "infections": len(raw),
42
+ "corporate_services": body.get("total_corporate_services"),
43
+ "user_services": body.get("total_user_services"),
44
+ "stealers": stealers,
45
+ "source": "hudsonrock",
46
+ }
47
+
48
+
49
+ async def _lookup(ctx: Context, kind: str) -> dict:
50
+ response = await ctx.http.get(
51
+ f"{BASE}-{kind}",
52
+ namespace="hudsonrock",
53
+ params={kind: ctx.target},
54
+ empty_on=(404, 429),
55
+ timeout=ctx.http_timeout or 45,
56
+ )
57
+ if response.status_code != 200:
58
+ return {"infected": False, "infections": 0, "stealers": [], "note": f"HTTP {response.status_code}"}
59
+ try:
60
+ return _summary(response.json())
61
+ except ValueError:
62
+ return {"infected": False, "infections": 0, "stealers": [], "note": "non-JSON"}
63
+
64
+
65
+ @collector("infostealer", ("email", "username", "domain"), "Info-stealer log infections", order=5, ttl=43200)
66
+ async def infostealer(ctx: Context) -> dict:
67
+ kind = {"email": "email", "username": "username", "domain": "domain"}[ctx.target_type]
68
+ result = await _lookup(ctx, kind)
69
+ result["target_kind"] = kind
70
+ result["checked"] = quote(ctx.target)
71
+ return result
@@ -0,0 +1,73 @@
1
+ """XposedOrNot breach analytics: dated, typed membership plus pastes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .base import Context, collector
8
+
9
+ URL = "https://api.xposedornot.com/v1/breach-analytics"
10
+ MAX_LISTED = 80
11
+
12
+
13
+ def _types(value: Any) -> list[str]:
14
+ return [part.strip() for part in str(value or "").split(";") if part.strip()]
15
+
16
+
17
+ def _breach(row: dict[str, Any]) -> dict[str, Any] | None:
18
+ name = str(row.get("breach") or "").strip()
19
+ if not name:
20
+ return None
21
+ date = str(row.get("xposed_date") or "").strip() or None
22
+ return {
23
+ "name": name,
24
+ "date": date,
25
+ "data_types": _types(row.get("xposed_data")),
26
+ "domain": row.get("domain"),
27
+ "pwn_count": row.get("xposed_records"),
28
+ "verified": str(row.get("verified") or "").lower() == "yes",
29
+ "password_risk": row.get("password_risk"),
30
+ "source": "xposedornot-analytics",
31
+ }
32
+
33
+
34
+ def _paste(row: dict[str, Any]) -> dict[str, Any] | None:
35
+ paste_id = str(row.get("paste_id") or row.get("id") or "").strip()
36
+ if not paste_id and not row.get("title"):
37
+ return None
38
+ return {
39
+ "id": paste_id or None,
40
+ "title": row.get("title"),
41
+ "date": row.get("date") or row.get("xposed_date"),
42
+ "source": row.get("source") or "xposedornot-analytics",
43
+ "origin": "xposedornot",
44
+ }
45
+
46
+
47
+ @collector("xposed_analytics", ("email",), "Dated breach and paste membership", order=14, ttl=21600)
48
+ async def xposed_analytics(ctx: Context) -> dict:
49
+ response = await ctx.http.get(
50
+ URL,
51
+ namespace="xposed-analytics",
52
+ params={"email": ctx.target},
53
+ empty_on=(404, 422, 429),
54
+ timeout=ctx.http_timeout or 60,
55
+ )
56
+ if response.status_code != 200:
57
+ return {"breaches": [], "pastes": [], "note": f"HTTP {response.status_code}"}
58
+ try:
59
+ body = response.json()
60
+ except ValueError:
61
+ return {"breaches": [], "pastes": [], "note": "non-JSON"}
62
+
63
+ raw = ((body.get("ExposedBreaches") or {}).get("breaches_details")) or []
64
+ breaches = [entry for entry in (_breach(row) for row in raw if isinstance(row, dict)) if entry]
65
+ raw_pastes = ((body.get("ExposedPastes") or {}).get("pastes_details")) or []
66
+ pastes = [entry for entry in (_paste(row) for row in raw_pastes if isinstance(row, dict)) if entry]
67
+ breaches.sort(key=lambda row: str(row.get("date") or ""), reverse=True)
68
+ return {
69
+ "found": len(breaches),
70
+ "breaches": breaches[:MAX_LISTED],
71
+ "pastes": pastes[:25],
72
+ "source": "xposedornot-analytics",
73
+ }
@@ -109,6 +109,7 @@ class Settings:
109
109
  base_url: str = DEFAULT_BASE_URL
110
110
  model: str = DEFAULT_MODEL
111
111
  api_key: str | None = None
112
+ hibp_api_key_value: str | None = None
112
113
  temperature: float = DEFAULT_TEMPERATURE
113
114
  max_tokens: int = 4000
114
115
  timeout: float = DEFAULT_TIMEOUT
@@ -127,7 +128,7 @@ class Settings:
127
128
 
128
129
  @property
129
130
  def hibp_api_key(self) -> str | None:
130
- return os.environ.get("HIBP_API_KEY") or None
131
+ return os.environ.get("HIBP_API_KEY") or self.hibp_api_key_value or None
131
132
 
132
133
  @property
133
134
  def github_token(self) -> str | None:
@@ -124,10 +124,10 @@ def collect_checks(settings: Settings) -> list[Check]:
124
124
  ),
125
125
  ]
126
126
  for name, variable, why in (
127
- ("Have I Been Pwned", "HIBP_API_KEY", "breach collectors will be skipped"),
127
+ ("Have I Been Pwned", "HIBP_API_KEY", "breach and paste collectors will be skipped"),
128
128
  ("GitHub", "GITHUB_TOKEN", "GitHub is limited to 60 requests/hour"),
129
129
  ):
130
- present = bool(os.environ.get(variable))
130
+ present = bool(os.environ.get(variable)) or (name.startswith("Have I") and bool(settings.hibp_api_key))
131
131
  checks.append(Check(name, "ok" if present else "warn", "set" if present else why))
132
132
  if key:
133
133
  checks.append(Check("api key shape", "ok" if len(key) >= 20 else "warn", f"{len(key)} chars"))
@@ -7,14 +7,14 @@ from typing import Any
7
7
 
8
8
  from .models import CollectorResult
9
9
 
10
- BREACH_SOURCES = frozenset({"hibp_breaches", "xposed_or_not", "leakcheck_public"})
10
+ BREACH_SOURCES = frozenset({"hibp_breaches", "xposed_or_not", "xposed_analytics", "leakcheck_public"})
11
11
  KEY_RE = re.compile(r"[^a-z0-9]+")
12
12
  CREDENTIAL_RE = re.compile(r"pass|hash|credential|secret|token|api[ _-]?key", re.I)
13
13
  SEVERITY = ("low", "medium", "high")
14
14
  MAX_PROMPT_BREACHES = 15
15
15
 
16
16
 
17
- def signals_for(merged: list[dict[str, Any]]) -> dict[str, Any]:
17
+ def signals_for(merged: list[dict[str, Any]], infections: int = 0) -> dict[str, Any]:
18
18
  """Compute risk signals the model can check itself against."""
19
19
  credential = [
20
20
  entry
@@ -22,10 +22,12 @@ def signals_for(merged: list[dict[str, Any]]) -> dict[str, Any]:
22
22
  if any(CREDENTIAL_RE.search(str(kind)) for kind in entry.get("data_types", []))
23
23
  ]
24
24
  count = len(merged)
25
+ floor = "high" if infections else _floor(count, len(credential))
25
26
  return {
26
27
  "breach_count": count,
27
28
  "credential_bearing": len(credential),
28
- "risk_floor": _floor(count, len(credential)),
29
+ "stealer_infections": infections,
30
+ "risk_floor": floor,
29
31
  }
30
32
 
31
33
 
@@ -91,6 +91,15 @@ def _infrastructure(results: list[CollectorResult], report_notes: list[str]) ->
91
91
  if isinstance(wildcard, dict) and wildcard.get("wildcard"):
92
92
  notes.append("Wildcard DNS is enabled, so unknown labels resolve")
93
93
 
94
+ stealer = _data(results, "infostealer")
95
+ if isinstance(stealer, dict) and stealer.get("infections"):
96
+ first = (stealer.get("stealers") or [{}])[0]
97
+ notes.append(
98
+ f"{stealer['infections']} info-stealer infection(s)"
99
+ + (f", latest {first.get('date')}" if first.get("date") else "")
100
+ + (f" on {first.get('computer')}" if first.get("computer") else "")
101
+ )
102
+
94
103
  whois = _data(results, "whois_registration")
95
104
  if isinstance(whois, dict) and whois.get("registrar"):
96
105
  parts = [f"registrar {whois['registrar']}"]
@@ -117,9 +126,16 @@ def _infrastructure(results: list[CollectorResult], report_notes: list[str]) ->
117
126
 
118
127
 
119
128
  def _recommendations(
120
- creds: int, mail: dict | None, wildcard: bool, ports: list[int], breach_count: int
129
+ creds: int,
130
+ mail: dict | None,
131
+ wildcard: bool,
132
+ ports: list[int],
133
+ breach_count: int,
134
+ infections: int = 0,
121
135
  ) -> list[str]:
122
136
  recs: list[str] = []
137
+ if infections:
138
+ recs.append("Change every password saved on the infected machine, then sign out all sessions")
123
139
  if creds:
124
140
  recs.append("Rotate passwords for the affected services and enable MFA where offered")
125
141
  elif breach_count:
@@ -204,5 +220,6 @@ def build(
204
220
  wildcard,
205
221
  risky,
206
222
  signals["breach_count"],
223
+ int((_data(results, "infostealer") or {}).get("infections") or 0),
207
224
  ),
208
225
  )
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
 
15
15
  from rich.console import Console
16
16
 
17
- from . import catalogue, collectors, normalise, offline, render, retention, synthesis
17
+ from . import catalogue, collectors, normalise, offline, render, retention, synthesis, verify
18
18
  from .config import Settings
19
19
  from .errors import MissingAPIKey, OsintKitError, SynthesisError, TargetParseError
20
20
  from .http import HttpClient
@@ -186,7 +186,11 @@ async def execute(
186
186
  logger.event("breaches_merged", duplicates=duplicates)
187
187
 
188
188
  merged_breaches = next((r.data for r in prompt_results if r.collector == "breach_index"), [])
189
- signals = normalise.signals_for(merged_breaches)
189
+ stealer = next((r.data for r in prompt_results if r.collector == "infostealer"), {})
190
+ infections = int((stealer or {}).get("infections") or 0)
191
+ signals = normalise.signals_for(merged_breaches, infections)
192
+ if infections:
193
+ report.notes.append(f"{infections} info-stealer infection(s) observed")
190
194
 
191
195
  bundle = PromptBundle(
192
196
  target=target,
@@ -244,6 +248,9 @@ async def execute(
244
248
  report.analysis.risk_level = normalise.apply_floor(
245
249
  report.analysis.risk_level, signals.get("risk_floor", "low")
246
250
  )
251
+ unsupported = verify.claims(report.analysis, prompt_results)
252
+ if unsupported:
253
+ report.notes.append("not found in source data: " + "; ".join(unsupported))
247
254
  if not report.analysis.summary.strip():
248
255
  fallback = offline.build(prompt_results, report.notes, target, narrated=True)
249
256
  report.analysis.summary = fallback.summary
@@ -7,8 +7,8 @@ from pathlib import Path
7
7
  from typing import Any
8
8
 
9
9
  FILENAME = "config.toml"
10
- KEYS = ("provider", "model", "base_url", "api_key", "resolvers")
11
- SECRET_KEYS = frozenset({"api_key"})
10
+ KEYS = ("provider", "model", "base_url", "api_key", "hibp_api_key", "resolvers")
11
+ SECRET_KEYS = frozenset({"api_key", "hibp_api_key"})
12
12
 
13
13
 
14
14
  def config_home() -> Path:
@@ -0,0 +1,77 @@
1
+ """Post-synthesis check that claims exist in the collected data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .models import Analysis, CollectorResult
6
+
7
+ MAX_ISSUES = 5
8
+
9
+
10
+ def _breach_rows(results: list[CollectorResult]) -> list[dict]:
11
+ rows: list[dict] = []
12
+ for result in results:
13
+ if result.collector == "breach_index" and isinstance(result.data, list):
14
+ rows.extend(row for row in result.data if isinstance(row, dict))
15
+ return rows
16
+
17
+
18
+ def _names(results: list[CollectorResult]) -> set[str]:
19
+ return {str(row.get("name") or "").strip().lower() for row in _breach_rows(results)}
20
+
21
+
22
+ def _dates(results: list[CollectorResult]) -> set[str]:
23
+ return {str(row.get("date") or "")[:10] for row in _breach_rows(results) if row.get("date")}
24
+
25
+
26
+ def _subdomains(results: list[CollectorResult]) -> set[str]:
27
+ names: set[str] = set()
28
+ for result in results:
29
+ data = result.data
30
+ if result.collector in {"crt_sh_subdomains"} and isinstance(data, list):
31
+ names.update(str(item).lower() for item in data)
32
+ if result.collector == "passive_subdomains" and isinstance(data, dict):
33
+ for key in ("live_hosts", "common_prefix_hits"):
34
+ names.update(str(row.get("host")).lower() for row in data.get(key) or [] if row.get("host"))
35
+ return names
36
+
37
+
38
+ def _urls(results: list[CollectorResult]) -> set[str]:
39
+ urls: set[str] = set()
40
+ for result in results:
41
+ data = result.data
42
+ if result.collector == "username_presence" and isinstance(data, dict):
43
+ urls.update(str(row.get("url")) for row in data.get("found") or [] if row.get("url"))
44
+ if result.collector == "github_profile" and isinstance(data, dict):
45
+ url = (data.get("profile") or {}).get("url")
46
+ if url:
47
+ urls.add(str(url))
48
+ return urls
49
+
50
+
51
+ def claims(analysis: Analysis, results: list[CollectorResult]) -> list[str]:
52
+ """Return short descriptions of claims that the source data does not support."""
53
+ issues: list[str] = []
54
+ names = _names(results)
55
+ dates = _dates(results)
56
+ hosts = _subdomains(results)
57
+ urls = _urls(results)
58
+ checked = {result.collector for result in results}
59
+ has_breaches = "breach_index" in checked
60
+ has_hosts = bool(checked & {"crt_sh_subdomains", "passive_subdomains"})
61
+ has_accounts = bool(checked & {"username_presence", "github_profile"})
62
+
63
+ for breach in analysis.breaches:
64
+ if has_breaches and breach.name.strip().lower() not in names:
65
+ issues.append(f"breach {breach.name!r} not in the supplied data")
66
+ if has_breaches and breach.date and breach.date[:10] not in dates:
67
+ issues.append(f"date {breach.date} for {breach.name!r} not in the supplied data")
68
+
69
+ for host in analysis.subdomains:
70
+ if has_hosts and host.strip().lower() not in hosts:
71
+ issues.append(f"subdomain {host!r} not in the supplied data")
72
+
73
+ for account in analysis.associated_accounts:
74
+ if has_accounts and account.url and account.url.rstrip("/") not in {url.rstrip("/") for url in urls}:
75
+ issues.append(f"account URL {account.url!r} not in the supplied data")
76
+
77
+ return issues[:MAX_ISSUES]
@@ -643,3 +643,48 @@ def test_password_command_hit_and_miss(monkeypatch):
643
643
  miss = runner.invoke(app, ["password", "hunter3", "--json"])
644
644
  assert miss.exit_code == 0
645
645
  assert jsonish(miss.output)["pwned"] is False
646
+
647
+
648
+ def test_init_stores_optional_hibp_key(xdg):
649
+ from osintkit import userconfig
650
+
651
+ result = runner.invoke(
652
+ app,
653
+ ["init", "--provider", "venice", "--api-key", "k" * 30, "--hibp-key", "h" * 32, "--no-verify"],
654
+ )
655
+ assert result.exit_code == 0
656
+ assert userconfig.load()["hibp_api_key"] == "h" * 32
657
+ shown = runner.invoke(app, ["config"])
658
+ assert "hhhhhh…" in shown.output
659
+ assert "h" * 32 not in shown.output
660
+
661
+
662
+ def test_stored_hibp_key_reaches_settings(xdg, monkeypatch):
663
+ from osintkit import userconfig
664
+
665
+ userconfig.save({"provider": "venice", "api_key": "k" * 30, "hibp_api_key": "stored-hibp"})
666
+ captured = {}
667
+
668
+ async def spy(target, settings, *args, **kwargs):
669
+ captured["hibp"] = settings.hibp_api_key
670
+ raise RuntimeError("stop")
671
+
672
+ monkeypatch.setattr(pipeline, "execute", spy)
673
+ runner.invoke(app, ["lookup", "--domain", "a.com"])
674
+ assert captured["hibp"] == "stored-hibp"
675
+
676
+
677
+ def test_env_hibp_key_beats_stored(xdg, monkeypatch):
678
+ from osintkit import userconfig
679
+
680
+ userconfig.save({"provider": "venice", "api_key": "k" * 30, "hibp_api_key": "stored-hibp"})
681
+ monkeypatch.setenv("HIBP_API_KEY", "env-hibp")
682
+ captured = {}
683
+
684
+ async def spy(target, settings, *args, **kwargs):
685
+ captured["hibp"] = settings.hibp_api_key
686
+ raise RuntimeError("stop")
687
+
688
+ monkeypatch.setattr(pipeline, "execute", spy)
689
+ runner.invoke(app, ["lookup", "--domain", "a.com"])
690
+ assert captured["hibp"] == "env-hibp"
@@ -0,0 +1,64 @@
1
+ import httpx
2
+ import respx
3
+
4
+ from osintkit.collectors import infostealer
5
+
6
+ URL = "https://cavalier.hudsonrock.com/api/json/v2/osint-tools/search-by-email"
7
+
8
+
9
+ @respx.mock
10
+ async def test_infections_are_summarised(make_context):
11
+ ctx = make_context("a@b.co", "email")
12
+ respx.get(URL).mock(
13
+ return_value=httpx.Response(
14
+ 200,
15
+ json={
16
+ "stealers": [
17
+ {
18
+ "date_compromised": "2026-10-31T00:00:00.000Z",
19
+ "computer_name": "DESKTOP-1 (David)",
20
+ "operating_system": "Not Found",
21
+ "ip": "105.118.***.**",
22
+ "antiviruses": ["None"],
23
+ "top_logins": ["a***n"],
24
+ "top_passwords": ["v**************E"],
25
+ }
26
+ ],
27
+ "total_corporate_services": 3,
28
+ "total_user_services": 9,
29
+ },
30
+ )
31
+ )
32
+ result = await infostealer.infostealer(ctx)
33
+ assert result["infected"] is True
34
+ assert result["infections"] == 1
35
+ assert result["stealers"][0]["date"] == "2026-10-31"
36
+ assert result["stealers"][0]["os"] is None
37
+ assert result["stealers"][0]["antiviruses"] == []
38
+
39
+
40
+ @respx.mock
41
+ async def test_clean_target(make_context):
42
+ ctx = make_context("a@b.co", "email")
43
+ respx.get(URL).mock(return_value=httpx.Response(200, json={"stealers": []}))
44
+ result = await infostealer.infostealer(ctx)
45
+ assert result["infected"] is False
46
+
47
+
48
+ @respx.mock
49
+ async def test_rate_limited_is_not_an_error(make_context):
50
+ ctx = make_context("a@b.co", "email")
51
+ respx.get(URL).mock(return_value=httpx.Response(429))
52
+ result = await infostealer.infostealer(ctx)
53
+ assert result["infections"] == 0
54
+ assert "429" in result["note"]
55
+
56
+
57
+ @respx.mock
58
+ async def test_domain_uses_domain_endpoint(make_context):
59
+ ctx = make_context("example.com", "domain")
60
+ route = respx.get("https://cavalier.hudsonrock.com/api/json/v2/osint-tools/search-by-domain").mock(
61
+ return_value=httpx.Response(200, json={"stealers": []})
62
+ )
63
+ await infostealer.infostealer(ctx)
64
+ assert route.calls[0].request.url.params["domain"] == "example.com"
@@ -116,3 +116,12 @@ def test_dict_shaped_sources_are_merged():
116
116
  row = next(r for r in kept if r.collector == "breach_index").data[0]
117
117
  assert row["sources"] == ["leakcheck_public", "xposedornot"]
118
118
  assert row["date"] == "2020-01-01"
119
+
120
+
121
+ def test_infections_force_a_high_floor():
122
+ assert normalise.signals_for([], infections=1)["risk_floor"] == "high"
123
+ assert normalise.signals_for([], infections=0)["risk_floor"] == "low"
124
+
125
+
126
+ def test_infections_are_counted_in_signals():
127
+ assert normalise.signals_for([], infections=3)["stealer_infections"] == 3
@@ -0,0 +1,54 @@
1
+ from osintkit import verify
2
+ from osintkit.models import Analysis, CollectorResult
3
+
4
+
5
+ def result(name, data):
6
+ return CollectorResult(collector=name, target="t", target_type="email", data=data)
7
+
8
+
9
+ def analysis(**kwargs):
10
+ base = {"summary": "s", "risk_level": "low"}
11
+ base.update(kwargs)
12
+ return Analysis.model_validate(base)
13
+
14
+
15
+ def test_supported_claims_pass():
16
+ results = [result("breach_index", [{"name": "Acme", "date": "2020-01-01"}])]
17
+ check = analysis(breaches=[{"name": "Acme", "date": "2020-01-01"}])
18
+ assert verify.claims(check, results) == []
19
+
20
+
21
+ def test_unknown_breach_is_flagged():
22
+ results = [result("breach_index", [{"name": "Acme"}])]
23
+ check = analysis(breaches=[{"name": "Invented"}])
24
+ assert any("Invented" in issue for issue in verify.claims(check, results))
25
+
26
+
27
+ def test_wrong_date_is_flagged():
28
+ results = [result("breach_index", [{"name": "Acme", "date": "2020-01-01"}])]
29
+ check = analysis(breaches=[{"name": "Acme", "date": "2024-09-09"}])
30
+ assert any("2024-09-09" in issue for issue in verify.claims(check, results))
31
+
32
+
33
+ def test_unknown_subdomain_and_url_are_flagged():
34
+ results = [
35
+ result("crt_sh_subdomains", ["api.example.com"]),
36
+ result("username_presence", {"found": [{"url": "https://github.com/other"}]}),
37
+ ]
38
+ check = analysis(
39
+ subdomains=["api.example.com", "made-up.example.com"],
40
+ associated_accounts=[{"platform": "GitHub", "url": "https://github.com/x"}],
41
+ )
42
+ issues = verify.claims(check, results)
43
+ assert any("made-up" in issue for issue in issues)
44
+ assert any("github.com/x" in issue for issue in issues)
45
+
46
+
47
+ def test_no_source_data_means_no_noise():
48
+ assert verify.claims(analysis(breaches=[{"name": "Anything"}]), []) == []
49
+
50
+
51
+ def test_issue_list_is_capped():
52
+ results = [result("breach_index", [])]
53
+ check = analysis(breaches=[{"name": f"B{index}"} for index in range(20)])
54
+ assert len(verify.claims(check, results)) == verify.MAX_ISSUES
@@ -0,0 +1,61 @@
1
+ import httpx
2
+ import respx
3
+
4
+ from osintkit.collectors import xposed_analytics as xa
5
+
6
+ URL = "https://api.xposedornot.com/v1/breach-analytics"
7
+
8
+
9
+ @respx.mock
10
+ async def test_breaches_are_normalised_and_sorted(make_context):
11
+ ctx = make_context("a@b.co", "email")
12
+ respx.get(URL).mock(
13
+ return_value=httpx.Response(
14
+ 200,
15
+ json={
16
+ "ExposedBreaches": {
17
+ "breaches_details": [
18
+ {"breach": "Old", "xposed_date": "2019", "xposed_data": "Emails;Passwords"},
19
+ {"breach": "New", "xposed_date": "2026", "xposed_records": 10, "verified": "Yes"},
20
+ ]
21
+ }
22
+ },
23
+ )
24
+ )
25
+ result = await xa.xposed_analytics(ctx)
26
+ assert [row["name"] for row in result["breaches"]] == ["New", "Old"]
27
+ assert result["breaches"][1]["data_types"] == ["Emails", "Passwords"]
28
+ assert result["breaches"][0]["verified"] is True
29
+
30
+
31
+ @respx.mock
32
+ async def test_pastes_are_normalised(make_context):
33
+ ctx = make_context("a@b.co", "email")
34
+ respx.get(URL).mock(
35
+ return_value=httpx.Response(
36
+ 200,
37
+ json={"ExposedPastes": {"pastes_details": [{"paste_id": "abc", "title": "t"}]}},
38
+ )
39
+ )
40
+ result = await xa.xposed_analytics(ctx)
41
+ assert result["pastes"][0]["id"] == "abc"
42
+
43
+
44
+ @respx.mock
45
+ async def test_error_status_is_a_note_not_a_crash(make_context):
46
+ ctx = make_context("a@b.co", "email")
47
+ respx.get(URL).mock(return_value=httpx.Response(429))
48
+ result = await xa.xposed_analytics(ctx)
49
+ assert result["breaches"] == []
50
+ assert "429" in result["note"]
51
+
52
+
53
+ @respx.mock
54
+ async def test_junk_rows_are_dropped(make_context):
55
+ ctx = make_context("a@b.co", "email")
56
+ respx.get(URL).mock(
57
+ return_value=httpx.Response(
58
+ 200, json={"ExposedBreaches": {"breaches_details": [None, {}, {"breach": ""}]}}
59
+ )
60
+ )
61
+ assert (await xa.xposed_analytics(ctx))["breaches"] == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes