argis 0.1.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.
- argis-0.1.0/.github/workflows/ci.yml +42 -0
- argis-0.1.0/.gitignore +11 -0
- argis-0.1.0/PKG-INFO +120 -0
- argis-0.1.0/README.md +104 -0
- argis-0.1.0/pyproject.toml +36 -0
- argis-0.1.0/src/argis/__init__.py +3 -0
- argis-0.1.0/src/argis/__main__.py +4 -0
- argis-0.1.0/src/argis/cli.py +147 -0
- argis-0.1.0/src/argis/core.py +155 -0
- argis-0.1.0/src/argis/diff.py +119 -0
- argis-0.1.0/src/argis/exceptions.py +25 -0
- argis-0.1.0/src/argis/sites.json +506 -0
- argis-0.1.0/src/argis/utils/__init__.py +0 -0
- argis-0.1.0/src/argis/utils/display.py +93 -0
- argis-0.1.0/src/argis/utils/export.py +44 -0
- argis-0.1.0/src/argis/utils/network.py +60 -0
- argis-0.1.0/tests/__init__.py +0 -0
- argis-0.1.0/tests/test_core.py +108 -0
- argis-0.1.0/tests/test_diff.py +62 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
branches: [main]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- name: Install package with dev dependencies
|
|
22
|
+
run: pip install -e ".[dev]"
|
|
23
|
+
- name: Run tests
|
|
24
|
+
run: pytest -v
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
needs: test
|
|
28
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
- uses: actions/setup-python@v5
|
|
33
|
+
with:
|
|
34
|
+
python-version: "3.12"
|
|
35
|
+
- name: Build distribution
|
|
36
|
+
run: |
|
|
37
|
+
pip install build
|
|
38
|
+
python -m build
|
|
39
|
+
- name: Publish to PyPI
|
|
40
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
41
|
+
with:
|
|
42
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
argis-0.1.0/.gitignore
ADDED
argis-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asynchronous username hunter with historical diff tracking across social platforms.
|
|
5
|
+
Author: mohilisop
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: async,cli,osint,security,username
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx[http2]>=0.27.0
|
|
10
|
+
Requires-Dist: rich>=13.7.0
|
|
11
|
+
Requires-Dist: typer>=0.12.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Argis 👁️
|
|
18
|
+
|
|
19
|
+
**The all-seeing username scanner.**
|
|
20
|
+
|
|
21
|
+
Argis hunts down a username across dozens of platforms concurrently, tells
|
|
22
|
+
you where it's registered, and — unlike most tools in this space — tracks
|
|
23
|
+
how that footprint *changes* over time.
|
|
24
|
+
|
|
25
|
+
Named after Argus, the hundred-eyed giant of Greek myth: one scan, every
|
|
26
|
+
platform, watched at once.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Async everything.** Built on `httpx` + `asyncio`; scans 80+ sites in
|
|
31
|
+
parallel instead of one at a time.
|
|
32
|
+
- **Diff engine.** `--diff` compares the current scan against your last
|
|
33
|
+
saved run and shows exactly what got registered or deleted.
|
|
34
|
+
- **False-positive resistant.** Detection rules per site (status code,
|
|
35
|
+
page-text match, or redirect-URL match) instead of blindly trusting a
|
|
36
|
+
200 OK.
|
|
37
|
+
- **Pretty terminal UI.** Live progress bar and color-coded results via
|
|
38
|
+
`rich`.
|
|
39
|
+
- **Exportable.** `--export csv|json|markdown` for piping into other tools.
|
|
40
|
+
- **Proxy / Tor support.** Route scans through a proxy or local Tor.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# From source, editable (for development):
|
|
46
|
+
pip install -e .
|
|
47
|
+
|
|
48
|
+
# Or with pipx (recommended once published):
|
|
49
|
+
pipx install argis
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Requires Python 3.10+.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Basic scan
|
|
58
|
+
argis scan john_doe
|
|
59
|
+
|
|
60
|
+
# Scan and compare against the last saved run
|
|
61
|
+
argis scan john_doe --diff
|
|
62
|
+
|
|
63
|
+
# Don't save this run to history
|
|
64
|
+
argis scan john_doe --no-save
|
|
65
|
+
|
|
66
|
+
# Export results
|
|
67
|
+
argis scan john_doe --export markdown -o john_doe_report.md
|
|
68
|
+
|
|
69
|
+
# Route through Tor
|
|
70
|
+
argis scan john_doe --tor
|
|
71
|
+
|
|
72
|
+
# View past scans
|
|
73
|
+
argis history john_doe
|
|
74
|
+
|
|
75
|
+
# Wipe saved history
|
|
76
|
+
argis clear-history john_doe
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## How detection works
|
|
80
|
+
|
|
81
|
+
Each entry in `src/argis/sites.json` defines a URL template plus a rule for
|
|
82
|
+
recognizing a "not found" response:
|
|
83
|
+
|
|
84
|
+
| `error_type` | Meaning |
|
|
85
|
+
|----------------|-------------------------------------------------------------------|
|
|
86
|
+
| `status_code` | Account doesn't exist if the response status matches `error_criteria` |
|
|
87
|
+
| `message` | Account doesn't exist if `error_criteria` text appears in the HTML |
|
|
88
|
+
| `response_url` | Account doesn't exist if the final (post-redirect) URL matches |
|
|
89
|
+
|
|
90
|
+
Add your own targets by editing `sites.json` — no code changes required.
|
|
91
|
+
|
|
92
|
+
## History storage
|
|
93
|
+
|
|
94
|
+
Scan history is stored per-username as JSON at
|
|
95
|
+
`~/.argis/history/<username>.json`. Each file holds a bounded list of past
|
|
96
|
+
snapshots (newest last), which is what `--diff` and `argis history` read
|
|
97
|
+
from.
|
|
98
|
+
|
|
99
|
+
## Project layout
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
argis/
|
|
103
|
+
├── pyproject.toml
|
|
104
|
+
├── src/argis/
|
|
105
|
+
│ ├── cli.py # typer commands
|
|
106
|
+
│ ├── core.py # async scanning engine
|
|
107
|
+
│ ├── diff.py # history storage + diff computation
|
|
108
|
+
│ ├── exceptions.py
|
|
109
|
+
│ ├── sites.json # target platforms + detection rules
|
|
110
|
+
│ └── utils/
|
|
111
|
+
│ ├── display.py # rich UI
|
|
112
|
+
│ ├── network.py # httpx client, UA rotation, proxy/Tor
|
|
113
|
+
│ └── export.py # csv/json/markdown export
|
|
114
|
+
└── tests/
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Disclaimer
|
|
118
|
+
|
|
119
|
+
Use responsibly. Only look up usernames you have a legitimate reason to
|
|
120
|
+
investigate, and respect the terms of service of the sites you query.
|
argis-0.1.0/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Argis 👁️
|
|
2
|
+
|
|
3
|
+
**The all-seeing username scanner.**
|
|
4
|
+
|
|
5
|
+
Argis hunts down a username across dozens of platforms concurrently, tells
|
|
6
|
+
you where it's registered, and — unlike most tools in this space — tracks
|
|
7
|
+
how that footprint *changes* over time.
|
|
8
|
+
|
|
9
|
+
Named after Argus, the hundred-eyed giant of Greek myth: one scan, every
|
|
10
|
+
platform, watched at once.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Async everything.** Built on `httpx` + `asyncio`; scans 80+ sites in
|
|
15
|
+
parallel instead of one at a time.
|
|
16
|
+
- **Diff engine.** `--diff` compares the current scan against your last
|
|
17
|
+
saved run and shows exactly what got registered or deleted.
|
|
18
|
+
- **False-positive resistant.** Detection rules per site (status code,
|
|
19
|
+
page-text match, or redirect-URL match) instead of blindly trusting a
|
|
20
|
+
200 OK.
|
|
21
|
+
- **Pretty terminal UI.** Live progress bar and color-coded results via
|
|
22
|
+
`rich`.
|
|
23
|
+
- **Exportable.** `--export csv|json|markdown` for piping into other tools.
|
|
24
|
+
- **Proxy / Tor support.** Route scans through a proxy or local Tor.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# From source, editable (for development):
|
|
30
|
+
pip install -e .
|
|
31
|
+
|
|
32
|
+
# Or with pipx (recommended once published):
|
|
33
|
+
pipx install argis
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires Python 3.10+.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Basic scan
|
|
42
|
+
argis scan john_doe
|
|
43
|
+
|
|
44
|
+
# Scan and compare against the last saved run
|
|
45
|
+
argis scan john_doe --diff
|
|
46
|
+
|
|
47
|
+
# Don't save this run to history
|
|
48
|
+
argis scan john_doe --no-save
|
|
49
|
+
|
|
50
|
+
# Export results
|
|
51
|
+
argis scan john_doe --export markdown -o john_doe_report.md
|
|
52
|
+
|
|
53
|
+
# Route through Tor
|
|
54
|
+
argis scan john_doe --tor
|
|
55
|
+
|
|
56
|
+
# View past scans
|
|
57
|
+
argis history john_doe
|
|
58
|
+
|
|
59
|
+
# Wipe saved history
|
|
60
|
+
argis clear-history john_doe
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## How detection works
|
|
64
|
+
|
|
65
|
+
Each entry in `src/argis/sites.json` defines a URL template plus a rule for
|
|
66
|
+
recognizing a "not found" response:
|
|
67
|
+
|
|
68
|
+
| `error_type` | Meaning |
|
|
69
|
+
|----------------|-------------------------------------------------------------------|
|
|
70
|
+
| `status_code` | Account doesn't exist if the response status matches `error_criteria` |
|
|
71
|
+
| `message` | Account doesn't exist if `error_criteria` text appears in the HTML |
|
|
72
|
+
| `response_url` | Account doesn't exist if the final (post-redirect) URL matches |
|
|
73
|
+
|
|
74
|
+
Add your own targets by editing `sites.json` — no code changes required.
|
|
75
|
+
|
|
76
|
+
## History storage
|
|
77
|
+
|
|
78
|
+
Scan history is stored per-username as JSON at
|
|
79
|
+
`~/.argis/history/<username>.json`. Each file holds a bounded list of past
|
|
80
|
+
snapshots (newest last), which is what `--diff` and `argis history` read
|
|
81
|
+
from.
|
|
82
|
+
|
|
83
|
+
## Project layout
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
argis/
|
|
87
|
+
├── pyproject.toml
|
|
88
|
+
├── src/argis/
|
|
89
|
+
│ ├── cli.py # typer commands
|
|
90
|
+
│ ├── core.py # async scanning engine
|
|
91
|
+
│ ├── diff.py # history storage + diff computation
|
|
92
|
+
│ ├── exceptions.py
|
|
93
|
+
│ ├── sites.json # target platforms + detection rules
|
|
94
|
+
│ └── utils/
|
|
95
|
+
│ ├── display.py # rich UI
|
|
96
|
+
│ ├── network.py # httpx client, UA rotation, proxy/Tor
|
|
97
|
+
│ └── export.py # csv/json/markdown export
|
|
98
|
+
└── tests/
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Disclaimer
|
|
102
|
+
|
|
103
|
+
Use responsibly. Only look up usernames you have a legitimate reason to
|
|
104
|
+
investigate, and respect the terms of service of the sites you query.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "argis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Asynchronous username hunter with historical diff tracking across social platforms."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "mohilisop" }]
|
|
13
|
+
keywords = ["osint", "username", "cli", "security", "async"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"typer>=0.12.0",
|
|
16
|
+
"httpx[http2]>=0.27.0",
|
|
17
|
+
"rich>=13.7.0",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0.0",
|
|
23
|
+
"pytest-asyncio>=0.23.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
argis = "argis.cli:app"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/argis"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
33
|
+
"src/argis/sites.json" = "argis/sites.json"
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Command-line interface for Argis. Parses flags and delegates to core.py
|
|
2
|
+
and diff.py — this module should stay free of scanning/HTTP logic."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from argis import diff as diffmod
|
|
14
|
+
from argis.core import ArgisEngine
|
|
15
|
+
from argis.exceptions import ArgisError
|
|
16
|
+
from argis.utils import display
|
|
17
|
+
from argis.utils.display import console
|
|
18
|
+
from argis.utils.export import export_results
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
help="Argis: the all-seeing username scanner. Hunt down accounts across "
|
|
22
|
+
"dozens of platforms and track how a username's footprint changes over time."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def scan(
|
|
28
|
+
username: str = typer.Argument(..., help="The target username to hunt down."),
|
|
29
|
+
diff: bool = typer.Option(
|
|
30
|
+
False, "--diff", "-d", help="Compare this scan against the last saved scan."
|
|
31
|
+
),
|
|
32
|
+
save: bool = typer.Option(
|
|
33
|
+
True, "--save/--no-save", help="Save this scan to history for future --diff runs."
|
|
34
|
+
),
|
|
35
|
+
proxy: Optional[str] = typer.Option(
|
|
36
|
+
None, "--proxy", help="Route requests through a proxy, e.g. socks5://127.0.0.1:9050"
|
|
37
|
+
),
|
|
38
|
+
tor: bool = typer.Option(
|
|
39
|
+
False, "--tor", help="Route requests through a local Tor SOCKS5 proxy."
|
|
40
|
+
),
|
|
41
|
+
timeout: float = typer.Option(7.0, "--timeout", help="Per-request timeout in seconds."),
|
|
42
|
+
concurrency: int = typer.Option(
|
|
43
|
+
30, "--concurrency", help="Maximum number of simultaneous requests."
|
|
44
|
+
),
|
|
45
|
+
export: Optional[str] = typer.Option(
|
|
46
|
+
None, "--export", help="Export format: csv, json, or markdown."
|
|
47
|
+
),
|
|
48
|
+
output: Optional[Path] = typer.Option(
|
|
49
|
+
None, "-o", "--output", help="Output file path for --export (default: <username>.<ext>)."
|
|
50
|
+
),
|
|
51
|
+
quiet: bool = typer.Option(
|
|
52
|
+
False, "--quiet", "-q", help="Suppress the progress bar and live [+] hits."
|
|
53
|
+
),
|
|
54
|
+
):
|
|
55
|
+
"""Search for a target username across all configured platforms."""
|
|
56
|
+
if not quiet:
|
|
57
|
+
display.print_banner(username)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
engine = ArgisEngine(
|
|
61
|
+
username,
|
|
62
|
+
proxy=proxy,
|
|
63
|
+
use_tor=tor,
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
concurrency=concurrency,
|
|
66
|
+
)
|
|
67
|
+
results = asyncio.run(engine.run_scan(quiet=quiet))
|
|
68
|
+
except ArgisError as exc:
|
|
69
|
+
console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
|
|
72
|
+
if not quiet:
|
|
73
|
+
console.print()
|
|
74
|
+
display.print_results_table(results, username)
|
|
75
|
+
display.print_summary(results)
|
|
76
|
+
|
|
77
|
+
if diff:
|
|
78
|
+
previous = diffmod.get_last_scan(username)
|
|
79
|
+
console.print()
|
|
80
|
+
if previous is None:
|
|
81
|
+
console.print(
|
|
82
|
+
"[dim]No previous scan found for this username — nothing to diff "
|
|
83
|
+
"against. This scan will become the baseline.[/dim]"
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
delta = diffmod.compute_diff(previous["results"], results)
|
|
87
|
+
display.print_diff(delta)
|
|
88
|
+
|
|
89
|
+
if save:
|
|
90
|
+
diffmod.save_scan(username, results)
|
|
91
|
+
|
|
92
|
+
if export:
|
|
93
|
+
fmt = export.lower()
|
|
94
|
+
ext = {"json": "json", "csv": "csv", "markdown": "md"}.get(fmt)
|
|
95
|
+
if ext is None:
|
|
96
|
+
console.print(f"[bold red]Unsupported export format:[/bold red] {export}")
|
|
97
|
+
raise typer.Exit(code=1)
|
|
98
|
+
out_path = output or Path(f"{username}.{ext}")
|
|
99
|
+
export_results(results, username, fmt, out_path)
|
|
100
|
+
console.print(f"[dim]Exported results to {out_path}[/dim]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def history(
|
|
105
|
+
username: str = typer.Argument(..., help="Username whose scan history to display."),
|
|
106
|
+
limit: int = typer.Option(10, "--limit", help="Maximum number of past scans to show."),
|
|
107
|
+
):
|
|
108
|
+
"""Show past scan timestamps and found-profile counts for a username."""
|
|
109
|
+
records = diffmod.load_history(username)
|
|
110
|
+
if not records:
|
|
111
|
+
console.print(f"[dim]No history found for '{username}'.[/dim]")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
table = Table(title=f"Scan history for @{username}")
|
|
115
|
+
table.add_column("#")
|
|
116
|
+
table.add_column("Timestamp")
|
|
117
|
+
table.add_column("Found")
|
|
118
|
+
table.add_column("Total sites")
|
|
119
|
+
|
|
120
|
+
for i, record in enumerate(records[-limit:], start=1):
|
|
121
|
+
found = sum(1 for r in record["results"].values() if r["status"] == "FOUND")
|
|
122
|
+
table.add_row(str(i), record["timestamp"], str(found), str(len(record["results"])))
|
|
123
|
+
|
|
124
|
+
console.print(table)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.command("clear-history")
|
|
128
|
+
def clear_history(
|
|
129
|
+
username: str = typer.Argument(..., help="Username whose history should be deleted."),
|
|
130
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
|
|
131
|
+
):
|
|
132
|
+
"""Delete all saved scan history for a username."""
|
|
133
|
+
if not yes:
|
|
134
|
+
confirmed = typer.confirm(f"Delete all saved history for '{username}'?")
|
|
135
|
+
if not confirmed:
|
|
136
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
137
|
+
raise typer.Exit()
|
|
138
|
+
|
|
139
|
+
removed = diffmod.clear_history(username)
|
|
140
|
+
if removed:
|
|
141
|
+
console.print(f"[green]History cleared for '{username}'.[/green]")
|
|
142
|
+
else:
|
|
143
|
+
console.print(f"[dim]No history existed for '{username}'.[/dim]")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
app()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The operational heart of Argis: loads site rules, runs concurrent async
|
|
2
|
+
HTTP checks, and applies detection rules to filter out false positives."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from argis.exceptions import SiteConfigError
|
|
13
|
+
from argis.utils.display import console, make_progress, print_found
|
|
14
|
+
from argis.utils.network import build_client, random_user_agent
|
|
15
|
+
|
|
16
|
+
# Generic markers that indicate a WAF/bot-challenge page rather than a real
|
|
17
|
+
# answer about account existence (e.g. Cloudflare's "Client Challenge",
|
|
18
|
+
# reCAPTCHA walls). These can return HTTP 200, so status-code rules alone
|
|
19
|
+
# would misread them as FOUND. Checked before any per-site rule runs.
|
|
20
|
+
_CHALLENGE_MARKERS = (
|
|
21
|
+
"client challenge",
|
|
22
|
+
"checking your browser",
|
|
23
|
+
"attention required",
|
|
24
|
+
"verify you are human",
|
|
25
|
+
"just a moment...",
|
|
26
|
+
"captcha",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArgisEngine:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
username: str,
|
|
34
|
+
*,
|
|
35
|
+
proxy: str | None = None,
|
|
36
|
+
use_tor: bool = False,
|
|
37
|
+
timeout: float = 7.0,
|
|
38
|
+
concurrency: int = 30,
|
|
39
|
+
sites_path: pathlib.Path | None = None,
|
|
40
|
+
):
|
|
41
|
+
self.username = username
|
|
42
|
+
self.proxy = proxy
|
|
43
|
+
self.use_tor = use_tor
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.sites = self._load_sites(sites_path)
|
|
46
|
+
self._semaphore = asyncio.Semaphore(concurrency)
|
|
47
|
+
|
|
48
|
+
def _load_sites(self, sites_path: pathlib.Path | None) -> dict:
|
|
49
|
+
"""Locate and load target site config relative to the package path."""
|
|
50
|
+
path = sites_path or (pathlib.Path(__file__).parent / "sites.json")
|
|
51
|
+
if not path.exists():
|
|
52
|
+
raise SiteConfigError(f"sites.json not found at {path}")
|
|
53
|
+
try:
|
|
54
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
55
|
+
sites = json.load(fh)
|
|
56
|
+
except json.JSONDecodeError as exc:
|
|
57
|
+
raise SiteConfigError(f"sites.json is not valid JSON: {exc}") from exc
|
|
58
|
+
|
|
59
|
+
for name, rules in sites.items():
|
|
60
|
+
if "url" not in rules or "error_type" not in rules:
|
|
61
|
+
raise SiteConfigError(
|
|
62
|
+
f"Site '{name}' is missing required 'url' or 'error_type' key"
|
|
63
|
+
)
|
|
64
|
+
return sites
|
|
65
|
+
|
|
66
|
+
async def check_platform(
|
|
67
|
+
self, client: httpx.AsyncClient, name: str, rules: dict
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""Evaluate a single site against its detection rule. Never raises."""
|
|
70
|
+
target_url = rules["url"].format(self.username)
|
|
71
|
+
headers = {"User-Agent": random_user_agent()}
|
|
72
|
+
|
|
73
|
+
async with self._semaphore:
|
|
74
|
+
try:
|
|
75
|
+
response = await client.get(target_url, headers=headers)
|
|
76
|
+
except httpx.TooManyRedirects:
|
|
77
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
78
|
+
except httpx.TimeoutException:
|
|
79
|
+
return {"status": "TIMEOUT", "url": target_url}
|
|
80
|
+
except httpx.RequestError:
|
|
81
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
82
|
+
except Exception:
|
|
83
|
+
# Catches low-level transport/protocol errors that escape
|
|
84
|
+
# httpx's own exception hierarchy (e.g. a raw h2.ProtocolError
|
|
85
|
+
# from a connection torn down mid-handshake under high
|
|
86
|
+
# concurrency). A single misbehaving connection should never
|
|
87
|
+
# take down the whole scan's asyncio.gather.
|
|
88
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
89
|
+
|
|
90
|
+
# 429 / 403 usually mean a WAF or rate limiter intervened, not a
|
|
91
|
+
# legitimate answer about account existence.
|
|
92
|
+
if response.status_code in (403, 429):
|
|
93
|
+
return {"status": "BLOCKED", "url": target_url}
|
|
94
|
+
|
|
95
|
+
# Some WAFs (e.g. Cloudflare) serve a challenge page with a 200,
|
|
96
|
+
# which would otherwise be misread as a legitimate FOUND result.
|
|
97
|
+
lowered_text = response.text[:2000].lower()
|
|
98
|
+
if any(marker in lowered_text for marker in _CHALLENGE_MARKERS):
|
|
99
|
+
return {"status": "BLOCKED", "url": target_url}
|
|
100
|
+
|
|
101
|
+
error_type = rules["error_type"]
|
|
102
|
+
error_criteria = rules.get("error_criteria")
|
|
103
|
+
|
|
104
|
+
if error_type == "status_code":
|
|
105
|
+
if response.status_code == int(error_criteria):
|
|
106
|
+
return {"status": "NOT_FOUND", "url": target_url}
|
|
107
|
+
elif error_type == "message":
|
|
108
|
+
if error_criteria and error_criteria in response.text:
|
|
109
|
+
return {"status": "NOT_FOUND", "url": target_url}
|
|
110
|
+
elif error_type == "response_url":
|
|
111
|
+
if error_criteria and str(response.url).rstrip("/") == error_criteria.rstrip("/"):
|
|
112
|
+
return {"status": "NOT_FOUND", "url": target_url}
|
|
113
|
+
|
|
114
|
+
if response.status_code == 200:
|
|
115
|
+
return {"status": "FOUND", "url": target_url}
|
|
116
|
+
|
|
117
|
+
return {"status": "UNKNOWN", "url": target_url}
|
|
118
|
+
|
|
119
|
+
async def run_scan(self, *, quiet: bool = False) -> dict[str, dict]:
|
|
120
|
+
"""Run all site checks concurrently with a live progress bar."""
|
|
121
|
+
results: dict[str, dict] = {}
|
|
122
|
+
|
|
123
|
+
async with build_client(
|
|
124
|
+
proxy=self.proxy, use_tor=self.use_tor, timeout=self.timeout
|
|
125
|
+
) as client:
|
|
126
|
+
if quiet:
|
|
127
|
+
tasks = [
|
|
128
|
+
self.check_platform(client, name, rules)
|
|
129
|
+
for name, rules in self.sites.items()
|
|
130
|
+
]
|
|
131
|
+
outcomes = await asyncio.gather(*tasks)
|
|
132
|
+
for (name, _), outcome in zip(self.sites.items(), outcomes):
|
|
133
|
+
results[name] = outcome
|
|
134
|
+
return results
|
|
135
|
+
|
|
136
|
+
with make_progress() as progress:
|
|
137
|
+
task_id = progress.add_task(
|
|
138
|
+
"[yellow]Probing networks...", total=len(self.sites)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def run_one(name: str, rules: dict) -> None:
|
|
142
|
+
outcome = await self.check_platform(client, name, rules)
|
|
143
|
+
results[name] = outcome
|
|
144
|
+
if outcome["status"] == "FOUND":
|
|
145
|
+
progress.console.print(
|
|
146
|
+
f"[bold green][+][/bold green] [white]{name}:[/white] "
|
|
147
|
+
f"[underline cyan]{outcome['url']}[/underline cyan]"
|
|
148
|
+
)
|
|
149
|
+
progress.advance(task_id)
|
|
150
|
+
|
|
151
|
+
await asyncio.gather(
|
|
152
|
+
*(run_one(name, rules) for name, rules in self.sites.items())
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return results
|