Skip to content

Commit 2f9140a

Browse files
authored
Merge pull request #274 from shredzwho/feature/hudson-rock-scan
feat: integrate Hudson Rock Infostealer Intelligence
2 parents 5379909 + a7cf11e commit 2f9140a

9 files changed

Lines changed: 275 additions & 50 deletions

File tree

docs/FLAGS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
| `-ef, --email-file FILE` | Scan multiple emails from file (one per line) |
99
| `--only-found` | Only show sites where the username/email was found |
1010
| `--allow-loud` | Enable scanning sites that may send emails/notifications |
11+
| `--hudson, --hudson-scan` | Check for infostealer intelligence using Hudson Rock's API |
1112
| `-c, --category CATEGORY` | Scan all platforms in a specific category |
1213
| `-lu, --list-user` | List all available modules for username scanning |
1314
| `-le, --list-email` | List all available modules for email scanning |

tests/test_helpers.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22
import threading
3+
import json
34
from types import SimpleNamespace
45
from unittest.mock import MagicMock, patch
56

@@ -8,6 +9,7 @@
89
from user_scanner.__main__ import main
910
from user_scanner.core import helpers
1011
from user_scanner.core.result import Result
12+
from user_scanner.core.helpers import _get_config_path, load_config, save_config_value, CONFIG_PATH
1113

1214

1315
def test_get_site_name():
@@ -336,3 +338,72 @@ def worker():
336338
t.join()
337339

338340
assert len(results) == 50
341+
342+
343+
def test_get_config_path_default(monkeypatch):
344+
monkeypatch.delenv("USER_SCANNER_CONFIG", raising=False)
345+
path = _get_config_path()
346+
assert path == CONFIG_PATH
347+
348+
def test_get_config_path_env_override(monkeypatch, tmp_path):
349+
custom_path = tmp_path / "custom_config.json"
350+
monkeypatch.setenv("USER_SCANNER_CONFIG", str(custom_path))
351+
path = _get_config_path()
352+
assert path == custom_path
353+
354+
def test_load_config_creates_default(tmp_path):
355+
config_file = tmp_path / "new_config.json"
356+
data = load_config(path=config_file)
357+
358+
assert config_file.exists()
359+
assert data["auto_update_status"] is True
360+
assert data["auto_hudson_prompt"] is True
361+
362+
def test_load_config_reads_existing(tmp_path):
363+
config_file = tmp_path / "existing.json"
364+
existing_data = {
365+
"auto_update_status": False,
366+
"auto_hudson_prompt": False
367+
}
368+
config_file.write_text(json.dumps(existing_data))
369+
370+
data = load_config(path=config_file)
371+
assert data == existing_data
372+
373+
def test_save_config_value_updates_keys(tmp_path):
374+
config_file = tmp_path / "test_save.json"
375+
376+
save_config_value("auto_update_status", False, path=config_file)
377+
data = load_config(path=config_file)
378+
assert data["auto_update_status"] is False
379+
assert data["auto_hudson_prompt"] is True
380+
381+
save_config_value("auto_hudson_prompt", False, path=config_file)
382+
data = load_config(path=config_file)
383+
assert data["auto_hudson_prompt"] is False
384+
assert data["auto_update_status"] is False
385+
386+
def test_load_config_handles_corrupt_json(tmp_path):
387+
config_file = tmp_path / "corrupt.json"
388+
config_file.write_text("{ 'broken': true, }")
389+
390+
data = load_config(path=config_file)
391+
assert data["auto_update_status"] is True
392+
assert data["auto_hudson_prompt"] is True
393+
394+
def test_save_config_value_creates_directory(tmp_path):
395+
nested_path = tmp_path / "subdir" / "nested_config.json"
396+
397+
save_config_value("auto_update_status", True, path=nested_path)
398+
399+
assert nested_path.exists()
400+
assert nested_path.parent.is_dir()
401+
402+
def test_hudson_config_value():
403+
from user_scanner.core.helpers import _get_config_path, load_config
404+
405+
actual_path = _get_config_path()
406+
data = load_config(path=actual_path)
407+
status = data.get("auto_hudson_prompt")
408+
409+
assert status is True, f"FAIL: Actual config at {actual_path} has auto_hudson_prompt set to {status} (Expected: True)"

tests/test_update.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
from user_scanner.utils import updater_logic as ul
1+
from user_scanner.core import helpers as hl
2+
23

34
def test_default_config():
4-
configs = ul.load_config()
5+
configs = hl.load_config()
56
assert "auto_update_status" in configs
67
# Make sure config.json has "auto_update_status" set to true
78
assert configs["auto_update_status"]
89

10+
911
def test_config_json(tmp_path, monkeypatch):
1012
cfg = tmp_path / "config.json"
1113
monkeypatch.setenv("USER_SCANNER_CONFIG", str(cfg))
12-
configs = ul.load_config()
14+
configs = hl.load_config()
1315
assert "auto_update_status" in configs
1416
# Should be default True
1517
assert configs["auto_update_status"] is True
@@ -20,10 +22,10 @@ def test_config_set(tmp_path, monkeypatch):
2022
monkeypatch.setenv("USER_SCANNER_CONFIG", str(cfg))
2123

