sitecheck-mcp
by snickery
README.md
# sitecheck-mcp
MCP server that answers **"is this website legit or a scam?"**. It gathers
evidence from registration data, TLS, DNS, certificate-transparency and
archive history, crowd-sourced phishing/malware feeds, optional reputation
APIs, brand look-alike analysis and the landing page itself, and returns a
weighted verdict with the reasons behind it.
Verdicts: `low_risk` · `caution` · `suspicious` · `likely_scam` · `dangerous`
(any critical signal) · `not_registered`. `low_risk` means *no evidence
found*, not *verified safe*: fresh scam domains go unlisted for hours to days.
## Tools
| Tool | Parameters | What it does |
|------|-----------|--------------|
| `check_site` | `url`, `fetch_page=True`, `keep_query=False` | Full assessment: verdict, 0-100 `risk_score`, `red_flags`, `trust_signals`, `facts`, `coverage`, `next_steps` |
| `domain_intel` | `domain` | Age, registrar, .au registrant + ABN/ACN, TLS (DV/OV/EV), DNS/mail, Tranco rank |
| `reputation` | `url`, `keep_query=False` | Blocklists + reputation APIs only |
| `lookalike_check` | `domain` | Typosquats, homoglyph/IDN spoofs, brand-stuffed names |
| `inspect_page` | `url`, `keep_query=False` | One guarded GET + content red flags |
| `verify_business` | `abn_or_acn` | ABN/ACN checksum + Australian Business Register lookup |
| `source_status` | `refresh_feeds=False` | Which sources are configured; feed ages |
## Evidence sources
| Source | Key? | Signal |
|--------|------|--------|
| RDAP (rdap.org) | no | registration date, registrar, status |
| auDA WHOIS (port 43) | no | .au registrant entity + ABN/ACN (.au RDAP has no creation date) |
| DNS (dnspython) | no | resolves, MX/SPF/DMARC |
| TLS handshake | no | validity, issuer, DV/OV/EV, certificate organisation |
| crt.sh | no | first certificate ever logged (age lower bound) |
| Wayback CDX | no | first archive capture (age lower bound) |
| Tranco top-1M | no | popularity rank (top 100k kept) |
| Phishing.Database ACTIVE | no | crowd-sourced active phishing domains (~390k) |
| OpenPhish community | no | live phishing URLs |
| URLhaus online feed | no | live malware URLs |
| ABN/ACN checksum | no | fake business numbers fail the check digits |
| Google Safe Browsing v4 | `SAFE_BROWSING_API_KEY` | Google's phishing/malware lists |
| VirusTotal v3 | `VIRUSTOTAL_API_KEY` | engine verdicts, community votes, WHOIS age |
| urlscan.io search | `URLSCAN_API_KEY` | community scans + verdicts |
| URLhaus host API | `ABUSECH_AUTH_KEY` | host malware history |
| ABN Lookup | `ABN_LOOKUP_GUID` | entity name, status, registration date |
Trustpilot and ScamAdviser were evaluated and left out: both refuse
non-browser clients (403), and working around bot protection is out of scope.
Feeds are cached in `~/.cache/sitecheck-mcp` (`SITECHECK_CACHE_DIR`) and
refreshed on use (Tranco weekly, phishing feeds 6–12 h). A failed refresh
keeps serving the stale copy.
## Safety design
- **No SSRF**: every host is resolved and refused unless *all* its addresses
are globally routable (no loopback, RFC1918, 100.64/10 tailnet,
link-local). The page fetcher re-checks on every redirect hop.
- **Query strings stripped by default** before any fetch or lookup:
phishing links carry per-recipient tokens, and fetching one tells the
sender the message was opened. `keep_query=true` opts in.
- **Lookups only**: nothing is submitted for scanning (VirusTotal/urlscan
submissions are public).
- One plain GET, no JavaScript, no cookies kept, 2 MB cap, 6 redirects max.
- Logs record tool name, outcome and duration only, never arguments.
## Scoring
An additive model from a baseline of 10: red flags add points, trust signals
subtract. Any critical signal forces `dangerous` (feed listing, Safe
Browsing, ≥3 VirusTotal engines, homoglyph of a brand, a brand domain as a
subdomain, a brand login page on the wrong domain). The weights are judgement
calls, not probabilities; see `scoring.py`. Feed hits are **exact-host**:
the phishing lists include shared platforms (`sites.google.com`), which are
downgraded to informational. The same goes for URLhaus's per-host count
(`drive.google.com` carries thousands of uploaded malware URLs). The platform
list (`SHARED_PLATFORM_HOSTS` in `checker.py`) holds exact hosts where many
users share one hostname. Per-user subdomains (`x.netlify.app`) are their own
site, and an exact-URL listing stays critical everywhere.
Whose reputation counts:
- **Rank, age and the registrant's ABN belong to the registered domain.** They
vouch for the host only if it resolves and isn't borrowing someone else's
name. A dead hostname or a fake-.au host gets none of them (noted as
`not credited`).
- **A live subdomain gets a scaled share of its parent's rank** (`parent_popular`):
−30 under a top-1k parent, −15 under a top-10k one.
- **Fake .au suffixes** (`fake_au_suffix`): `x.au.com` (au.com is KDDI's
ordinary .com), `x.comau.shop`, `-com-au` labels, and `.com.au` / `.gov.au`
inside a subdomain (`mygov.gov.au.verify.top`). A real .com.au needs an
ABN/ACN to register; these look the same and need nothing.
- **Young ABNs earn no trust**: an active registration under a year old gives
no credit, and under 90 days is a mild flag. Scam operators register real
Pty Ltds. The age comes from `AbnStatusEffectiveFrom`, because the ABR JSON
has no original-registration date.
Known limit: sites behind a JavaScript bot gate hide their storefront from
the fetch. A home-made gate ("verifying your browser") on a young, unranked
site is scored as a cloaking signal, and the content checks report
`blocked by ... bot challenge`.
## Install
```bash
uvx sitecheck-mcp --stdio # or: pip install sitecheck-mcp
```
Claude Desktop / Claude Code (stdio):
```json
{"mcpServers": {"sitecheck": {"command": "uvx", "args": ["sitecheck-mcp", "--stdio"]}}}
```
Every reputation API key is optional; `source_status` shows which sources are active.
## Running
```bash
uv run --env-file .env sitecheck-mcp --stdio # stdio client
MCP_BEARER_TOKEN=... uv run sitecheck-mcp # HTTP :8333/mcp, bearer required
```
## Tests
```bash
uv run --extra test pytest tests/ -q # unit + mocked (offline)
SITECHECK_LIVE=1 uv run --extra test pytest tests/test_integration.py -v # live, read-only
```
This server cannot be deployed
Maintenance
ActivityNo data
ResponsivenessNo issues