Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/lint-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Install linters
run: |
python -m pip install --upgrade pip
pip install httpx curl_cffi colorama types-colorama
pip install httpx curl_cffi colorama types-colorama rich
pip install ruff==0.15.22 mypy

- name: Run ruff
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- name: Install linters
run: |
python -m pip install --upgrade pip
pip install httpx curl_cffi colorama types-colorama
pip install httpx curl_cffi colorama types-colorama rich
pip install ruff==0.15.22 mypy

- name: Run ruff
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ user-scanner -u johndoe # single username scanning
```
### Verbose mode

Use `-v` flag to show the url of the sites being checked
Use `-v` flag to show the url of the sites being checked.
Use `--all` flag to show all sites (including those where the target was not found, skipped, or errored).
Note: By default, the scanner only displays sites where the target is found/registered.
```bash
user-scanner -v -e johndoe@gmail.com -c dev
```
Expand Down
2 changes: 1 addition & 1 deletion docs/FLAGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
| `-e, --email EMAIL` | Scan a single email across platforms |
| `-uf, --username-file FILE` | Scan multiple usernames from file (one per line) |
| `-ef, --email-file FILE` | Scan multiple emails from file (one per line) |
| `--only-found` | Only show sites where the username/email was found |
| `--allow-loud` | Enable scanning sites that may send emails/notifications |
| `--no-nsfw` | Disable NSFW site scanning |
| `--hudson, --hudson-scan` | Check for infostealer intelligence using Hudson Rock's API |
| `-c, --category CATEGORY` | Scan all platforms in a specific category (comma-separated for multiple) |
| `-lu, --list-user` | List all available modules for username scanning |
| `-le, --list-email` | List all available modules for email scanning |
| `-v, --verbose` | Enable verbose output to show urls of the websites |
| `--all` | Show all results including Not Found/Not Registered/Error/Skipped |
| `-m, --module MODULE` | Scan a specific module (comma-separated for multiple) |
| `-p, --permute PERMUTE` | Generate username permutations using a pattern/suffix |
| `-P, --proxy-file FILE` | Use proxies from file (one per line) |
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ dependencies = [
"httpx[http2]>=0.27,<0.29",
"socksio>=1.0,<2",
"colorama>=0.4,<1",
"curl_cffi>=0.7,<1"
"curl_cffi>=0.7,<1",
"rich>=13.0.0"
]

requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def validate_testsite(username):

setattr(module, "validate_testsite", validate_testsite)

orchestrator.run_user_module(module, "bob", ScanConfig())
orchestrator.run_user_module(module, "bob", ScanConfig(show_all=True))
out = capsys.readouterr().out
assert "bob" in out # Needs to be improved

Expand Down
11 changes: 9 additions & 2 deletions tests/test_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ def test_get_output_color_and_icon_per_status():
assert Result.skipped().get_output_icon() == "[~]"


def test_show_only_found_filters_non_taken(capsys):
conf = ScanConfig(only_found=True)
def test_show_default_filters_non_taken(capsys):
conf = ScanConfig()

Result.available(site_name="HiddenSite").show(conf)
assert capsys.readouterr().out == ""
Expand All @@ -297,6 +297,13 @@ def test_show_only_found_filters_non_taken(capsys):
assert "VisibleSite" in capsys.readouterr().out


def test_show_all_displays_non_taken(capsys):
conf = ScanConfig(show_all=True)

Result.available(site_name="VisibleSite").show(conf)
assert "VisibleSite" in capsys.readouterr().out


def test_extra_update_skips_none_and_blank_values():
res = Result.available()
res.update(extra={"empty_str": " ", "none_val": None, "kept": "value"})
Expand Down
22 changes: 14 additions & 8 deletions user_scanner/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@


def main():
if "--only-found" in sys.argv:
print(f"{Fore.YELLOW}[!] The '--only-found' flag is deprecated and has been removed.{Style.RESET_ALL}")
print(f"{Fore.YELLOW}[!] Showing only found modules is now the DEFAULT behavior.{Style.RESET_ALL}")
print(f"{Fore.YELLOW}[!] To show all results, use the '--all' flag.{Style.RESET_ALL}")
sys.exit(1)

parser = argparse.ArgumentParser(
prog="user-scanner",
description="Scan usernames or emails across multiple platforms.",
Expand Down Expand Up @@ -93,6 +99,12 @@ def main():
help="Enable verbose output to show urls of the websites",
)

parser.add_argument(
"--all",
action="store_true",
help="Show all results including Not Found/Not Registered/Error/Skipped.",
)

parser.add_argument(
"-s",
"--stop",
Expand Down Expand Up @@ -142,12 +154,6 @@ def main():
help="Validate proxies before scanning (tests against gstatic.com/generate_204)",
)

parser.add_argument(
"--only-found",
action="store_true",
help="Only show sites where the username/email was found",
)

parser.add_argument(
"--allow-loud",
action="store_true",
Expand Down Expand Up @@ -339,7 +345,7 @@ def main():

config = ScanConfig(
allow_loud=args.allow_loud,
only_found=args.only_found,
show_all=args.all,
no_nsfw=args.no_nsfw,
verbose=args.verbose,
timeout=args.timeout,
Expand Down Expand Up @@ -515,7 +521,7 @@ def main():
total_found = len([r for r in results if r.is_found()])
total_skipped = len([r for r in results if r.status == Status.SKIPPED])

if args.only_found and total_found == 0:
if not config.show_all and total_found == 0:
print(f"\n{R}[✘] No results found for the given target(s).{X}")
else:
print(f"\n{C}[i] Scan complete.\n Total hits:{X} {total_found}")
Expand Down
84 changes: 67 additions & 17 deletions user_scanner/core/email_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
get_global_timeout,
)
from user_scanner.core.result import Result, Status
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, MofNCompleteColumn

# Monkey-patch httpx clients to automatically use proxies for email scans
_original_async_client_init = httpx.AsyncClient.__init__
Expand Down Expand Up @@ -96,8 +97,8 @@ async def _async_worker(

result.update(**params)

# Logic to print header dynamically for --only-found streaming
if configs.only_found and result.status == Status.TAKEN:
# Logic to print header dynamically for --show-all streaming
if not configs.show_all and result.status == Status.TAKEN:
if printed_cats is not None and actual_cat not in printed_cats:
print(
f"\n{Fore.MAGENTA}== {actual_cat.upper()} SITES =={Style.RESET_ALL}"
Expand Down Expand Up @@ -128,7 +129,25 @@ async def _run_batch(

if not tasks:
return []
return list(await asyncio.gather(*tasks))

results = []

with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
transient=True,
) as progress:
task_id = progress.add_task(f"[cyan]Scanning {email}...", total=len(tasks))

for coro in asyncio.as_completed(tasks):
result = await coro
results.append(result)
progress.advance(task_id)

return results


async def _run_email_module_batch_async(
Expand All @@ -149,7 +168,7 @@ async def _run_email_category_batch_async(
modules = load_modules(category_path)
printed_cats = set()

if not configs.only_found:
if configs.show_all:
print(f"\n{Fore.MAGENTA}== {cat_name.upper()} SITES =={Style.RESET_ALL}")
printed_cats.add(cat_name)

Expand All @@ -169,22 +188,53 @@ def run_email_category_batch(
async def _run_email_full_batch_async(email: str, configs: ScanConfig) -> List[Result]:
categories = load_categories(True, configs.no_nsfw)
all_results = []
printed_cats = set()
printed_cats: Set[str] = set()

# 1. Pre-spawn all tasks for all categories (global concurrency)
category_tasks = []
total_tasks = 0
for cat_name, cat_path in categories.items():
display_name = cat_name.capitalize()
modules = load_modules(cat_path)

if not configs.only_found:
print(f"\n{Fore.MAGENTA}== {cat_name.upper()} SITES =={Style.RESET_ALL}")
printed_cats.add(cat_name)

cat_results = await _run_batch(
modules,
email,
configs,
printed_cats=printed_cats,
)
all_results.extend(cat_results)

sem = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
tasks = []
for module in modules:
tasks.append(
_async_worker(
module,
email,
sem,
configs,
printed_cats=printed_cats,
)
)
category_tasks.append((display_name, tasks))
total_tasks += len(tasks)

# 2. Await tasks category by category to stream grouped output
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
MofNCompleteColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
transient=True,
) as progress:
task_id = progress.add_task(f"[cyan]Scanning {email}...", total=total_tasks)

for display_name, tasks in category_tasks:
if not tasks:
continue

if configs.show_all:
print(f"\n{Fore.MAGENTA}== {display_name.upper()} SITES =={Style.RESET_ALL}")
printed_cats.add(display_name)

for coro in asyncio.as_completed(tasks):
result = await coro
all_results.append(result)
progress.advance(task_id)

return all_results

Expand Down
2 changes: 1 addition & 1 deletion user_scanner/core/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
class ScanConfig:
allow_loud: bool = False
no_nsfw: bool = False
only_found: bool = False
show_all: bool = False
verbose: bool = False
timeout: Optional[float] = None

Expand Down
Loading
Loading