2224
def get_status():
23-
return ul.load_config()["auto_update_status"]
25+
return hl.load_config()["auto_update_status"]
2426

25-
ul.save_config_change(False)
27+
hl.save_config_value("auto_update_status", False)
2628
assert get_status() is False
2729

28-
ul.save_config_change(True)
30+
hl.save_config_value("auto_update_status", True)
2931
assert get_status() is True

user_scanner/__main__.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
)
3333
from user_scanner.core.result import Status
3434
from user_scanner.core.version import load_local_version
35+
from user_scanner.core.hudson import run_hudson_scan
3536
from user_scanner.utils.update import update_self
3637
from user_scanner.utils.updater_logic import check_for_updates
3738

@@ -134,6 +135,14 @@ def main():
134135

135136
parser.add_argument("-U", "--update", action="store_true", help="Update the tool")
136137

138+
parser.add_argument(
139+
"--hudson", "--hudson-scan",
140+
action="store_true",
141+
dest="hudson_scan",
142+
help="Check for infostealer intelligence using Hudson Rock's API",
143+
)
144+
145+
137146
parser.add_argument("--version", action="store_true", help="Print version")
138147

139148
args = parser.parse_args()
@@ -209,6 +218,7 @@ def main():
209218
check_for_updates()
210219
print_banner()
211220

221+
212222
# Handle bulk email file
213223
if args.email_file:
214224
try:
@@ -310,6 +320,17 @@ def main():
310320
else:
311321
print(f"\n{Fore.CYAN} Checking username: {target}{Style.RESET_ALL}")
312322

323+
324+
if args.hudson_scan:
325+
if args.category or args.module:
326+
print(f"{R}[✘] Error: --hudson cannot be used with -m or -c {X}")
327+
print(f"{Y}[i] Use it independently{X}")
328+
sys.exit(1)
329+
330+
run_hudson_scan(target, is_email)
331+
continue
332+
333+
313334
if args.module:
314335
modules = find_module(args.module.replace(".", "_"), is_email)
315336
fn = run_email_module_batch if is_email else run_user_module
@@ -345,6 +366,10 @@ def main():
345366
fn = run_email_full_batch if is_email else run_user_full
346367
results.extend(fn(target, config))
347368

369+
370+
if args.hudson_scan:
371+
sys.exit(0)
372+
348373
if args.output:
349374
content = (
350375
formatter.into_csv(results)
@@ -386,7 +411,7 @@ def main():
386411
print(G + f"\n[+] Results saved to {args.output}" + Style.RESET_ALL)
387412

388413
total_found = len([r for r in results if r.is_found()])
389-
total_skipped = len([r for r in results if r == Status.SKIPPED])
414+
total_skipped = len([r for r in results if r.status == Status.SKIPPED])
390415

391416
if args.only_found and total_found == 0:
392417
print(f"\n{R}[✘] No results found for the given target(s).{X}")

user_scanner/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"auto_update_status": true
2+
"auto_update_status": true,
3+
"auto_hudson_prompt": true
34
}

user_scanner/core/helpers.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from pathlib import Path
55
from typing import Dict, List, Optional
66
import inspect
7+
import json
8+
import os
79
import random
810
import threading
911
import functools
@@ -27,6 +29,9 @@
2729
],
2830
}
2931

32+
CONFIG_PATH = Path(__file__).parent.parent / "config.json"
33+
34+
3035
@dataclass(frozen=True)
3136
class ScanConfig:
3237
allow_loud: bool = False
@@ -232,3 +237,47 @@ def get_random_user_agent():
232237
"""return random"""
233238
random_agent = random.choice(agents)
234239
return random_agent
240+
241+
242+
def _get_config_path(path: str | Path | None = None) -> Path:
243+
"""
244+
Determine the config path in this order:
245+
1. explicit path argument (if provided)
246+
2. environment variable USER_SCANNER_CONFIG (if set)
247+
3. default CONFIG_PATH
248+
"""
249+
if path:
250+
return Path(path)
251+
env = os.environ.get("USER_SCANNER_CONFIG")
252+
if env:
253+
return Path(env)
254+
return CONFIG_PATH
255+
256+
257+
def load_config(path: str | Path | None = None) -> dict:
258+
cp = _get_config_path(path)
259+
if cp.exists():
260+
try:
261+
return json.loads(cp.read_text())
262+
except json.JSONDecodeError:
263+
# This prevents the crash on corrupted JSON
264+
pass
265+
266+
default = {
267+
"auto_update_status": True,
268+
"auto_hudson_prompt": True
269+
}
270+
cp.parent.mkdir(parents=True, exist_ok=True)
271+
cp.write_text(json.dumps(default, indent=2))
272+
return default
273+
274+
275+
276+
def save_config_value(key: str, value: Any, path: str | Path | None = None):
277+
"""Generic helper to update any specific key in the config."""
278+
cp = _get_config_path(path)
279+
content = load_config(path)
280+
content[key] = value
281+
cp.parent.mkdir(parents=True, exist_ok=True)
282+
cp.write_text(json.dumps(content, indent=2))
283+

