Metadata-Version: 2.5
Name: sitecheck-mcp
Version: 0.1.0
Summary: MCP server that assesses whether a website is legitimate or a scam, from registration, TLS, reputation feeds, look-alike analysis and page content
Project-URL: Homepage, https://github.com/snickery/sitecheck-mcp
Project-URL: Repository, https://github.com/snickery/sitecheck-mcp
Project-URL: Issues, https://github.com/snickery/sitecheck-mcp/issues
Author-email: Nick Engel <nick@engel.au>
License-Expression: MIT
License-File: LICENSE
Keywords: mcp,model-context-protocol,phishing,scam,threat-intel,typosquatting
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Requires-Python: >=3.12
Requires-Dist: beautifulsoup4>=4.12
Requires-Dist: certifi>=2024.8.30
Requires-Dist: cryptography>=43
Requires-Dist: dnspython>=2.6
Requires-Dist: httpx>=0.27.0
Requires-Dist: idna>=3.7
Requires-Dist: mcp-server-kit<0.2,>=0.1
Requires-Dist: mcp[cli]>=1.0.0
Requires-Dist: rapidfuzz>=3.9
Requires-Dist: tldextract>=5.1
Provides-Extra: test
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
Requires-Dist: pytest>=8.0; extra == 'test'
Description-Content-Type: text/markdown

# 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
```
