osintkit 0.6.0__tar.gz → 0.8.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 (96) hide show
  1. {osintkit-0.6.0 → osintkit-0.8.0}/CHANGELOG.md +15 -0
  2. {osintkit-0.6.0 → osintkit-0.8.0}/PKG-INFO +20 -3
  3. {osintkit-0.6.0 → osintkit-0.8.0}/README.md +19 -2
  4. {osintkit-0.6.0 → osintkit-0.8.0}/docs/REFERENCE.md +17 -2
  5. {osintkit-0.6.0 → osintkit-0.8.0}/pyproject.toml +1 -1
  6. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/__init__.py +1 -1
  7. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/cli.py +65 -2
  8. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/__init__.py +1 -0
  9. osintkit-0.8.0/src/osintkit/collectors/xposed_analytics.py +73 -0
  10. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/config.py +2 -1
  11. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/doctor.py +2 -2
  12. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/normalise.py +1 -1
  13. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/pipeline.py +4 -1
  14. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/userconfig.py +2 -2
  15. osintkit-0.8.0/src/osintkit/verify.py +77 -0
  16. osintkit-0.8.0/src/osintkit/watch.py +97 -0
  17. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_cli.py +55 -0
  18. osintkit-0.8.0/tests/test_verify.py +54 -0
  19. osintkit-0.8.0/tests/test_watch.py +81 -0
  20. osintkit-0.8.0/tests/test_xposed_analytics.py +61 -0
  21. {osintkit-0.6.0 → osintkit-0.8.0}/.editorconfig +0 -0
  22. {osintkit-0.6.0 → osintkit-0.8.0}/.github/CODEOWNERS +0 -0
  23. {osintkit-0.6.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {osintkit-0.6.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  25. {osintkit-0.6.0 → osintkit-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {osintkit-0.6.0 → osintkit-0.8.0}/.github/dependabot.yml +0 -0
  27. {osintkit-0.6.0 → osintkit-0.8.0}/.github/pull_request_template.md +0 -0
  28. {osintkit-0.6.0 → osintkit-0.8.0}/.github/workflows/ci.yml +0 -0
  29. {osintkit-0.6.0 → osintkit-0.8.0}/.github/workflows/codeql.yml +0 -0
  30. {osintkit-0.6.0 → osintkit-0.8.0}/.github/workflows/publish.yml +0 -0
  31. {osintkit-0.6.0 → osintkit-0.8.0}/.github/workflows/release.yml +0 -0
  32. {osintkit-0.6.0 → osintkit-0.8.0}/.github/workflows/smoke.yml +0 -0
  33. {osintkit-0.6.0 → osintkit-0.8.0}/.gitignore +0 -0
  34. {osintkit-0.6.0 → osintkit-0.8.0}/.mailmap +0 -0
  35. {osintkit-0.6.0 → osintkit-0.8.0}/.pre-commit-config.yaml +0 -0
  36. {osintkit-0.6.0 → osintkit-0.8.0}/CODE_OF_CONDUCT.md +0 -0
  37. {osintkit-0.6.0 → osintkit-0.8.0}/CONTRIBUTING.md +0 -0
  38. {osintkit-0.6.0 → osintkit-0.8.0}/LICENSE +0 -0
  39. {osintkit-0.6.0 → osintkit-0.8.0}/SECURITY.md +0 -0
  40. {osintkit-0.6.0 → osintkit-0.8.0}/docs/architecture.md +0 -0
  41. {osintkit-0.6.0 → osintkit-0.8.0}/docs/demo.svg +0 -0
  42. {osintkit-0.6.0 → osintkit-0.8.0}/docs/sample-output.md +0 -0
  43. {osintkit-0.6.0 → osintkit-0.8.0}/docs/social-preview.png +0 -0
  44. {osintkit-0.6.0 → osintkit-0.8.0}/examples/README.md +0 -0
  45. {osintkit-0.6.0 → osintkit-0.8.0}/examples/targets.csv +0 -0
  46. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/catalogue.py +0 -0
  47. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/accounts.py +0 -0
  48. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/base.py +0 -0
  49. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/breaches.py +0 -0
  50. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/ct.py +0 -0
  51. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/dns.py +0 -0
  52. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/github.py +0 -0
  53. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/infostealer.py +0 -0
  54. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/pastes.py +0 -0
  55. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/ports.py +0 -0
  56. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/subdomains.py +0 -0
  57. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  58. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/diff.py +0 -0
  59. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/errors.py +0 -0
  60. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/http.py +0 -0
  61. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/llm.py +0 -0
  62. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/logging_store.py +0 -0
  63. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/models.py +0 -0
  64. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/offline.py +0 -0
  65. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/passwords.py +0 -0
  66. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/py.typed +0 -0
  67. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/render.py +0 -0
  68. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/retention.py +0 -0
  69. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/synthesis.py +0 -0
  70. {osintkit-0.6.0 → osintkit-0.8.0}/src/osintkit/targets.py +0 -0
  71. {osintkit-0.6.0 → osintkit-0.8.0}/tests/conftest.py +0 -0
  72. {osintkit-0.6.0 → osintkit-0.8.0}/tests/fixtures/model_reply.json +0 -0
  73. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_accounts.py +0 -0
  74. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_breaches.py +0 -0
  75. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_catalogue.py +0 -0
  76. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_collectors.py +0 -0
  77. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_config.py +0 -0
  78. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_ct.py +0 -0
  79. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_diff.py +0 -0
  80. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_doctor.py +0 -0
  81. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_e2e.py +0 -0
  82. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_http.py +0 -0
  83. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_infostealer.py +0 -0
  84. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_llm.py +0 -0
  85. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_normalise.py +0 -0
  86. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_offline.py +0 -0
  87. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_passwords.py +0 -0
  88. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_pastes.py +0 -0
  89. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_pipeline.py +0 -0
  90. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_render.py +0 -0
  91. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_retention.py +0 -0
  92. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_synthesis.py +0 -0
  93. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_targets.py +0 -0
  94. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_userconfig.py +0 -0
  95. {osintkit-0.6.0 → osintkit-0.8.0}/tests/test_whois.py +0 -0
  96. {osintkit-0.6.0 → osintkit-0.8.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.8.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `watch`: interval re-runs that stay silent unless a diff shows change, with optional desktop
14
+ notification and bounded cycles
15
+
16
+ ## 0.7.0 — 2026-11-02
17
+
18
+ ### Added
19
+
20
+ - `xposed_analytics`: dated, typed, verified breach membership and pastes, keyless
21
+ - post-synthesis self-check listing any claimed breach, date, host, or account URL absent from
22
+ the supplied data
23
+
9
24
  ## 0.6.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.6.0
3
+ Version: 0.8.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
 
@@ -104,6 +106,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
104
106
  | `batch` | A CSV of targets, run concurrently, reported in order |
105
107
  | `diff` | What changed between two archived runs |
106
108
  | `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
109
+ | `watch` | Re-check on an interval, print only changes, optionally notify |
107
110
  | `history` | Past runs, replayable by id |
108
111
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
109
112
  | `collectors` | Every collector and what it touches |
@@ -144,6 +147,8 @@ the model fail.
144
147
  undercut.
145
148
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
146
149
  - **Validated output.** Strict JSON schema, one repair round-trip, then a clean failure.
150
+ - **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
151
+ collected data; anything unsupported is called out.
147
152
 
148
153
  ## Collectors
149
154
 
@@ -168,6 +173,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
168
173
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
169
174
  machine.
170
175
 
176
+ ## Monitoring
177
+
178
+ ```bash
179
+ osintkit watch --domain example.com --every 1d
180
+ osintkit watch --email you@example.com --every 6h --no-notify
181
+ ```
182
+
183
+ The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
184
+ breach, a new host, a source failing, or a risk change — and optionally raise a desktop
185
+ notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
186
+ is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
187
+
171
188
  ## Safety and legality
172
189
 
173
190
  - Public data only. No logins, no scraping behind auth, no paid brokers.
@@ -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
 
@@ -72,6 +74,7 @@ Optional keys: `HIBP_API_KEY` turns on Have I Been Pwned, `GITHUB_TOKEN` lifts G
72
74
  | `batch` | A CSV of targets, run concurrently, reported in order |
73
75
  | `diff` | What changed between two archived runs |
74
76
  | `password` | Is a password in Pwned Passwords (free, no key, hash-prefix only) |
77
+ | `watch` | Re-check on an interval, print only changes, optionally notify |
75
78
  | `history` | Past runs, replayable by id |
76
79
  | `doctor` | Key, endpoint, and directory checks (`--deep` verifies auth) |
77
80
  | `collectors` | Every collector and what it touches |
@@ -112,6 +115,8 @@ the model fail.
112
115
  undercut.
113
116
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
114
117
  - **Validated output.** Strict JSON schema, one repair round-trip, then a clean failure.
118
+ - **Self-checked claims.** Every breach, date, host, and account URL is compared back to the
119
+ collected data; anything unsupported is called out.
115
120
 
116
121
  ## Collectors
117
122
 
@@ -136,6 +141,18 @@ Passwords are checked separately with `osintkit password`: Pwned Passwords cover
136
141
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
137
142
  machine.
138
143
 
144
+ ## Monitoring
145
+
146
+ ```bash
147
+ osintkit watch --domain example.com --every 1d
148
+ osintkit watch --email you@example.com --every 6h --no-notify
149
+ ```
150
+
151
+ The first run prints a baseline. Later runs print nothing unless `diff` sees movement — a new
152
+ breach, a new host, a source failing, or a risk change — and optionally raise a desktop
153
+ notification through `notify-send`. `--cycles` bounds the loop, `ctrl-c` stops it, and every run
154
+ is archived so you can inspect it afterwards. Pair with cron or a systemd timer for unattended use.
155
+
139
156
  ## Safety and legality
140
157
 
141
158
  - Public data only. No logins, no scraping behind auth, no paid brokers.
@@ -8,7 +8,7 @@ Everything the README leaves out: every flag, every collector, storage, and fail
8
8
  | --- | --- | --- | --- |
9
9
  | `infostealer` | email / username / domain | Hudson Rock stealer logs | no |
10
10
  | `hibp_breaches`, `hibp_pastes` | email | Have I Been Pwned | yes |
11
- | `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 |
12
12
  | `gravatar` | email | Gravatar | no |
13
13
  | `crt_sh_subdomains`, `crt_sh_certificates` | domain | crt.sh | no |
14
14
  | `crt_sh_organization` | company | crt.sh organisation search, with apex-domain identity fallback | no |
@@ -62,6 +62,20 @@ Explicit `--base-url` and `--model` beat both the provider and the prefix. Tempe
62
62
  Per-command: `--json`, `--markdown`, `-o/--out`, `--raw`, `--offline`, `--dry-run`, `--only`, `--skip`,
63
63
  `--ports`, `--fail-fast`, `-c/--concurrency`, `--limit`, `--show`, `--days`, `--clear`, `--deep`.
64
64
 
65
+ ## Watching
66
+
67
+ `watch` repeats a lookup on an interval (`--every`, minimum 60s), prints a baseline, and then
68
+ only reports diffs: added breaches or hosts, risk direction, source failure and recovery.
69
+ `--notify` (default) calls `notify-send` when available; `--full` prints the whole report on a
70
+ change; `--cycles N` bounds the loop. Every cycle is archived and logged like a normal run.
71
+
72
+ ## Self-check
73
+
74
+ After synthesis every breach name, date, subdomain, and account URL is compared with the supplied
75
+ data. Anything the model asserted that no source returned is listed under a "not found in source
76
+ data" note, so drift is visible instead of plausible. Checks only run for source families that
77
+ were actually collected.
78
+
65
79
  ## Info-stealer logs
66
80
 
67
81
  `infostealer` queries Hudson Rock's keyless endpoint for infections that saved credentials on a
@@ -88,7 +102,7 @@ pwned. `--json` for machines. Nothing about the password beyond the prefix leave
88
102
 
89
103
  `osintkit init` writes `~/.config/osintkit/config.toml` (or `$XDG_CONFIG_HOME`) with mode 600 in
90
104
  a 700 directory, after checking the key with a one-token request. Keys recognised:
91
- `provider`, `model`, `base_url`, `api_key`, `resolvers`.
105
+ `provider`, `model`, `base_url`, `api_key`, `hibp_api_key`, `resolvers`.
92
106
 
93
107
  `osintkit config` prints them masked, `--reveal` shows the key, `--unset KEY` removes one, and
94
108
  `--path` prints the location. Deleting the file is the same as `--unset` everything.
@@ -96,6 +110,7 @@ a 700 directory, after checking the key with a one-token request. Keys recognise
96
110
  Precedence for each value: command-line flag, then the provider-specific environment variable,
97
111
  then a key prefix, then the config file, then the built-in default. Stored values apply only when
98
112
  the stored provider matches the one in use, so a one-off key export never gets the stored key.
113
+ `HIBP_API_KEY` in the environment beats a stored `hibp_api_key`.
99
114
 
100
115
  ## Offline mode
101
116
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "osintkit"
7
- version = "0.6.0"
7
+ version = "0.8.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.6.0"
3
+ __version__ = "0.8.0"
@@ -18,7 +18,7 @@ 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 __version__, diff, doctor, passwords, pipeline, render, retention, userconfig, watch
22
22
  from .collectors import REGISTRY
23
23
  from .config import (
24
24
  DEFAULT_RESOLVERS,
@@ -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(
@@ -627,6 +639,57 @@ def init(
627
639
  out.print("[dim]try: osintkit lookup --domain example.com[/dim]")
628
640
 
629
641
 
642
+ @app.command("watch")
643
+ def watch_cmd(
644
+ target: str | None = typer.Argument(None, help="Email, username, domain, or company."),
645
+ email: str | None = typer.Option(None, "--email", help="Email target."),
646
+ username: str | None = typer.Option(None, "--username", help="Username target."),
647
+ domain: str | None = typer.Option(None, "--domain", help="Domain target."),
648
+ company: str | None = typer.Option(None, "--company", help="Company target."),
649
+ every: str = typer.Option("1d", "--every", help="Interval: 30m, 6h, 1d."),
650
+ cycles: int = typer.Option(0, "--cycles", min=0, help="Stop after N runs; 0 means forever."),
651
+ full: bool = typer.Option(False, "--full", help="Print the whole report on each change."),
652
+ notify_changes: bool = typer.Option(True, "--notify/--no-notify", help="Desktop notification."),
653
+ offline: bool = typer.Option(False, "--offline", help="Local report only; no model, no key."),
654
+ only: list[str] | None = typer.Option(None, "--only", help="Run only these collectors."),
655
+ skip: list[str] | None = typer.Option(None, "--skip", help="Skip these collectors."),
656
+ ) -> None:
657
+ """Re-check a target on a schedule and report only changes."""
658
+ out = _console()
659
+ try:
660
+ interval = watch.parse_duration(every)
661
+ raw, declared = _pick_target(target, email, username, domain, company)
662
+ except (ValueError, TargetParseError) as exc:
663
+ out.print(f"[red]{exc}[/red]")
664
+ raise typer.Exit(2) from exc
665
+
666
+ settings = _settings(offline=offline, output_json=False, output_markdown=False, raw=False)
667
+
668
+ async def run_once():
669
+ outcome = await pipeline.execute(
670
+ raw, settings, RunLogger(pipeline.new_run_id()), out, declared_type=declared
671
+ )
672
+ return outcome.report
673
+
674
+ def on_report(report, lines, baseline):
675
+ watch.render_event(report, lines, baseline, out, full=full)
676
+ if lines and notify_changes:
677
+ body = "; ".join(lines)[:180]
678
+ watch.notify(f"change for {report.target}", body)
679
+
680
+ out.print(
681
+ f"[dim]watching {render.escape(raw)} every {every}"
682
+ + (f", {cycles} runs" if cycles else "")
683
+ + " · ctrl-c stops[/dim]"
684
+ )
685
+ try:
686
+ changes = asyncio.run(watch.run(raw, run_once, on_report, every=interval, cycles=cycles))
687
+ except KeyboardInterrupt:
688
+ out.print("[yellow]stopped[/yellow]")
689
+ return
690
+ out.print(f"[dim]{changes} change event(s)[/dim]")
691
+
692
+
630
693
  @app.command()
631
694
  def password(
632
695
  value: str | None = typer.Argument(None, help="Password to check; prompt if omitted."),
@@ -698,7 +761,7 @@ def config_cmd(
698
761
  table.add_column("value")
699
762
  for name, value in stored.items():
700
763
  shown: object = value
701
- if name == "api_key" and isinstance(value, str) and not reveal:
764
+ if name in {"api_key", "hibp_api_key"} and isinstance(value, str) and not reveal:
702
765
  shown = userconfig.mask(value)
703
766
  table.add_row(name, render.escape(str(shown)))
704
767
  out.print(table)
@@ -13,6 +13,7 @@ from . import ( # noqa: F401 (import order matters: side-effect registration)
13
13
  ports,
14
14
  subdomains,
15
15
  whois_lookup,
16
+ xposed_analytics,
16
17
  )
17
18
  from .base import REGISTRY, Collector, Context, collectors_for, run_collector
18
19
 
@@ -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,7 +7,7 @@ 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,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
@@ -248,6 +248,9 @@ async def execute(
248
248
  report.analysis.risk_level = normalise.apply_floor(
249
249
  report.analysis.risk_level, signals.get("risk_floor", "low")
250
250
  )
251
+ unsupported = verify.claims(report.analysis, prompt_results)
252
+ if unsupported:
253
+ report.notes.append("not found in source data: " + "; ".join(unsupported))
251
254
  if not report.analysis.summary.strip():
252
255
  fallback = offline.build(prompt_results, report.notes, target, narrated=True)
253
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]
@@ -0,0 +1,97 @@
1
+ """Repeat a lookup on an interval and speak only when something changed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Awaitable, Callable
10
+
11
+ from . import diff, render
12
+ from .models import Report
13
+
14
+ UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
15
+ DURATION_RE = re.compile(r"^(\d+)([smhd])$")
16
+ MIN_INTERVAL = 60
17
+
18
+
19
+ def parse_duration(text: str) -> int:
20
+ match = DURATION_RE.match(text.strip().lower())
21
+ if not match:
22
+ raise ValueError(f"{text!r} is not a duration like 30m, 6h, or 1d")
23
+ value = int(match.group(1)) * UNITS[match.group(2)]
24
+ if value < MIN_INTERVAL:
25
+ raise ValueError(f"interval must be at least {MIN_INTERVAL}s")
26
+ return value
27
+
28
+
29
+ def describe(change: dict) -> list[str]:
30
+ lines: list[str] = []
31
+ if change["risk_rose"]:
32
+ lines.append(f"risk rose {change['risk']['old']} -> {change['risk']['new']}")
33
+ if change["risk_fell"]:
34
+ lines.append(f"risk fell {change['risk']['old']} -> {change['risk']['new']}")
35
+ for name in change["breaches"]["added"]:
36
+ lines.append(f"new breach: {name}")
37
+ for name in change["subdomains"]["added"]:
38
+ lines.append(f"new host: {name}")
39
+ for name in change["collectors"]["started_failing"]:
40
+ lines.append(f"source failing: {name}")
41
+ for name in change["collectors"]["started_working"]:
42
+ lines.append(f"source recovered: {name}")
43
+ return lines
44
+
45
+
46
+ def notify(title: str, body: str) -> bool:
47
+ binary = shutil.which("notify-send")
48
+ if not binary:
49
+ return False
50
+ try:
51
+ subprocess.run([binary, "osintkit", title, body], check=False, timeout=10)
52
+ return True
53
+ except (OSError, subprocess.SubprocessError):
54
+ return False
55
+
56
+
57
+ async def run(
58
+ target: str,
59
+ run_once: Callable[[], Awaitable[Report]],
60
+ on_report: Callable[[Report, list[str], bool], None],
61
+ *,
62
+ every: int,
63
+ cycles: int = 0,
64
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
65
+ ) -> int:
66
+ """Run `run_once` on an interval. Returns the number of change events."""
67
+ previous: Report | None = None
68
+ changes = 0
69
+ done = 0
70
+ while True:
71
+ report = await run_once()
72
+ done += 1
73
+ lines: list[str] = []
74
+ if previous is not None:
75
+ comparison = diff.compare(previous, report)
76
+ if not diff.is_empty(comparison):
77
+ changes += 1
78
+ lines = describe(comparison)
79
+ if previous is None or lines:
80
+ on_report(report, lines, previous is None)
81
+ previous = report
82
+ if cycles and done >= cycles:
83
+ return changes
84
+ await sleep(every)
85
+
86
+
87
+ def render_event(report: Report, lines: list[str], baseline: bool, console, *, full: bool) -> None:
88
+ stamp = render.escape(report.generated_at)
89
+ if baseline:
90
+ console.print(f"[dim]{stamp} baseline for {render.escape(report.target)}[/dim]")
91
+ render.render_report(report, console)
92
+ return
93
+ console.print(f"[yellow]change[/yellow] {stamp} {render.escape(report.target)}")
94
+ for line in lines:
95
+ console.print(f" - {render.escape(line)}")
96
+ if full:
97
+ render.render_report(report, console)
@@ -643,3 +643,58 @@ 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"
691
+
692
+
693
+ def test_watch_runs_a_single_cycle(monkeypatch):
694
+ monkeypatch.setattr(pipeline, "LLMClient", None)
695
+ result = runner.invoke(
696
+ app, ["watch", "--domain", "example.com", "--every", "60s", "--cycles", "1", "--offline"]
697
+ )
698
+ assert result.exit_code == 0
699
+ assert "baseline" in result.output
700
+ assert "0 change event" in result.output
@@ -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,81 @@
1
+ import pytest
2
+
3
+ from osintkit import watch
4
+ from osintkit.models import Analysis, Report
5
+
6
+
7
+ def report(risk="low", breaches=()):
8
+ return Report(
9
+ target="example.com",
10
+ target_type="domain",
11
+ analysis=Analysis(summary="s", risk_level=risk, breaches=[{"name": b} for b in breaches]),
12
+ )
13
+
14
+
15
+ def test_parse_duration():
16
+ assert watch.parse_duration("30m") == 1800
17
+ assert watch.parse_duration("2h") == 7200
18
+ assert watch.parse_duration("1d") == 86400
19
+
20
+
21
+ @pytest.mark.parametrize("text", ["10s", "soon", "5x", ""])
22
+ def test_parse_duration_rejects(text):
23
+ with pytest.raises(ValueError):
24
+ watch.parse_duration(text)
25
+
26
+
27
+ def test_describe_lists_changes():
28
+ change = {
29
+ "risk": {"old": "low", "new": "high"},
30
+ "risk_rose": True,
31
+ "risk_fell": False,
32
+ "breaches": {"added": ["Acme"], "removed": []},
33
+ "subdomains": {"added": ["x.example.com"], "removed": []},
34
+ "collectors": {"started_failing": ["crt_sh_subdomains"], "started_working": []},
35
+ }
36
+ lines = watch.describe(change)
37
+ assert "risk rose low -> high" in lines
38
+ assert "new breach: Acme" in lines
39
+ assert "new host: x.example.com" in lines
40
+ assert "source failing: crt_sh_subdomains" in lines
41
+
42
+
43
+ async def test_run_reports_baseline_then_only_changes():
44
+ sequence = [report(), report(), report(risk="high"), report(risk="high")]
45
+ events = []
46
+ slept = []
47
+
48
+ async def run_once():
49
+ return sequence.pop(0)
50
+
51
+ async def sleeper(seconds):
52
+ slept.append(seconds)
53
+
54
+ changes = await watch.run(
55
+ "example.com",
56
+ run_once,
57
+ lambda rep, lines, baseline: events.append((baseline, list(lines))),
58
+ every=60,
59
+ cycles=4,
60
+ sleep=sleeper,
61
+ )
62
+ assert changes == 1
63
+ assert events[0] == (True, [])
64
+ assert events[1][0] is False
65
+ assert any("risk rose" in line for line in events[1][1])
66
+
67
+
68
+ async def test_run_respects_cycles():
69
+ events = []
70
+
71
+ async def run_once():
72
+ return report()
73
+
74
+ async def sleeper(seconds):
75
+ return None
76
+
77
+ changes = await watch.run(
78
+ "example.com", run_once, lambda *a: events.append(a), every=60, cycles=3, sleep=sleeper
79
+ )
80
+ assert changes == 0
81
+ assert len(events) == 1
@@ -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