osintkit 0.5.0__tar.gz → 0.6.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 (90) hide show
  1. {osintkit-0.5.0 → osintkit-0.6.0}/CHANGELOG.md +7 -0
  2. {osintkit-0.5.0 → osintkit-0.6.0}/PKG-INFO +6 -1
  3. {osintkit-0.5.0 → osintkit-0.6.0}/README.md +5 -0
  4. {osintkit-0.5.0 → osintkit-0.6.0}/docs/REFERENCE.md +10 -0
  5. {osintkit-0.5.0 → osintkit-0.6.0}/pyproject.toml +1 -1
  6. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/__init__.py +1 -1
  7. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/__init__.py +1 -0
  8. osintkit-0.6.0/src/osintkit/collectors/infostealer.py +71 -0
  9. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/normalise.py +4 -2
  10. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/offline.py +18 -1
  11. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/pipeline.py +5 -1
  12. osintkit-0.6.0/tests/test_infostealer.py +64 -0
  13. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_normalise.py +9 -0
  14. {osintkit-0.5.0 → osintkit-0.6.0}/.editorconfig +0 -0
  15. {osintkit-0.5.0 → osintkit-0.6.0}/.github/CODEOWNERS +0 -0
  16. {osintkit-0.5.0 → osintkit-0.6.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {osintkit-0.5.0 → osintkit-0.6.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  18. {osintkit-0.5.0 → osintkit-0.6.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {osintkit-0.5.0 → osintkit-0.6.0}/.github/dependabot.yml +0 -0
  20. {osintkit-0.5.0 → osintkit-0.6.0}/.github/pull_request_template.md +0 -0
  21. {osintkit-0.5.0 → osintkit-0.6.0}/.github/workflows/ci.yml +0 -0
  22. {osintkit-0.5.0 → osintkit-0.6.0}/.github/workflows/codeql.yml +0 -0
  23. {osintkit-0.5.0 → osintkit-0.6.0}/.github/workflows/publish.yml +0 -0
  24. {osintkit-0.5.0 → osintkit-0.6.0}/.github/workflows/release.yml +0 -0
  25. {osintkit-0.5.0 → osintkit-0.6.0}/.github/workflows/smoke.yml +0 -0
  26. {osintkit-0.5.0 → osintkit-0.6.0}/.gitignore +0 -0
  27. {osintkit-0.5.0 → osintkit-0.6.0}/.mailmap +0 -0
  28. {osintkit-0.5.0 → osintkit-0.6.0}/.pre-commit-config.yaml +0 -0
  29. {osintkit-0.5.0 → osintkit-0.6.0}/CODE_OF_CONDUCT.md +0 -0
  30. {osintkit-0.5.0 → osintkit-0.6.0}/CONTRIBUTING.md +0 -0
  31. {osintkit-0.5.0 → osintkit-0.6.0}/LICENSE +0 -0
  32. {osintkit-0.5.0 → osintkit-0.6.0}/SECURITY.md +0 -0
  33. {osintkit-0.5.0 → osintkit-0.6.0}/docs/architecture.md +0 -0
  34. {osintkit-0.5.0 → osintkit-0.6.0}/docs/demo.svg +0 -0
  35. {osintkit-0.5.0 → osintkit-0.6.0}/docs/sample-output.md +0 -0
  36. {osintkit-0.5.0 → osintkit-0.6.0}/docs/social-preview.png +0 -0
  37. {osintkit-0.5.0 → osintkit-0.6.0}/examples/README.md +0 -0
  38. {osintkit-0.5.0 → osintkit-0.6.0}/examples/targets.csv +0 -0
  39. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/catalogue.py +0 -0
  40. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/cli.py +0 -0
  41. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/accounts.py +0 -0
  42. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/base.py +0 -0
  43. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/breaches.py +0 -0
  44. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/ct.py +0 -0
  45. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/dns.py +0 -0
  46. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/github.py +0 -0
  47. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/pastes.py +0 -0
  48. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/ports.py +0 -0
  49. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/subdomains.py +0 -0
  50. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/collectors/whois_lookup.py +0 -0
  51. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/config.py +0 -0
  52. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/diff.py +0 -0
  53. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/doctor.py +0 -0
  54. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/errors.py +0 -0
  55. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/http.py +0 -0
  56. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/llm.py +0 -0
  57. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/logging_store.py +0 -0
  58. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/models.py +0 -0
  59. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/passwords.py +0 -0
  60. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/py.typed +0 -0
  61. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/render.py +0 -0
  62. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/retention.py +0 -0
  63. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/synthesis.py +0 -0
  64. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/targets.py +0 -0
  65. {osintkit-0.5.0 → osintkit-0.6.0}/src/osintkit/userconfig.py +0 -0
  66. {osintkit-0.5.0 → osintkit-0.6.0}/tests/conftest.py +0 -0
  67. {osintkit-0.5.0 → osintkit-0.6.0}/tests/fixtures/model_reply.json +0 -0
  68. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_accounts.py +0 -0
  69. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_breaches.py +0 -0
  70. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_catalogue.py +0 -0
  71. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_cli.py +0 -0
  72. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_collectors.py +0 -0
  73. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_config.py +0 -0
  74. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_ct.py +0 -0
  75. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_diff.py +0 -0
  76. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_doctor.py +0 -0
  77. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_e2e.py +0 -0
  78. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_http.py +0 -0
  79. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_llm.py +0 -0
  80. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_offline.py +0 -0
  81. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_passwords.py +0 -0
  82. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_pastes.py +0 -0
  83. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_pipeline.py +0 -0
  84. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_render.py +0 -0
  85. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_retention.py +0 -0
  86. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_synthesis.py +0 -0
  87. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_targets.py +0 -0
  88. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_userconfig.py +0 -0
  89. {osintkit-0.5.0 → osintkit-0.6.0}/tests/test_whois.py +0 -0
  90. {osintkit-0.5.0 → osintkit-0.6.0}/uv.lock +0 -0
@@ -6,6 +6,13 @@ All notable changes are recorded here. The format follows
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.6.0 — 2026-11-02
10
+
11
+ ### Added
12
+
13
+ - `infostealer` collector for Hudson Rock stealer logs across email, username, and domain
14
+ - any stealer infection forces a high risk floor and a rotate-everything recommendation
15
+
9
16
  ## 0.5.0 — 2026-11-02
10
17
 
11
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osintkit
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -138,6 +138,8 @@ the model fail.
138
138
  and XposedOrNot catalogues. Those catalogues are free; only HIBP *account* checks need a key.
139
139
  - **Email domains follow the address.** An email lookup also checks the domain's DNS, mail
140
140
  posture, and WHOIS, marked as derived.
141
+ - **Info-stealer logs.** Hudson Rock reports machines infected by stealers that saved
142
+ credentials, with dates and hostnames; one infection forces a high risk floor.
141
143
  - **Risk floor.** Breach count and credential types set a floor the model may exceed but never
142
144
  undercut.
143
145
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
@@ -159,6 +161,9 @@ against the public HIBP and XposedOrNot catalogues for date, exposed fields, and
159
161
  the report can say "LinkedIn, 2021-06, emails and password hashes" instead of a bare name. Add a
160
162
  `HIBP_API_KEY` (about $4/month) to get per-account membership with the same metadata, plus pastes.
161
163
 
164
+ Stealer logs matter more than most breaches: the credentials were live on an infected machine,
165
+ not just leaked years ago. They are queried for every target type, keyless.
166
+
162
167
  Passwords are checked separately with `osintkit password`: Pwned Passwords covers hundreds of
163
168
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
164
169
  machine.
@@ -106,6 +106,8 @@ the model fail.
106
106
  and XposedOrNot catalogues. Those catalogues are free; only HIBP *account* checks need a key.
107
107
  - **Email domains follow the address.** An email lookup also checks the domain's DNS, mail
108
108
  posture, and WHOIS, marked as derived.
109
+ - **Info-stealer logs.** Hudson Rock reports machines infected by stealers that saved
110
+ credentials, with dates and hostnames; one infection forces a high risk floor.
109
111
  - **Risk floor.** Breach count and credential types set a floor the model may exceed but never
110
112
  undercut.
111
113
  - **Cached everything.** 24h default, shorter for volatile sources; repeat prompts cost nothing.
@@ -127,6 +129,9 @@ against the public HIBP and XposedOrNot catalogues for date, exposed fields, and
127
129
  the report can say "LinkedIn, 2021-06, emails and password hashes" instead of a bare name. Add a
128
130
  `HIBP_API_KEY` (about $4/month) to get per-account membership with the same metadata, plus pastes.
129
131
 
132
+ Stealer logs matter more than most breaches: the credentials were live on an infected machine,
133
+ not just leaked years ago. They are queried for every target type, keyless.
134
+
130
135
  Passwords are checked separately with `osintkit password`: Pwned Passwords covers hundreds of
131
136
  millions of leaked passwords, needs no key, and only the first five SHA-1 characters leave the
132
137
  machine.
@@ -6,6 +6,7 @@ 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
11
  | `xposed_or_not`, `leakcheck_public` | email | free public breach indexes | no |
11
12
  | `gravatar` | email | Gravatar | no |
@@ -61,6 +62,15 @@ 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
+ ## Info-stealer logs
66
+
67
+ `infostealer` queries Hudson Rock's keyless endpoint for infections that saved credentials on a
68
+ compromised machine. It returns the infection count, dates, hostnames, IPs, antivirus state, and
69
+ masked logins and passwords. Any infection forces a `high` risk floor; masked values are shown as
70
+ the source provides them and are not usable as credentials.
71
+
72
+ The address goes to Hudson Rock; nothing else does.
73
+
64
74
  ## Breach catalogue enrichment
65
75
 
66
76
  Breach names from free indexes are joined against `haveibeenpwned.com/api/v3/breaches` (keyless
@@ -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.6.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.6.0"
@@ -8,6 +8,7 @@ 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,
@@ -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
@@ -14,7 +14,7 @@ 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
  )
@@ -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,
@@ -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
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
File without changes
File without changes