user_scanner/core/hudson.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import httpx
2+
import json
3+
from colorama import Fore, Style
4+
from user_scanner.core.helpers import load_config, _get_config_path
5+
6+
# Color configs
7+
R = Fore.RED
8+
G = Fore.GREEN
9+
C = Fore.CYAN
10+
Y = Fore.YELLOW
11+
M = Fore.MAGENTA
12+
X = Style.RESET_ALL
13+
14+
def update_hudson_preference(show_prompt: bool):
15+
"""Updates the config file using your existing helper logic."""
16+
cp = _get_config_path()
17+
content = load_config()
18+
content["auto_hudson_prompt"] = show_prompt
19+
cp.write_text(json.dumps(content, indent=2))
20+
21+
def check_hudson_permission(target: str) -> bool:
22+
"""Disclaimers and prompts based on user config."""
23+
config = load_config()
24+
25+
# If the user already set this to false, skip the prompt and run
26+
if not config.get("auto_hudson_prompt", True):
27+
return True
28+
29+
print(f"\n{Y}[!] PRIVACY NOTICE & DISCLAIMER:{X}")
30+
print(" Hudson Rock is a third-party intelligence service.")
31+
print(f" By proceeding, the identifier '{C}{target}{Y}' will be sent to their API.")
32+
print(f" They may log queries. We are not responsible for their data handling.{X}")
33+
34+
while True:
35+
choice = input(f"\n{C}Query Hudson Rock? (y/n/d for don't ask again): {X}").lower().strip()
36+
if choice == 'y':
37+
return True
38+
elif choice == 'n':
39+
print(f"{Y}[i] Hudson Rock scan skipped.{X}")
40+
return False
41+
elif choice == 'd':
42+
update_hudson_preference(False)
43+
print(f"{G}[+] Preference saved. You won't be prompted again.{X}")
44+
return True
45+
else:
46+
print(f"{R}[!] Please enter 'y', 'n', or 'd'.{X}")
47+
48+
def run_hudson_scan(target: str, is_email: bool = False):
49+
"""
50+
Fetch and display intelligence from Hudson Rock's OSINT API.
51+
"""
52+
# Permission check using the new logic
53+
if not check_hudson_permission(target):
54+
return
55+
56+
base_url = "https://cavalier.hudsonrock.com/api/json/v2/osint-tools/"
57+
endpoint = "search-by-email" if is_email else "search-by-username"
58+
param = "email" if is_email else "username"
59+
60+
url = f"{base_url}{endpoint}?{param}={target}"
61+
62+
print(f"\n{M}== HUDSON ROCK INFOSTEALER INTELLIGENCE =={X}")
63+
print(f"{C}[i] Attribution: Data provided by Hudson Rock (https://www.hudsonrock.com){X}")
64+
print(f"{C}[*] Querying Hudson Rock for {target}...{X}")
65+
66+
try:
67+
with httpx.Client(timeout=10.0) as client:
68+
response = client.get(url)
69+
70+
if response.status_code == 200:
71+
data = response.json()
72+
stealers = data.get("stealers", [])
73+
74+
if not stealers:
75+
print(f"{G}[✔] No infostealer infections found for this {param}.{X}")
76+
return
77+
78+
total_stealers = len(stealers)
79+
print(f"{R}[!] FOUND {total_stealers} INFOSTEALER INFECTION(S) ASSOCIATED WITH THIS {param.upper()}!{X}")
80+
81+
for i, stealer in enumerate(stealers, 1):
82+
print(f"\n {Y}Infection #{i}:{X}")
83+
print(f" - Stealer Family: {stealer.get('stealer_family', 'Unknown')}")
84+
print(f" - Date Compromised: {stealer.get('date_compromised', 'Unknown')}")
85+
print(f" - Operating System: {stealer.get('operating_system', 'Unknown')}")
86+
print(f" - Computer Name: {stealer.get('computer_name', 'Unknown')}")
87+
88+
antiviruses = stealer.get('antiviruses', [])
89+
if antiviruses:
90+
print(f" - Antiviruses: {', '.join(antiviruses)}")
91+
92+
top_logins = stealer.get('top_logins', [])
93+
if top_logins:
94+
print(f" - Sample Logins Found: {', '.join(top_logins[:3])}...")
95+
96+
print(f"\n{Y}[!] Recommendation: All credentials saved on infected computers are at risk.{X}")
97+
print(f"{Y}[!] Visit https://www.hudsonrock.com/free-tools for more info.{X}")
98+
99+
elif response.status_code == 404:
100+
print(f"{G}[✔] No data found for this {param} in Hudson Rock database.{X}")
101+
else:
102+
print(f"{R}[✘] Hudson Rock API error: HTTP {response.status_code}{X}")
103+
104+
except Exception as e:
105+
print(f"{R}[✘] Error connecting to Hudson Rock: {e}{X}")

0 commit comments

Comments
 (0)