siteread 0.0.0-stage → 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +232 -2
- package/bin/siteread.js +277 -0
- package/package.json +35 -4
- package/src/agents.js +85 -0
- package/src/digest/csv.js +37 -0
- package/src/digest/gsc.js +233 -0
- package/src/digest/index.js +76 -0
- package/src/digest/log.js +363 -0
- package/src/digest/referrer.js +77 -0
- package/src/digest/zip.js +41 -0
- package/src/file-store.js +49 -0
- package/src/index.js +16 -0
- package/src/ip-ranges.js +219 -0
- package/src/scanner.js +56 -0
- package/src/verifier.js +221 -0
- package/src/version.js +2 -0
package/src/ip-ranges.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The vendors' published crawler IP lists: what tells a real ChatGPT-User from a scanner
|
|
2
|
+
// wearing its User-Agent.
|
|
3
|
+
//
|
|
4
|
+
// Ported from the crawler-verification code Synacl Labs runs on synacl.com (2026-10). There it
|
|
5
|
+
// lived behind a Redis key shared by a cluster; here the store is pluggable (verifier.js).
|
|
6
|
+
//
|
|
7
|
+
// WHY (measured on that site's nginx log, 2026-09-17 → 10-16). A scanner cycles AI user-agents:
|
|
8
|
+
// 217 of 221 ChatGPT-User requests from outside OpenAI's list came from IPs that also got a 444
|
|
9
|
+
// (nginx's "close the connection, send nothing" answer to a scanner path), and DeepSeekBot /
|
|
10
|
+
// CCBot / Grok / Perplexity-User / Claude-SearchBot were 100 % spoofed. Where a vendor publishes
|
|
11
|
+
// a list, "IP is in it" is the test; where none exists, the fallback is "the IP never hit a
|
|
12
|
+
// scanner path" (the 444 rule: createVerifier's `isKnownScannerIp`). On that site the two tests
|
|
13
|
+
// agreed almost perfectly, so the fallback is honest, just weaker.
|
|
14
|
+
//
|
|
15
|
+
// The 444 rule CANNOT clear a crawler that follows a bad link into a scanner path. ClaudeBot
|
|
16
|
+
// (on the 444 rule until 10-17) resolved a GitHub README's relative links against the site,
|
|
17
|
+
// asked for /protocol//blob/master/examples/docker-compose.yml, drew one 444, and all 50 of
|
|
18
|
+
// that IP's reads that day were counted spoofed. Anthropic publishes ONE list for its bots,
|
|
19
|
+
// claude.com/crawling/bots.json, used here for all three Claude agents: on 30 days of log every
|
|
20
|
+
// ClaudeBot IP and every page-reading Claude-User IP was in it, and the four IPs probing
|
|
21
|
+
// /.env-style paths under Claude names were not. (Claude-User from Anthropic's 160.79.104.0/21
|
|
22
|
+
// was NOT in it: that is the MCP connector calling a tool server, not a page read. The list
|
|
23
|
+
// published 2026-10-07 does carry two /28s from that range.)
|
|
24
|
+
//
|
|
25
|
+
// SHAPE. A list is refetched only when its stored copy is > 24 h old. A failed fetch keeps the
|
|
26
|
+
// previous copy; a copy nobody could refresh for 8 days is dropped, so a vendor's moved ranges
|
|
27
|
+
// cannot quietly mark its new IPs as spoofed forever. Nothing here throws: no store, no network
|
|
28
|
+
// or a broken list all end in "no list for that agent", which is the verdict 'unknown'.
|
|
29
|
+
|
|
30
|
+
import net from 'node:net';
|
|
31
|
+
|
|
32
|
+
export const REFRESH_AFTER_MS = 24 * 3_600_000;
|
|
33
|
+
export const DROP_AFTER_MS = 8 * 86_400_000;
|
|
34
|
+
export const FETCH_TIMEOUT_MS = 8_000;
|
|
35
|
+
// A list is a few hundred prefixes; these bound what a broken (or hostile) response can cost.
|
|
36
|
+
export const MAX_BODY_CHARS = 2_000_000;
|
|
37
|
+
export const MAX_PREFIXES = 20_000;
|
|
38
|
+
// No vendor publishes anything near this broad. A /0 in a list would verify EVERY address,
|
|
39
|
+
// so a prefix shorter than these is a broken list, not a big one.
|
|
40
|
+
export const MIN_PREFIX = Object.freeze({ ipv4: 8, ipv6: 16 });
|
|
41
|
+
// Two processes sharing a store can disagree on the time by a little. A fetchedAt further in
|
|
42
|
+
// the future than this is not skew: it would keep a list "fresh" forever and defeat the drop.
|
|
43
|
+
const CLOCK_SKEW_MS = 5 * 60_000;
|
|
44
|
+
|
|
45
|
+
const CLAUDE_BOTS = 'https://claude.com/crawling/bots.json';
|
|
46
|
+
|
|
47
|
+
// Agent key (agents.js AGENTS / CRAWLERS) → its vendor's published list. Every URL answers
|
|
48
|
+
// { creationTime, prefixes: [{ ipv4Prefix } | { ipv6Prefix }] } (all checked 2026-10-21).
|
|
49
|
+
export const SOURCES = Object.freeze([
|
|
50
|
+
{ agent: 'chatgpt-user', url: 'https://openai.com/chatgpt-user.json' },
|
|
51
|
+
{ agent: 'oai-searchbot', url: 'https://openai.com/searchbot.json' },
|
|
52
|
+
{ agent: 'gptbot', url: 'https://openai.com/gptbot.json' },
|
|
53
|
+
{ agent: 'perplexity-user', url: 'https://www.perplexity.com/perplexity-user.json' },
|
|
54
|
+
{ agent: 'perplexitybot', url: 'https://www.perplexity.com/perplexitybot.json' },
|
|
55
|
+
{ agent: 'applebot', url: 'https://search.developer.apple.com/applebot.json' },
|
|
56
|
+
// Not claude-code: Claude Code's WebFetch runs on the user's own machine, never from these.
|
|
57
|
+
{ agent: 'claude-user', url: CLAUDE_BOTS },
|
|
58
|
+
{ agent: 'claude-searchbot', url: CLAUDE_BOTS },
|
|
59
|
+
{ agent: 'claudebot', url: CLAUDE_BOTS },
|
|
60
|
+
// Classic search. googlebot.json covers Googlebot and its -Image/-News/-Video variants.
|
|
61
|
+
{ agent: 'googlebot', url: 'https://developers.google.com/static/search/apis/ipranges/googlebot.json' },
|
|
62
|
+
{ agent: 'bingbot', url: 'https://www.bing.com/toolbox/bingbot.json' },
|
|
63
|
+
].map(Object.freeze));
|
|
64
|
+
|
|
65
|
+
/** 'a.b.c.d/n' or 'x::/n' → { type, net, prefix, cidr } (normalised), or null. */
|
|
66
|
+
export function parseCidr(raw) {
|
|
67
|
+
if (typeof raw !== 'string') return null;
|
|
68
|
+
const s = raw.trim();
|
|
69
|
+
const slash = s.indexOf('/');
|
|
70
|
+
if (slash < 1 || s.length > 64) return null;
|
|
71
|
+
const addr = s.slice(0, slash);
|
|
72
|
+
const bits = s.slice(slash + 1);
|
|
73
|
+
if (!/^\d{1,3}$/.test(bits)) return null;
|
|
74
|
+
const prefix = Number(bits);
|
|
75
|
+
const type = net.isIPv4(addr) ? 'ipv4' : net.isIPv6(addr) ? 'ipv6' : null;
|
|
76
|
+
if (!type) return null;
|
|
77
|
+
if (prefix < MIN_PREFIX[type] || prefix > (type === 'ipv4' ? 32 : 128)) return null;
|
|
78
|
+
return { type, net: addr.toLowerCase(), prefix, cidr: `${addr.toLowerCase()}/${prefix}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The vendors' shared format: { prefixes: [{ ipv4Prefix } | { ipv6Prefix }] } → [cidr]. */
|
|
82
|
+
export function parseList(body) {
|
|
83
|
+
const items = Array.isArray(body?.prefixes) ? body.prefixes : [];
|
|
84
|
+
const out = new Set();
|
|
85
|
+
for (const it of items) {
|
|
86
|
+
if (out.size >= MAX_PREFIXES) break;
|
|
87
|
+
const p = parseCidr(it?.ipv4Prefix ?? it?.ipv6Prefix);
|
|
88
|
+
if (p) out.add(p.cidr);
|
|
89
|
+
}
|
|
90
|
+
return [...out];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A stored copy's prefixes ([cidr]) → [cidr], re-validated and bounded. */
|
|
94
|
+
export function parsePrefixes(list) {
|
|
95
|
+
if (!Array.isArray(list)) return [];
|
|
96
|
+
const out = new Set();
|
|
97
|
+
for (const c of list.slice(0, MAX_PREFIXES)) {
|
|
98
|
+
const p = parseCidr(c);
|
|
99
|
+
if (p) out.add(p.cidr);
|
|
100
|
+
}
|
|
101
|
+
return [...out];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// An address as the log writes it → { ip, type }, or null. An IPv4-mapped IPv6 address
|
|
105
|
+
// (::ffff:1.2.3.4, what a dual-stack listener can log) is checked as the IPv4 it is.
|
|
106
|
+
export function normIp(raw) {
|
|
107
|
+
let s = String(raw ?? '').trim().toLowerCase();
|
|
108
|
+
const zone = s.indexOf('%');
|
|
109
|
+
if (zone !== -1) s = s.slice(0, zone);
|
|
110
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s);
|
|
111
|
+
if (mapped) s = mapped[1];
|
|
112
|
+
if (net.isIPv4(s)) return { ip: s, type: 'ipv4' };
|
|
113
|
+
if (net.isIPv6(s)) return { ip: s, type: 'ipv6' };
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** ms since epoch from a stored fetchedAt, or NaN. */
|
|
118
|
+
export function fetchedAtMs(l) {
|
|
119
|
+
return typeof l?.fetchedAt === 'string' ? Date.parse(l.fetchedAt) : NaN;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** True when a copy fetched at `at` is younger than `maxAgeMs` at `now` (and not from the future). */
|
|
123
|
+
export function youngerThan(at, now, maxAgeMs) {
|
|
124
|
+
return Number.isFinite(at) && now - at < maxAgeMs && at - now <= CLOCK_SKEW_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** { lists: { agent: { prefixes, fetchedAt } } } from whatever a store returned. */
|
|
128
|
+
export function normDoc(doc) {
|
|
129
|
+
const lists = doc && typeof doc === 'object' && doc.lists && typeof doc.lists === 'object' && !Array.isArray(doc.lists)
|
|
130
|
+
? doc.lists : {};
|
|
131
|
+
// A copy: the caller mutates it, and an in-memory store may hand out its own object.
|
|
132
|
+
const out = {};
|
|
133
|
+
for (const [agent, l] of Object.entries(lists)) {
|
|
134
|
+
if (l && typeof l === 'object' && Array.isArray(l.prefixes)) {
|
|
135
|
+
out[agent] = { prefixes: l.prefixes.slice(0, MAX_PREFIXES), fetchedAt: l.fetchedAt };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { lists: out };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A matcher over a stored document: { hasList(agent), contains(agent, addr), get(agent) }.
|
|
143
|
+
* Only the agents in `sources` are loaded, and a copy past the drop age is ignored even if
|
|
144
|
+
* nobody ran refresh() to delete it.
|
|
145
|
+
*/
|
|
146
|
+
export function buildMatcher(doc, { sources = SOURCES, now = Date.now() } = {}) {
|
|
147
|
+
const lists = new Map(); // agent → { block, count, fetchedAt }
|
|
148
|
+
for (const { agent } of sources) {
|
|
149
|
+
if (lists.has(agent)) continue;
|
|
150
|
+
const l = doc?.lists?.[agent];
|
|
151
|
+
if (!l || !Array.isArray(l.prefixes)) continue;
|
|
152
|
+
if (!youngerThan(fetchedAtMs(l), now, DROP_AFTER_MS)) continue;
|
|
153
|
+
const block = new net.BlockList();
|
|
154
|
+
let count = 0;
|
|
155
|
+
for (const c of l.prefixes.slice(0, MAX_PREFIXES)) {
|
|
156
|
+
const p = parseCidr(c);
|
|
157
|
+
if (!p) continue;
|
|
158
|
+
try { block.addSubnet(p.net, p.prefix, p.type); count++; } catch { /* skip */ }
|
|
159
|
+
}
|
|
160
|
+
if (count) lists.set(agent, { block, count, fetchedAt: l.fetchedAt });
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
hasList: (agent) => lists.has(agent),
|
|
164
|
+
/** `addr` is normIp()'s { ip, type } or a raw address string. */
|
|
165
|
+
contains(agent, addr) {
|
|
166
|
+
const l = lists.get(agent);
|
|
167
|
+
const a = l && (typeof addr === 'object' && addr ? addr : normIp(addr));
|
|
168
|
+
if (!a) return false;
|
|
169
|
+
try { return l.block.check(a.ip, a.type); } catch { return false; }
|
|
170
|
+
},
|
|
171
|
+
get: (agent) => {
|
|
172
|
+
const l = lists.get(agent);
|
|
173
|
+
return l ? { prefixes: l.count, fetchedAt: l.fetchedAt } : null;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The body, read at most MAX_BODY_CHARS deep: a hostile endpoint streaming forever is cut off
|
|
179
|
+
// here (and by the fetch timeout) instead of being buffered whole before the size check.
|
|
180
|
+
async function readCapped(res, max = MAX_BODY_CHARS) {
|
|
181
|
+
const reader = res.body?.getReader?.();
|
|
182
|
+
if (!reader) {
|
|
183
|
+
const text = await res.text();
|
|
184
|
+
if (text.length > max) throw new Error('response too large');
|
|
185
|
+
return text;
|
|
186
|
+
}
|
|
187
|
+
const dec = new TextDecoder();
|
|
188
|
+
let text = '';
|
|
189
|
+
for (;;) {
|
|
190
|
+
const { done, value } = await reader.read();
|
|
191
|
+
if (done) break;
|
|
192
|
+
text += dec.decode(value, { stream: true });
|
|
193
|
+
if (text.length > max) {
|
|
194
|
+
await reader.cancel().catch(() => {});
|
|
195
|
+
throw new Error('response too large');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return text + dec.decode();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** GET a URL and JSON.parse it, bounded in time and size. Throws on any failure. */
|
|
202
|
+
export async function fetchJson(url, fetchImpl, { userAgent } = {}) {
|
|
203
|
+
const headers = { accept: 'application/json' };
|
|
204
|
+
if (userAgent) headers['user-agent'] = userAgent;
|
|
205
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers });
|
|
206
|
+
if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`);
|
|
207
|
+
const text = await readCapped(res);
|
|
208
|
+
// A list URL that answers 200 with an HTML page (a moved list, a CDN challenge) must not
|
|
209
|
+
// parse as an empty list: JSON.parse is the check. (Content-Type is no help: OpenAI serves
|
|
210
|
+
// chatgpt-user.json as application/octet-stream.)
|
|
211
|
+
return JSON.parse(text);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One vendor list → [cidr]. Throws when it holds nothing usable. */
|
|
215
|
+
export async function fetchList(url, fetchImpl, opts) {
|
|
216
|
+
const prefixes = parseList(await fetchJson(url, fetchImpl, opts));
|
|
217
|
+
if (!prefixes.length) throw new Error('no usable prefixes');
|
|
218
|
+
return prefixes;
|
|
219
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Scanner/exploit request paths: what a vulnerability scanner asks for and a real reader never
|
|
2
|
+
// does. An IP that asks for one of these is a scanner, whatever User-Agent it wears; that is the
|
|
3
|
+
// "444 rule" (nginx answers these with 444, "close, send nothing") the verifier falls back on
|
|
4
|
+
// when a vendor publishes no IP list (createVerifier's `isKnownScannerIp`).
|
|
5
|
+
//
|
|
6
|
+
// Ported from the edge classifier Synacl Labs runs on synacl.com (2026-10), keeping only the
|
|
7
|
+
// host-blind, product-agnostic rules; that site's own carve-outs are gone. A site with a real
|
|
8
|
+
// path that matches (a /install.sh, a Decap CMS /admin/config.yml) passes `allow`.
|
|
9
|
+
//
|
|
10
|
+
// Classify on the REQUEST PATH ONLY, never the User-Agent: a scanner whose UA was literally
|
|
11
|
+
// "http://<site>/wp-admin/install.php?step=1" made the raw-line version flag itself. Feed it the
|
|
12
|
+
// raw request target (nginx's $request_uri: path + query, NOT url-decoded), so the rules below
|
|
13
|
+
// see exactly what the edge saw.
|
|
14
|
+
|
|
15
|
+
export const SCANNER_PATH_RE = new RegExp(
|
|
16
|
+
'(\\/\\.(?!well-known))' + // /.env /.git … but not /.well-known
|
|
17
|
+
'|(\\.(php[0-9]?|phtml|phps|asp|aspx|jsp|jspx|cgi|env|sql|bak|old|backup|swp|ini|sh)(\\?|$))' + // dead tech ext
|
|
18
|
+
'|(^\\/+(wp-|xmlrpc|phpmyadmin|boaform|cgi-bin|HNAP1|SDK\\/webLanguage|' +
|
|
19
|
+
'apply\\.cgi|tmUnblock\\.cgi|GponForm|\\.git|\\.env|actuator(\\/|$)|_ignition|solr\\/|autodiscover))' +
|
|
20
|
+
// Words that are also ordinary slugs (/mysql-vs-postgres, /backup-strategies, /shell-tips) need
|
|
21
|
+
// a segment end after them; the scanner forms (/mysql/, /backup.zip, /shell?cd+/tmp) still match.
|
|
22
|
+
// (The original matched these as bare prefixes: its site had no such pages.) `/vendor/` is not
|
|
23
|
+
// here at all: static sites serve /vendor/*.js, and the scanner hits below it end in .php or a
|
|
24
|
+
// dotfile, which the rules above already catch.
|
|
25
|
+
'|(^\\/+(wordpress|pma|mysql|shell|backups?)(?=[/?.]|$))' +
|
|
26
|
+
// WP-artifact probes bury these deep in the path (//wp-includes/…/license.txt/…) with a leading
|
|
27
|
+
// // to dodge the ^/-anchored rule; these tokens never appear in a legit URL: match anywhere.
|
|
28
|
+
'|(wp-includes|wp-admin|wp-content|wp-login|wlwmanifest|xmlrpc)' +
|
|
29
|
+
// Percent-encoded dot: /%2eenv, /$(pwd)/%2egit/config, /private/smtp%2ejson. Every rule above
|
|
30
|
+
// wants a LITERAL '.', and the raw request target is not url-decoded, so these passed as
|
|
31
|
+
// ordinary traffic until this rule. Path only: [^?]* stops at the first '?' so an encoded
|
|
32
|
+
// query value can't false-positive.
|
|
33
|
+
'|(^[^?]*%2e)' +
|
|
34
|
+
// Secret/config-file scanners: framework and cloud artifacts no site serves on purpose.
|
|
35
|
+
// docker-compose only as a YAML FILE, so a /docker-compose-tutorial/ post is not a probe (the
|
|
36
|
+
// ClaudeBot incident's /…/examples/docker-compose.yml still is).
|
|
37
|
+
'|(appsettings\\.|aws-exports\\.|composer\\.(json|lock)|/_profiler|/phpinfo|launchSettings|' +
|
|
38
|
+
'serviceAccountKey|service-account-credentials|api-keys\\.json|docker-compose(\\.[\\w-]+)?\\.ya?ml|' +
|
|
39
|
+
'config\\.ya?ml|aws\\.ya?ml)' +
|
|
40
|
+
// Dockerfile and credentials.* only as FILES: a route that merely ends in /credentials is a
|
|
41
|
+
// normal API path on plenty of apps.
|
|
42
|
+
'|(/Dockerfile(\\.[a-z]+)?(\\?|$))|(/credentials\\.[a-z]+(\\?|$))',
|
|
43
|
+
'i',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True when a raw request target (path + query, not url-decoded) is a scanner probe.
|
|
48
|
+
* `allow` (a RegExp tested against the same string) exempts a site's own legit paths.
|
|
49
|
+
*/
|
|
50
|
+
export function isScannerPath(path, { allow } = {}) {
|
|
51
|
+
if (typeof path !== 'string' || !path) return false;
|
|
52
|
+
if (!SCANNER_PATH_RE.test(path)) return false;
|
|
53
|
+
// search() ignores a /g regex's lastIndex, so a shared global `allow` can't flip-flop.
|
|
54
|
+
if (allow instanceof RegExp && path.search(allow) !== -1) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
package/src/verifier.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// A verdict for one request: is the IP that sent this User-Agent really that vendor's crawler?
|
|
2
|
+
//
|
|
3
|
+
// verified the agent's vendor publishes an IP list and the IP is in it
|
|
4
|
+
// spoofed the vendor publishes a list and the IP is not in it (or the UA is a robots.txt
|
|
5
|
+
// token nothing real sends, or `isKnownScannerIp` says the IP probed scanner paths)
|
|
6
|
+
// unknown no list to check against: the vendor publishes none, or it could not be loaded
|
|
7
|
+
// n/a the UA claims to be no known AI agent or search crawler
|
|
8
|
+
//
|
|
9
|
+
// A vendor list always wins over the 444 rule: ClaudeBot once followed a broken README link
|
|
10
|
+
// into a scanner path, and the 444 rule called a real crawler spoofed (ip-ranges.js).
|
|
11
|
+
//
|
|
12
|
+
// Refresh rules are the ported ones (ip-ranges.js): refetch a list when the stored copy is
|
|
13
|
+
// > 24 h old, keep the old copy when a fetch fails, drop a copy nobody could refresh for 8 days.
|
|
14
|
+
// Nothing here throws.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
SOURCES, REFRESH_AFTER_MS, DROP_AFTER_MS,
|
|
18
|
+
normIp, normDoc, buildMatcher, fetchJson, fetchList, parsePrefixes, fetchedAtMs, youngerThan,
|
|
19
|
+
} from './ip-ranges.js';
|
|
20
|
+
import { detectAgent, detectCrawler } from './agents.js';
|
|
21
|
+
import { VERSION } from './version.js';
|
|
22
|
+
|
|
23
|
+
const USER_AGENT = `siteread/${VERSION} (+https://www.npmjs.com/package/siteread)`;
|
|
24
|
+
|
|
25
|
+
/** The default store: one process, nothing on disk. */
|
|
26
|
+
export function memoryStore(initial = null) {
|
|
27
|
+
let doc = initial;
|
|
28
|
+
return {
|
|
29
|
+
async get() { return doc; },
|
|
30
|
+
async set(next) { doc = next; },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {Function} [opts.fetch] WHATWG fetch (default globalThis.fetch)
|
|
37
|
+
* @param {{get(): Promise<object|null>, set(doc: object): Promise<void>}} [opts.store]
|
|
38
|
+
* @param {Array<{agent: string, url: string}>} [opts.sources]
|
|
39
|
+
* @param {() => number} [opts.now]
|
|
40
|
+
* @param {{warn?: Function, error?: Function}} [opts.log]
|
|
41
|
+
* @param {string|null} [opts.feedUrl] one merged document instead of the vendor URLs (opt-in)
|
|
42
|
+
* @param {(ip: string) => boolean} [opts.isKnownScannerIp] the 444 rule, for agents with no list
|
|
43
|
+
*/
|
|
44
|
+
export function createVerifier({
|
|
45
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
46
|
+
store = memoryStore(),
|
|
47
|
+
sources = SOURCES,
|
|
48
|
+
now = Date.now,
|
|
49
|
+
log = console,
|
|
50
|
+
feedUrl = null,
|
|
51
|
+
isKnownScannerIp = null,
|
|
52
|
+
} = {}) {
|
|
53
|
+
// One entry per agent (the first wins), in the order given.
|
|
54
|
+
const srcs = [];
|
|
55
|
+
const urlOf = new Map();
|
|
56
|
+
for (const s of sources) {
|
|
57
|
+
if (!s || typeof s.agent !== 'string' || typeof s.url !== 'string' || urlOf.has(s.agent)) continue;
|
|
58
|
+
srcs.push({ agent: s.agent, url: s.url });
|
|
59
|
+
urlOf.set(s.agent, s.url);
|
|
60
|
+
}
|
|
61
|
+
let matcher = buildMatcher(null, { sources: srcs, now: now() });
|
|
62
|
+
|
|
63
|
+
async function fetchFromVendors(due, t, doc, out) {
|
|
64
|
+
// One GET per distinct URL: Anthropic's bots.json serves three agents.
|
|
65
|
+
const urls = [...new Set(due.map(d => d.url))];
|
|
66
|
+
const results = await Promise.allSettled(urls.map(u => fetchList(u, fetchImpl, { userAgent: USER_AGENT })));
|
|
67
|
+
const byUrl = new Map(urls.map((u, i) => [u, results[i]]));
|
|
68
|
+
for (const { agent, url } of due) {
|
|
69
|
+
const r = byUrl.get(url);
|
|
70
|
+
if (r.status === 'fulfilled') {
|
|
71
|
+
doc.lists[agent] = { prefixes: r.value, fetchedAt: new Date(t).toISOString() };
|
|
72
|
+
out.fetched.push(agent);
|
|
73
|
+
} else {
|
|
74
|
+
// Keep whatever copy is stored: a vendor's CDN hiccup must not turn every one
|
|
75
|
+
// of its real crawlers into "spoofed" until the next refresh.
|
|
76
|
+
fail(out, agent, r.reason, `${agent} (${url})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The future hosted feed: { lists: { agent: { prefixes: [cidr], fetchedAt } } }, one GET.
|
|
82
|
+
// Every prefix is re-validated exactly like a vendor's, and a list keeps the feed's own
|
|
83
|
+
// fetchedAt (when the FEED fetched it from the vendor), so the 24 h / 8 day rules still
|
|
84
|
+
// measure the age of the vendor data, not of the feed hop.
|
|
85
|
+
async function fetchFromFeed(due, t, doc, out) {
|
|
86
|
+
let body;
|
|
87
|
+
try {
|
|
88
|
+
body = await fetchJson(feedUrl, fetchImpl, { userAgent: USER_AGENT });
|
|
89
|
+
} catch (err) {
|
|
90
|
+
for (const { agent } of due) fail(out, agent, err, `feed (${feedUrl})`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const lists = body && typeof body.lists === 'object' && body.lists ? body.lists : {};
|
|
94
|
+
for (const { agent } of due) {
|
|
95
|
+
const l = Object.hasOwn(lists, agent) ? lists[agent] : null;
|
|
96
|
+
const prefixes = parsePrefixes(l?.prefixes);
|
|
97
|
+
// A future fetchedAt is clamped to now: it would otherwise keep the copy fresh forever.
|
|
98
|
+
const at = Math.min(fetchedAtMs(l), t);
|
|
99
|
+
const stored = fetchedAtMs(doc.lists[agent]);
|
|
100
|
+
if (!prefixes.length) { fail(out, agent, new Error('no usable prefixes in feed'), 'feed'); continue; }
|
|
101
|
+
if (!Number.isFinite(at)) { fail(out, agent, new Error('feed list has no fetchedAt'), 'feed'); continue; }
|
|
102
|
+
if (Number.isFinite(stored) && at <= stored) { fail(out, agent, new Error('feed copy is not newer'), 'feed'); continue; }
|
|
103
|
+
doc.lists[agent] = { prefixes, fetchedAt: new Date(at).toISOString() };
|
|
104
|
+
out.fetched.push(agent);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fail(out, agent, err, what) {
|
|
109
|
+
const msg = err?.message || String(err);
|
|
110
|
+
out.failed.push(agent);
|
|
111
|
+
out.errors[agent] = msg;
|
|
112
|
+
log?.warn?.(`[siteread] ${what} failed: ${msg}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One refresh pass: read the store, fetch what is due, drop what is too old, write back.
|
|
117
|
+
* Never throws. Returns { fetched, failed, fresh, dropped, errors } (agent keys; errors maps
|
|
118
|
+
* a failed agent to its reason) and `error` when the store itself failed.
|
|
119
|
+
* `force` refetches every list whatever its age (a failed fetch still keeps the old copy).
|
|
120
|
+
*/
|
|
121
|
+
async function refresh({ force = false } = {}) {
|
|
122
|
+
const t = now();
|
|
123
|
+
const out = { fetched: [], failed: [], fresh: [], dropped: [], errors: {} };
|
|
124
|
+
let doc;
|
|
125
|
+
try {
|
|
126
|
+
doc = normDoc(await store.get());
|
|
127
|
+
} catch (err) {
|
|
128
|
+
log?.error?.(`[siteread] store read failed: ${err.message}`);
|
|
129
|
+
out.error = err.message;
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const due = [];
|
|
134
|
+
for (const s of srcs) {
|
|
135
|
+
if (!force && youngerThan(fetchedAtMs(doc.lists[s.agent]), t, REFRESH_AFTER_MS)) out.fresh.push(s.agent);
|
|
136
|
+
else due.push(s);
|
|
137
|
+
}
|
|
138
|
+
if (due.length) {
|
|
139
|
+
if (typeof fetchImpl !== 'function') {
|
|
140
|
+
for (const { agent } of due) fail(out, agent, new Error('no fetch available'), agent);
|
|
141
|
+
} else if (feedUrl) {
|
|
142
|
+
await fetchFromFeed(due, t, doc, out);
|
|
143
|
+
} else {
|
|
144
|
+
await fetchFromVendors(due, t, doc, out);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const [agent, l] of Object.entries(doc.lists)) {
|
|
148
|
+
if (urlOf.has(agent) && youngerThan(fetchedAtMs(l), t, DROP_AFTER_MS)) continue;
|
|
149
|
+
delete doc.lists[agent];
|
|
150
|
+
out.dropped.push(agent);
|
|
151
|
+
}
|
|
152
|
+
if (out.fetched.length || out.dropped.length) await store.set(doc);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
log?.error?.(`[siteread] refresh failed: ${err.message}`);
|
|
155
|
+
out.error = err.message;
|
|
156
|
+
}
|
|
157
|
+
// Whatever made it into `doc` is usable in this process even if the store write failed.
|
|
158
|
+
matcher = buildMatcher(doc, { sources: srcs, now: t });
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Rebuild the matcher from the store without fetching (a reader beside one refresher). */
|
|
163
|
+
async function load() {
|
|
164
|
+
const t = now();
|
|
165
|
+
try {
|
|
166
|
+
matcher = buildMatcher(normDoc(await store.get()), { sources: srcs, now: t });
|
|
167
|
+
} catch (err) {
|
|
168
|
+
log?.error?.(`[siteread] store read failed: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
return lists();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The verdict for one request. Synchronous: uses the lists the last refresh()/load() saw. */
|
|
174
|
+
function check(ip, ua) {
|
|
175
|
+
const agent = detectAgent(ua);
|
|
176
|
+
const e = agent || detectCrawler(ua);
|
|
177
|
+
if (!e) {
|
|
178
|
+
return { agent: null, kind: null, assistant: null, purpose: null, verdict: 'n/a', reason: 'not-a-known-agent', source: null };
|
|
179
|
+
}
|
|
180
|
+
const base = {
|
|
181
|
+
agent: e.key,
|
|
182
|
+
kind: agent ? 'ai' : 'crawler',
|
|
183
|
+
assistant: e.assistant ?? null,
|
|
184
|
+
purpose: e.purpose,
|
|
185
|
+
source: urlOf.get(e.key) ?? null,
|
|
186
|
+
};
|
|
187
|
+
if (e.robotsTokenOnly) return { ...base, verdict: 'spoofed', reason: 'robots-token-not-a-user-agent' };
|
|
188
|
+
const addr = normIp(ip);
|
|
189
|
+
if (!addr) return { ...base, verdict: 'unknown', reason: 'invalid-ip' };
|
|
190
|
+
if (matcher.hasList(e.key)) {
|
|
191
|
+
return matcher.contains(e.key, addr)
|
|
192
|
+
? { ...base, verdict: 'verified', reason: 'in-vendor-list' }
|
|
193
|
+
: { ...base, verdict: 'spoofed', reason: 'not-in-vendor-list' };
|
|
194
|
+
}
|
|
195
|
+
// No list. The 444 rule, if the caller supplies it: an IP that probed scanner paths is not
|
|
196
|
+
// the crawler it claims to be. It can only ever turn 'unknown' into 'spoofed'.
|
|
197
|
+
if (typeof isKnownScannerIp === 'function') {
|
|
198
|
+
let hit = false;
|
|
199
|
+
try { hit = Boolean(isKnownScannerIp(addr.ip)); } catch { hit = false; }
|
|
200
|
+
if (hit) return { ...base, verdict: 'spoofed', reason: 'known-scanner-ip' };
|
|
201
|
+
}
|
|
202
|
+
return { ...base, verdict: 'unknown', reason: base.source ? 'list-unavailable' : 'no-vendor-list' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Freshness of every source, in `sources` order. */
|
|
206
|
+
function lists() {
|
|
207
|
+
const t = now();
|
|
208
|
+
return srcs.map(({ agent, url }) => {
|
|
209
|
+
const l = matcher.get(agent);
|
|
210
|
+
return {
|
|
211
|
+
agent,
|
|
212
|
+
url,
|
|
213
|
+
prefixes: l?.prefixes ?? 0,
|
|
214
|
+
fetchedAt: l?.fetchedAt ?? null,
|
|
215
|
+
fresh: l ? youngerThan(Date.parse(l.fetchedAt), t, REFRESH_AFTER_MS) : false,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { refresh, load, check, lists };
|
|
221
|
+
}
|
package/src/version.js
ADDED