@apideck/agent-analytics 0.15.0 → 0.17.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/README.md +62 -4
- package/dist/firewall.cjs +4 -0
- package/dist/firewall.cjs.map +1 -0
- package/dist/firewall.d.cts +145 -0
- package/dist/firewall.d.ts +145 -0
- package/dist/firewall.js +4 -0
- package/dist/firewall.js.map +1 -0
- package/dist/gateway-BuF0-FG_.d.ts +317 -0
- package/dist/gateway-f52pV_qP.d.cts +317 -0
- package/dist/index.cjs +2 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -625
- package/dist/index.d.ts +5 -625
- package/dist/index.js +2 -10
- package/dist/index.js.map +1 -1
- package/dist/pay.cjs +8 -0
- package/dist/pay.cjs.map +1 -0
- package/dist/pay.d.cts +112 -0
- package/dist/pay.d.ts +112 -0
- package/dist/pay.js +8 -0
- package/dist/pay.js.map +1 -0
- package/dist/policy-ByqnGK1b.d.ts +105 -0
- package/dist/policy-CEJ-j-Lz.d.cts +105 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ extra steps.
|
|
|
104
104
|
So start by counting:
|
|
105
105
|
|
|
106
106
|
```ts
|
|
107
|
-
import { paymentGate } from '@apideck/agent-analytics'
|
|
107
|
+
import { paymentGate } from '@apideck/agent-analytics/payments'
|
|
108
108
|
import { combinedVerifier } from '@apideck/agent-analytics/verify'
|
|
109
109
|
|
|
110
110
|
const gate = await paymentGate(req, {
|
|
@@ -125,7 +125,7 @@ When you know the number, switch to an entitlement: one 402 advertising a bulk
|
|
|
125
125
|
offer, one settlement, a reusable credential.
|
|
126
126
|
|
|
127
127
|
```ts
|
|
128
|
-
import { entitlementGateway } from '@apideck/agent-analytics'
|
|
128
|
+
import { entitlementGateway } from '@apideck/agent-analytics/payments'
|
|
129
129
|
|
|
130
130
|
const gate = await paymentGate(req, {
|
|
131
131
|
onTraining: 'charge',
|
|
@@ -156,9 +156,15 @@ Measured against real traffic shapes:
|
|
|
156
156
|
serve retrieval allow ChatGPT-User
|
|
157
157
|
403 training block ClaudeBot from an unpublished IP
|
|
158
158
|
402 training charge ClaudeBot from a real Anthropic IP
|
|
159
|
-
serve search allow Googlebot
|
|
159
|
+
serve search allow Googlebot, PerplexityBot
|
|
160
|
+
serve preview allow Slackbot, facebookexternalhit
|
|
160
161
|
```
|
|
161
162
|
|
|
163
|
+
`preview` is its own intent rather than a flavour of `retrieval`: a link unfurl
|
|
164
|
+
is a person pasting your URL into a conversation, not an assistant answering a
|
|
165
|
+
question. Folding the two together would inflate the retrieval number, which is
|
|
166
|
+
the one figure the split exists to measure.
|
|
167
|
+
|
|
162
168
|
Settlement is never ours. `mppxGateway` wraps Stripe's MPP SDK; `x402Gateway`
|
|
163
169
|
calls a facilitator you supply. The library emits challenges and reads
|
|
164
170
|
credentials — holding money would drag PCI scope into edge middleware.
|
|
@@ -258,7 +264,7 @@ every rule comes out in `log` mode and Vercel stages rule changes as drafts, so
|
|
|
258
264
|
nothing is live until you run `vercel firewall publish` yourself.
|
|
259
265
|
|
|
260
266
|
```ts
|
|
261
|
-
import { recommendFirewallRules, firewallScript } from '@apideck/agent-analytics'
|
|
267
|
+
import { recommendFirewallRules, firewallScript } from '@apideck/agent-analytics/firewall'
|
|
262
268
|
|
|
263
269
|
const rules = recommendFirewallRules(observations) // aggregate from your warehouse
|
|
264
270
|
console.log(firewallScript(rules)) // runnable, commented bash
|
|
@@ -279,9 +285,61 @@ its `evidence`, a `risk` rating, and a `caveat` where over-blocking is plausible
|
|
|
279
285
|
— the datacenter-ASN rule is marked `high` risk because corporate VPNs and
|
|
280
286
|
privacy relays egress from hosting networks.
|
|
281
287
|
|
|
288
|
+
### Pools that no per-address threshold catches
|
|
289
|
+
|
|
290
|
+
A rotating proxy pool is built so that no single address looks abusive. Measured
|
|
291
|
+
on one production site: 34 addresses across 11 countries, one user agent each,
|
|
292
|
+
the heaviest doing 100 requests in a day — every one invisible to a per-address
|
|
293
|
+
threshold, while collectively sweeping the site.
|
|
294
|
+
|
|
295
|
+
Volume cannot separate that from real readers, so the burst rule keys on *rate*,
|
|
296
|
+
which needs `spanSeconds` on your observations:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
{ ip: '104.28.233.73', requests: 31, distinctPaths: 16, spanSeconds: 1 }
|
|
300
|
+
// → 1,860 requests/min. Not a person.
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The rule is only safe because its condition excludes static assets. The WAF sees
|
|
304
|
+
every request; the middleware that produced your observations probably does not,
|
|
305
|
+
so a naive limit on `Mozilla` throttles a real visitor on their first page view.
|
|
306
|
+
Override `assetExclusions` if your app does not serve assets from `/_next/`.
|
|
307
|
+
|
|
282
308
|
See [`docs/TESTING-PAYMENTS.md`](./docs/TESTING-PAYMENTS.md) for testing the
|
|
283
309
|
payment path end to end.
|
|
284
310
|
|
|
311
|
+
## Entry points
|
|
312
|
+
|
|
313
|
+
The root carries detection, classification, policy and capture — what every
|
|
314
|
+
consumer needs. Everything optional lives behind a subpath, so it only reaches
|
|
315
|
+
your bundle if you import it.
|
|
316
|
+
|
|
317
|
+
| Import | Contains | Root bundle cost |
|
|
318
|
+
| --- | --- | ---: |
|
|
319
|
+
| `@apideck/agent-analytics` | detection, classification, `agentPolicy`, `trackVisit` | 11.6 kB / **4.5 kB gz** |
|
|
320
|
+
| `…/verify` | Web Bot Auth + published IP range tables | 19.0 kB |
|
|
321
|
+
| `…/payments` | 402 challenges, gateways, entitlements | 10.9 kB |
|
|
322
|
+
| `…/firewall` | WAF rule recommendations (offline tool) | 6.8 kB |
|
|
323
|
+
| `…/markdown` | Markdown-twin content negotiation | 2.0 kB |
|
|
324
|
+
|
|
325
|
+
This split is load-bearing rather than tidy-minded. Exporting the payment and
|
|
326
|
+
firewall surfaces from the root once pushed it from 9.6 kB to 22.5 kB — every
|
|
327
|
+
site paid for a firewall recommender that will never run in middleware. Nothing
|
|
328
|
+
failed; the number just drifted for weeks until someone looked.
|
|
329
|
+
|
|
330
|
+
So CI now enforces it. `npm run size` checks each entry against
|
|
331
|
+
[`size-budget.json`](./size-budget.json) and fails the build on a regression:
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
entry gzipped budget used
|
|
335
|
+
dist/index.js 4.44 kB 4.88 kB 91%
|
|
336
|
+
dist/verify.js 6.25 kB 7.42 kB 84%
|
|
337
|
+
dist/pay.js 4.05 kB 4.49 kB 90%
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Raising a budget is deliberate — `npm run size -- --update`, and say why in the
|
|
341
|
+
commit.
|
|
342
|
+
|
|
285
343
|
## Install
|
|
286
344
|
|
|
287
345
|
```bash
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
'use strict';var x={prefixes:["/_next/"],extensions:["js","css","png","jpg","jpeg","gif","svg","webp","ico","woff","woff2","ttf","otf","map"]};function A(n){return `'${JSON.stringify(n).replace(/'/g,"'\\''")}'`}function _(n){let t=[`vercel firewall rules add ${JSON.stringify(n.name)}`];if(n.groups.forEach((o,s)=>{s>0&&t.push(" --or");for(let c of o)t.push(` --condition ${A(c)}`);}),t.push(` --action ${n.action}`),n.action==="rate_limit"&&n.rateLimit){t.push(` --rate-limit-window ${n.rateLimit.window}`),t.push(` --rate-limit-requests ${n.rateLimit.requests}`),t.push(` --rate-limit-action ${n.rateLimit.action}`);for(let o of n.rateLimit.keys)t.push(` --rate-limit-keys ${o}`);}return t.push(" --yes"),t.join(` \\
|
|
2
|
+
`)}function L(n){return {name:n.name,conditionGroup:n.groups.map(t=>({conditions:t})),action:{mitigate:{action:n.action}}}}function u(n){return {...n,cli:_(n),json:L(n)}}function R(n){if(!n.length)return 0;let t=[...n].sort((s,c)=>s-c),o=Math.floor(t.length/2);return t.length%2?t[o]:(t[o-1]+t[o])/2}function B(n,t={}){let o=[];if(!t.omitProtectiveBypass){let e=n.filter(r=>r.intent==="retrieval"||r.intent==="search"),i=e.reduce((r,p)=>r+p.requests,0),a=[...new Set(e.map(r=>r.botName))];o.push(u({name:"Allow retrieval and search agents",rationale:"Retrieval agents and search crawlers must never be caught by the rules below \u2014 they bring readers and rankings.",evidence:i?`${i.toLocaleString("en-US")} observed requests across ${a.length} vendors (${a.slice(0,6).join(", ")})`:"no retrieval or search traffic observed yet; installed pre-emptively",groups:[[{type:"user_agent",op:"inc",value:["ChatGPT-User","OAI-SearchBot","Claude-User","Claude-SearchBot","Perplexity-User","PerplexityBot","Googlebot","bingbot","DuckDuckBot","Applebot"]}]],action:"bypass",eventual:"bypass",risk:"low",caveat:"Place this rule first (`vercel firewall rules reorder ... --first`). A user-agent allowlist is spoofable, so pair with verification in middleware rather than relying on it for security \u2014 its job here is to stop your own rules misfiring."}));}let s=n.filter(e=>e.intent==="preview");if(!t.omitProtectiveBypass&&s.length){let e=s.reduce((a,r)=>a+r.requests,0),i=[...new Set(s.map(a=>a.botName))];o.push(u({name:"Allow link unfurlers",rationale:"Someone pasted your URL into a conversation and the platform fetched it to render a card. Blocking it makes your links look broken wherever they are shared.",evidence:`${e.toLocaleString("en-US")} requests across ${i.length} platform${i.length===1?"":"s"} (${i.slice(0,6).join(", ")})`,groups:[[{type:"user_agent",op:"inc",value:["facebookexternalhit","Twitterbot","LinkedInBot","Slackbot","Discordbot","TelegramBot","WhatsApp","redditbot"]}]],action:"bypass",eventual:"bypass",risk:"medium",caveat:"These user agents are trivially spoofed and none of these platforms publish verifiable IP ranges, so this rule hands a bypass to anyone who sets the header. Scope it to your public content paths and never to anything authenticated or expensive."}));}let c=n.filter(e=>e.verification==="spoofed"),h=[...new Set(c.map(e=>e.ip).filter(e=>!!e))];if(h.length){let e=c.reduce((a,r)=>a+r.requests,0),i=[...new Set(c.map(a=>a.botName))];o.push(u({name:"Deny impersonated crawler identities",rationale:"These addresses claimed a crawler identity that failed verification against the vendor\u2019s published ranges or signature.",evidence:`${e.toLocaleString("en-US")} requests from ${h.length} address${h.length===1?"":"es"} impersonating ${i.join(", ")}`,groups:[[{type:"ip_address",op:"inc",value:h}]],action:"log",eventual:"deny",risk:"low",caveat:"Verification failure is strong evidence, but confirm your edge controls x-forwarded-for before enforcing \u2014 behind a proxy that forwards a client-supplied header the verdict is worthless."}));}let v=n.filter(e=>e.ip&&e.verification!=="verified"),w=t.abuseThreshold??Math.max(500,Math.round(R(v.map(e=>e.requests))*10)),d=v.filter(e=>e.requests>=w).sort((e,i)=>i.requests-e.requests).slice(0,50);if(d.length){let e=d.filter(i=>(i.distinctPaths??0)>100);o.push(u({name:"Rate limit high-volume unverified addresses",rationale:"A single address making orders of magnitude more requests than the median, with no verified identity.",evidence:`${d.length} address${d.length===1?"":"es"} above ${w.toLocaleString("en-US")} requests`+(e.length?`; ${e.length} swept >100 distinct paths, which reads as a scrape rather than a reader`:""),groups:[[{type:"ip_address",op:"inc",value:d.map(i=>i.ip)}]],action:"log",eventual:"rate_limit",rateLimit:{window:60,requests:60,action:"rate_limit",keys:["ip"]},risk:"medium",caveat:"Shared egress means one address can front many real users \u2014 a corporate NAT, a mobile carrier, or a VPN. Review the dashboard before enforcing."}));}let g=n.filter(e=>e.ip!==void 0&&!d.some(i=>i.ip===e.ip)&&e.verification!=="verified"&&(e.intent==="unknown"||e.intent==="tooling")&&/Mozilla|Chrome|Safari/i.test(e.userAgent)),S=t.minPoolSize??5,y=[...new Set(g.map(e=>e.ip))];if(y.length>=S){let e=t.burstBudget??{window:60,requests:30},i=g.reduce((l,q)=>l+q.requests,0),a=[...new Set(g.map(l=>l.country).filter(Boolean))],r=g.filter(l=>l.spanSeconds!==void 0&&l.spanSeconds>0),p=r.filter(l=>l.requests/l.spanSeconds>e.requests/e.window),k=r.length?Math.max(...r.map(l=>l.requests/l.spanSeconds*60)):0,b=t.assetExclusions??x,$=[{type:"user_agent",op:"sub",value:"Mozilla"},...b.prefixes.map(l=>({type:"path",op:"pre",value:l,neg:true})),{type:"path",op:"re",value:`\\.(?:${b.extensions.join("|")})$`,neg:true}];o.push(u({name:"Burst limit page navigations",rationale:"A pool of addresses each too small to trip a per-address threshold, together behaving like one scraper. Rate is the only signal that separates them from real readers.",evidence:`${y.length} addresses across ${a.length} countries, ${i.toLocaleString("en-US")} requests, none individually above the abuse threshold`+(r.length?`; ${p.length} of ${r.length} measurable slices exceeded ${e.requests} requests/${e.window}s, peaking at ${Math.round(k).toLocaleString("en-US")}/min`:"; no timing supplied, so the burst budget is a default rather than a measurement"),groups:[$],action:"log",eventual:"rate_limit",rateLimit:{window:e.window,requests:e.requests,action:"rate_limit",keys:["ip"]},risk:"medium",caveat:"The asset exclusions are what make this safe \u2014 the WAF counts every request, including the dozens of assets behind a single page view, so verify they match how your app actually serves static files before enforcing. Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx."}));}let f=n.filter(e=>e.intent==="training");if(f.length){let e=f.reduce((r,p)=>r+p.requests,0),i=[...new Set(f.map(r=>r.botName))],a=t.trainingBudget??{window:3600,requests:600};o.push(u({name:"Rate limit training crawlers",rationale:"Bound what bulk corpus collection costs you without removing yourself from training sets.",evidence:`${e.toLocaleString("en-US")} training requests from ${i.length} vendors (${i.slice(0,6).join(", ")})`,groups:[[{type:"user_agent",op:"inc",value:["GPTBot","ClaudeBot","CCBot","Bytespider","Amazonbot","meta-externalagent"]}]],action:"log",eventual:"rate_limit",rateLimit:{window:a.window,requests:a.requests,action:"rate_limit",keys:["ip"]},risk:"medium",caveat:"Denying these removes you from future training sets, which may be exactly wrong for discoverability. Rate limit rather than deny unless you have decided otherwise. Note Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx."}));}let m=[...new Set(n.filter(e=>e.asn!==void 0&&/Mozilla|Chrome|Safari/i.test(e.userAgent)).filter(e=>e.verification!=="verified").map(e=>e.asn))];if(m.length){let i=n.filter(a=>a.asn!==void 0&&m.includes(a.asn)).reduce((a,r)=>a+r.requests,0);o.push(u({name:"Challenge browser user agents from datacenter networks",rationale:"A browser user agent arriving from a hosting network is automation wearing a costume \u2014 real browsers come from consumer ISPs.",evidence:`${i.toLocaleString("en-US")} requests across ${m.length} datacenter AS numbers`,groups:[[{type:"geo_as_number",op:"inc",value:m},{type:"user_agent",op:"sub",value:"Mozilla"}]],action:"log",eventual:"challenge",risk:"high",caveat:"Highest false-positive risk here. Corporate VPNs, privacy relays and some mobile carriers egress from hosting ASNs, and a challenge page breaks API clients and link unfurlers outright. Keep this in log mode for a full week before considering enforcement."}));}return o}function j(n){let t=["#!/usr/bin/env bash","# Vercel WAF proposals generated from observed agent traffic.","#","# Every rule starts in LOG mode and blocks nothing. Vercel stages rule","# changes as drafts, so nothing is live until you run:","#","# vercel firewall diff # review","# vercel firewall publish --yes # go live","#","# Review each rule in the dashboard before promoting it to its eventual","# action. Rules evaluate top to bottom, so keep the bypass rule first.","set -euo pipefail",""];n.forEach((s,c)=>{t.push(`# ${c+1}. ${s.name}`),t.push(`# why: ${s.rationale}`),t.push(`# evidence: ${s.evidence}`),t.push(`# risk: ${s.risk} \u2014 eventual action: ${s.eventual}`),s.caveat&&t.push(`# caveat: ${s.caveat}`),t.push(s.cli),t.push("");});let o=n.filter(s=>s.action==="bypass");if(o.length){t.push("# Keep the protective allow rules at the top of the evaluation order.");for(let s of [...o].reverse())t.push(`vercel firewall rules reorder ${JSON.stringify(s.name)} --first --yes`);}return n.length&&(t.push(""),t.push("vercel firewall diff"),t.push('echo "Review above, then: vercel firewall publish --yes"')),t.join(`
|
|
3
|
+
`)}exports.firewallScript=j;exports.recommendFirewallRules=B;//# sourceMappingURL=firewall.cjs.map
|
|
4
|
+
//# sourceMappingURL=firewall.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/firewall.ts"],"names":["DEFAULT_ASSET_EXCLUSIONS","shellQuote","json","toCli","r","parts","group","i","k","toJson","conditions","finish","median","ns","s","a","b","mid","recommendFirewallRules","observations","opts","out","wanted","o","requests","n","names","preview","spoofed","spoofedIps","v","vendors","perIp","threshold","heavy","sweeping","pool","h","minPool","poolIps","burst","countries","timed","overBudget","peak","assets","p","training","budget","headlessAsns","firewallScript","recommendations","lines","protective"],"mappings":"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","file":"firewall.cjs","sourcesContent":["/**\n * Recommend Vercel WAF rules from observed agent traffic.\n *\n * This generates *proposals*, never live changes. Every recommendation comes out\n * with `action: 'log'`, because a firewall rule's blast radius is unpredictable\n * until real traffic hits it and a bad `deny` takes out real users or your SEO.\n * Vercel's own guidance is log → review → preview → production; the `eventual`\n * field records where a rule is meant to end up, and `cli` emits the command for\n * the *current* stage only.\n *\n * Two hard rules, both from measurement rather than taste:\n *\n * 1. Retrieval agents and search crawlers are never proposed for blocking.\n * 60% of AI traffic on one production site is retrieval — a person asked a\n * question and an assistant went to read the page. Blocking that is\n * blocking your own distribution. The recommender emits a `bypass` rule to\n * protect them *first*, so later rules cannot catch them.\n *\n * 2. Training crawlers get rate limits, not denials, by default. The point is\n * to bound cost, not to disappear from corpora.\n *\n * Only abuse gets a denial: an identity that failed cryptographic or IP\n * verification, or a single address behaving like a scraper.\n */\n\nimport type { AgentIntent } from './policy.js'\n\n/** A Vercel WAF condition. Mirrors the CLI's `--condition` JSON. */\nexport interface FirewallCondition {\n type:\n | 'user_agent'\n | 'ip_address'\n | 'geo_as_number'\n | 'geo_country'\n | 'path'\n | 'method'\n | 'environment'\n | 'ja4_digest'\n op: 'eq' | 'neq' | 'sub' | 'pre' | 'suf' | 're' | 'inc' | 'ninc' | 'gt' | 'gte'\n value?: string | number | Array<string | number>\n key?: string\n neg?: boolean\n}\n\nexport type FirewallAction = 'log' | 'deny' | 'challenge' | 'bypass' | 'rate_limit'\n\nexport interface RateLimitSpec {\n /** Seconds, 10–3600. */\n window: number\n /** Max requests per window. */\n requests: number\n /** What happens on breach. */\n action: 'rate_limit' | 'deny' | 'challenge' | 'log'\n keys: Array<'ip' | 'ja4'>\n}\n\nexport interface FirewallRecommendation {\n name: string\n /** Why this rule is proposed, in one sentence. */\n rationale: string\n /** The measurement behind it. Never propose a rule without evidence. */\n evidence: string\n /** OR of ANDs: outer array is groups, inner is conditions within a group. */\n groups: FirewallCondition[][]\n /** Always `'log'` or `'bypass'` — see the module note. */\n action: FirewallAction\n /** Where this rule is intended to end up after review. */\n eventual: FirewallAction\n rateLimit?: RateLimitSpec\n /** How likely this is to catch traffic you wanted. */\n risk: 'low' | 'medium' | 'high'\n /** What could go wrong, when it is not obvious. */\n caveat?: string\n /** Ready-to-run CLI for the *current* stage. */\n cli: string\n /** Equivalent `--json` payload. */\n json: unknown\n}\n\n/** One aggregated slice of observed traffic. */\nexport interface TrafficObservation {\n userAgent: string\n botName: string\n intent: AgentIntent\n requests: number\n ip?: string\n /** Autonomous system number, if you resolved one. */\n asn?: number\n /** Distinct paths this slice touched — a scraper sweeps, a reader does not. */\n distinctPaths?: number\n /**\n * Seconds between this slice's first and last request. Combined with\n * `requests` this gives a rate, which is the signal that separates a scraper\n * from a reader when volume alone does not: on one production site the\n * heaviest single address managed only 100 requests a day — far under any\n * sane abuse threshold — but fetched 16 distinct pages in one second.\n */\n spanSeconds?: number\n /** Verification verdict, if you ran one. */\n verification?: 'verified' | 'spoofed' | 'unverifiable' | 'not-claimed'\n country?: string\n}\n\nexport interface RecommendOptions {\n /**\n * Requests-per-slice above which a single IP is considered abusive. Defaults\n * to 10x the median across observations, floored at 500.\n */\n abuseThreshold?: number\n /** Rate-limit budget proposed for training crawlers. Defaults to 600/hour. */\n trainingBudget?: { window: number; requests: number }\n /** Skip the protective bypass rule. Rarely a good idea. */\n omitProtectiveBypass?: boolean\n /**\n * Burst budget for the distributed-scraper rule, in *page* requests. Defaults\n * to 30 per 60s, which no human reaches once static assets are excluded.\n */\n burstBudget?: { window: number; requests: number }\n /**\n * How many distinct low-volume addresses must look alike before they are\n * treated as one coordinated pool. Defaults to 5.\n */\n minPoolSize?: number\n /**\n * Path prefixes and extensions the burst rule must not count, because the WAF\n * sees every asset request while your middleware probably does not. Defaults\n * to Next.js internals and the usual static extensions.\n */\n assetExclusions?: { prefixes: readonly string[]; extensions: readonly string[] }\n}\n\nconst DEFAULT_ASSET_EXCLUSIONS = {\n prefixes: ['/_next/'],\n extensions: [\n 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico',\n 'woff', 'woff2', 'ttf', 'otf', 'map'\n ]\n} as const\n\n/* -------------------------------------------------------------------------- */\n\nfunction shellQuote(json: unknown): string {\n return `'${JSON.stringify(json).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction toCli(r: Omit<FirewallRecommendation, 'cli' | 'json'>): string {\n const parts = [`vercel firewall rules add ${JSON.stringify(r.name)}`]\n r.groups.forEach((group, i) => {\n if (i > 0) parts.push(' --or')\n for (const c of group) parts.push(` --condition ${shellQuote(c)}`)\n })\n parts.push(` --action ${r.action}`)\n if (r.action === 'rate_limit' && r.rateLimit) {\n parts.push(` --rate-limit-window ${r.rateLimit.window}`)\n parts.push(` --rate-limit-requests ${r.rateLimit.requests}`)\n parts.push(` --rate-limit-action ${r.rateLimit.action}`)\n for (const k of r.rateLimit.keys) parts.push(` --rate-limit-keys ${k}`)\n }\n parts.push(' --yes')\n return parts.join(' \\\\\\n')\n}\n\nfunction toJson(r: Omit<FirewallRecommendation, 'cli' | 'json'>): unknown {\n return {\n name: r.name,\n conditionGroup: r.groups.map((conditions) => ({ conditions })),\n action: { mitigate: { action: r.action } }\n }\n}\n\nfunction finish(r: Omit<FirewallRecommendation, 'cli' | 'json'>): FirewallRecommendation {\n return { ...r, cli: toCli(r), json: toJson(r) }\n}\n\nfunction median(ns: number[]): number {\n if (!ns.length) return 0\n const s = [...ns].sort((a, b) => a - b)\n const mid = Math.floor(s.length / 2)\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2\n}\n\n/* -------------------------------------------------------------------------- */\n\n/**\n * Turn observations into staged WAF proposals.\n *\n * @example\n * ```ts\n * const rules = recommendFirewallRules(observations)\n * for (const r of rules) {\n * console.log(`# ${r.name} — ${r.rationale}`)\n * console.log(`# evidence: ${r.evidence}`)\n * console.log(r.cli)\n * }\n * ```\n */\nexport function recommendFirewallRules(\n observations: readonly TrafficObservation[],\n opts: RecommendOptions = {}\n): FirewallRecommendation[] {\n const out: FirewallRecommendation[] = []\n\n /* 1. Protect the traffic you want, first and above everything else. --------\n Rules are evaluated top to bottom, so this has to be rule #1 or a later\n user-agent rule will swallow the agents that bring you readers. */\n if (!opts.omitProtectiveBypass) {\n const wanted = observations.filter((o) => o.intent === 'retrieval' || o.intent === 'search')\n const requests = wanted.reduce((n, o) => n + o.requests, 0)\n const names = [...new Set(wanted.map((o) => o.botName))]\n out.push(\n finish({\n name: 'Allow retrieval and search agents',\n rationale:\n 'Retrieval agents and search crawlers must never be caught by the rules below — they bring readers and rankings.',\n evidence: requests\n ? `${requests.toLocaleString('en-US')} observed requests across ${names.length} vendors (${names.slice(0, 6).join(', ')})`\n : 'no retrieval or search traffic observed yet; installed pre-emptively',\n groups: [\n [\n {\n type: 'user_agent',\n op: 'inc',\n value: [\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'Perplexity-User',\n 'PerplexityBot',\n 'Googlebot',\n 'bingbot',\n 'DuckDuckBot',\n 'Applebot'\n ]\n }\n ]\n ],\n action: 'bypass',\n eventual: 'bypass',\n risk: 'low',\n caveat:\n 'Place this rule first (`vercel firewall rules reorder ... --first`). A user-agent allowlist is spoofable, so pair with verification in middleware rather than relying on it for security — its job here is to stop your own rules misfiring.'\n })\n )\n }\n\n /* 1b. Link unfurlers, protected separately from the agents above. ----------\n Kept out of the rule above deliberately. Those tokens belong to vendors who\n publish IP ranges and increasingly sign their requests; `facebookexternalhit`\n and friends do neither and are among the most-forged strings on the web. A\n `bypass` skips every managed ruleset too, so folding them into a rule\n labelled `low` risk would understate what it hands out. Same protection,\n honest label, and an operator can decline this one on its own. */\n const preview = observations.filter((o) => o.intent === 'preview')\n if (!opts.omitProtectiveBypass && preview.length) {\n const requests = preview.reduce((n, o) => n + o.requests, 0)\n const names = [...new Set(preview.map((o) => o.botName))]\n out.push(\n finish({\n name: 'Allow link unfurlers',\n rationale:\n 'Someone pasted your URL into a conversation and the platform fetched it to render a card. Blocking it makes your links look broken wherever they are shared.',\n evidence: `${requests.toLocaleString('en-US')} requests across ${names.length} platform${names.length === 1 ? '' : 's'} (${names.slice(0, 6).join(', ')})`,\n groups: [\n [\n {\n type: 'user_agent',\n op: 'inc',\n value: [\n 'facebookexternalhit',\n 'Twitterbot',\n 'LinkedInBot',\n 'Slackbot',\n 'Discordbot',\n 'TelegramBot',\n 'WhatsApp',\n 'redditbot'\n ]\n }\n ]\n ],\n action: 'bypass',\n eventual: 'bypass',\n risk: 'medium',\n caveat:\n 'These user agents are trivially spoofed and none of these platforms publish verifiable IP ranges, so this rule hands a bypass to anyone who sets the header. Scope it to your public content paths and never to anything authenticated or expensive.'\n })\n )\n }\n\n /* 2. Failed verification — the only class that earns a denial. ------------- */\n const spoofed = observations.filter((o) => o.verification === 'spoofed')\n const spoofedIps = [...new Set(spoofed.map((o) => o.ip).filter((v): v is string => !!v))]\n if (spoofedIps.length) {\n const requests = spoofed.reduce((n, o) => n + o.requests, 0)\n const vendors = [...new Set(spoofed.map((o) => o.botName))]\n out.push(\n finish({\n name: 'Deny impersonated crawler identities',\n rationale:\n 'These addresses claimed a crawler identity that failed verification against the vendor’s published ranges or signature.',\n evidence: `${requests.toLocaleString('en-US')} requests from ${spoofedIps.length} address${spoofedIps.length === 1 ? '' : 'es'} impersonating ${vendors.join(', ')}`,\n groups: [[{ type: 'ip_address', op: 'inc', value: spoofedIps }]],\n action: 'log',\n eventual: 'deny',\n risk: 'low',\n caveat:\n 'Verification failure is strong evidence, but confirm your edge controls x-forwarded-for before enforcing — behind a proxy that forwards a client-supplied header the verdict is worthless.'\n })\n )\n }\n\n /* 3. Single addresses behaving like scrapers. ------------------------------ */\n const perIp = observations.filter((o) => o.ip && o.verification !== 'verified')\n const threshold =\n opts.abuseThreshold ?? Math.max(500, Math.round(median(perIp.map((o) => o.requests)) * 10))\n const heavy = perIp\n .filter((o) => o.requests >= threshold)\n .sort((a, b) => b.requests - a.requests)\n .slice(0, 50)\n if (heavy.length) {\n const sweeping = heavy.filter((o) => (o.distinctPaths ?? 0) > 100)\n out.push(\n finish({\n name: 'Rate limit high-volume unverified addresses',\n rationale:\n 'A single address making orders of magnitude more requests than the median, with no verified identity.',\n evidence:\n `${heavy.length} address${heavy.length === 1 ? '' : 'es'} above ${threshold.toLocaleString('en-US')} requests` +\n (sweeping.length\n ? `; ${sweeping.length} swept >100 distinct paths, which reads as a scrape rather than a reader`\n : ''),\n groups: [[{ type: 'ip_address', op: 'inc', value: heavy.map((o) => o.ip!) }]],\n action: 'log',\n eventual: 'rate_limit',\n rateLimit: { window: 60, requests: 60, action: 'rate_limit', keys: ['ip'] },\n risk: 'medium',\n caveat:\n 'Shared egress means one address can front many real users — a corporate NAT, a mobile carrier, or a VPN. Review the dashboard before enforcing.'\n })\n )\n }\n\n /* 3b. Many small addresses behaving as one. --------------------------------\n Rule 3 asks \"is any single address abusive?\" and a rotating proxy pool is\n built precisely so the answer is no. Observed on one production site: 34\n addresses across 11 countries, one user agent each, none above 100 requests\n a day — every one of them invisible to a per-address threshold, while\n collectively sweeping the site and inflating its analytics.\n\n Volume cannot separate that from real readers, so this keys on rate. It is\n safe to do so only because the condition excludes static assets: a person\n loading one page fires dozens of asset requests that the WAF counts and\n your middleware does not, and a naive limit on `Mozilla` would throttle\n real users on their first page view. */\n const pool = observations.filter(\n (o) =>\n o.ip !== undefined &&\n !heavy.some((h) => h.ip === o.ip) &&\n o.verification !== 'verified' &&\n (o.intent === 'unknown' || o.intent === 'tooling') &&\n /Mozilla|Chrome|Safari/i.test(o.userAgent)\n )\n const minPool = opts.minPoolSize ?? 5\n const poolIps = [...new Set(pool.map((o) => o.ip!))]\n if (poolIps.length >= minPool) {\n const burst = opts.burstBudget ?? { window: 60, requests: 30 }\n const requests = pool.reduce((n, o) => n + o.requests, 0)\n const countries = [...new Set(pool.map((o) => o.country).filter(Boolean))]\n\n // Only slices that actually carry timing can be shown to exceed the budget.\n // Say how many were measurable rather than implying the rest were clean.\n const timed = pool.filter((o) => o.spanSeconds !== undefined && o.spanSeconds > 0)\n const overBudget = timed.filter(\n (o) => o.requests / o.spanSeconds! > burst.requests / burst.window\n )\n const peak = timed.length\n ? Math.max(...timed.map((o) => (o.requests / o.spanSeconds!) * 60))\n : 0\n\n const assets = opts.assetExclusions ?? DEFAULT_ASSET_EXCLUSIONS\n const conditions: FirewallCondition[] = [\n { type: 'user_agent', op: 'sub', value: 'Mozilla' },\n ...assets.prefixes.map(\n (p): FirewallCondition => ({ type: 'path', op: 'pre', value: p, neg: true })\n ),\n {\n type: 'path',\n op: 're',\n value: `\\\\.(?:${assets.extensions.join('|')})$`,\n neg: true\n }\n ]\n\n out.push(\n finish({\n name: 'Burst limit page navigations',\n rationale:\n 'A pool of addresses each too small to trip a per-address threshold, together behaving like one scraper. Rate is the only signal that separates them from real readers.',\n evidence:\n `${poolIps.length} addresses across ${countries.length} countries, ${requests.toLocaleString('en-US')} requests, none individually above the abuse threshold` +\n (timed.length\n ? `; ${overBudget.length} of ${timed.length} measurable slices exceeded ${burst.requests} requests/${burst.window}s, peaking at ${Math.round(peak).toLocaleString('en-US')}/min`\n : '; no timing supplied, so the burst budget is a default rather than a measurement'),\n groups: [conditions],\n action: 'log',\n eventual: 'rate_limit',\n rateLimit: { window: burst.window, requests: burst.requests, action: 'rate_limit', keys: ['ip'] },\n risk: 'medium',\n caveat:\n 'The asset exclusions are what make this safe — the WAF counts every request, including the dozens of assets behind a single page view, so verify they match how your app actually serves static files before enforcing. Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx.'\n })\n )\n }\n\n /* 4. Training crawlers: bound the cost, do not disappear from corpora. ----- */\n const training = observations.filter((o) => o.intent === 'training')\n if (training.length) {\n const requests = training.reduce((n, o) => n + o.requests, 0)\n const vendors = [...new Set(training.map((o) => o.botName))]\n const budget = opts.trainingBudget ?? { window: 3600, requests: 600 }\n out.push(\n finish({\n name: 'Rate limit training crawlers',\n rationale:\n 'Bound what bulk corpus collection costs you without removing yourself from training sets.',\n evidence: `${requests.toLocaleString('en-US')} training requests from ${vendors.length} vendors (${vendors.slice(0, 6).join(', ')})`,\n groups: [\n [\n {\n type: 'user_agent',\n op: 'inc',\n value: ['GPTBot', 'ClaudeBot', 'CCBot', 'Bytespider', 'Amazonbot', 'meta-externalagent']\n }\n ]\n ],\n action: 'log',\n eventual: 'rate_limit',\n rateLimit: { window: budget.window, requests: budget.requests, action: 'rate_limit', keys: ['ip'] },\n risk: 'medium',\n caveat:\n 'Denying these removes you from future training sets, which may be exactly wrong for discoverability. Rate limit rather than deny unless you have decided otherwise. Note Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx.'\n })\n )\n }\n\n /* 5. Datacenter ASNs presenting browser user agents. ---------------------- */\n const headlessAsns = [\n ...new Set(\n observations\n .filter((o) => o.asn !== undefined && /Mozilla|Chrome|Safari/i.test(o.userAgent))\n .filter((o) => o.verification !== 'verified')\n .map((o) => o.asn!)\n )\n ]\n if (headlessAsns.length) {\n const slices = observations.filter((o) => o.asn !== undefined && headlessAsns.includes(o.asn))\n const requests = slices.reduce((n, o) => n + o.requests, 0)\n out.push(\n finish({\n name: 'Challenge browser user agents from datacenter networks',\n rationale:\n 'A browser user agent arriving from a hosting network is automation wearing a costume — real browsers come from consumer ISPs.',\n evidence: `${requests.toLocaleString('en-US')} requests across ${headlessAsns.length} datacenter AS numbers`,\n groups: [\n [\n { type: 'geo_as_number', op: 'inc', value: headlessAsns },\n { type: 'user_agent', op: 'sub', value: 'Mozilla' }\n ]\n ],\n action: 'log',\n eventual: 'challenge',\n risk: 'high',\n caveat:\n 'Highest false-positive risk here. Corporate VPNs, privacy relays and some mobile carriers egress from hosting ASNs, and a challenge page breaks API clients and link unfurlers outright. Keep this in log mode for a full week before considering enforcement.'\n })\n )\n }\n\n return out\n}\n\n/** Render recommendations as a runnable, commented shell script. */\nexport function firewallScript(recommendations: readonly FirewallRecommendation[]): string {\n const lines = [\n '#!/usr/bin/env bash',\n '# Vercel WAF proposals generated from observed agent traffic.',\n '#',\n '# Every rule starts in LOG mode and blocks nothing. Vercel stages rule',\n '# changes as drafts, so nothing is live until you run:',\n '#',\n '# vercel firewall diff # review',\n '# vercel firewall publish --yes # go live',\n '#',\n '# Review each rule in the dashboard before promoting it to its eventual',\n '# action. Rules evaluate top to bottom, so keep the bypass rule first.',\n 'set -euo pipefail',\n ''\n ]\n recommendations.forEach((r, i) => {\n lines.push(`# ${i + 1}. ${r.name}`)\n lines.push(`# why: ${r.rationale}`)\n lines.push(`# evidence: ${r.evidence}`)\n lines.push(`# risk: ${r.risk} — eventual action: ${r.eventual}`)\n if (r.caveat) lines.push(`# caveat: ${r.caveat}`)\n lines.push(r.cli)\n lines.push('')\n })\n const protective = recommendations.filter((r) => r.action === 'bypass')\n if (protective.length) {\n lines.push('# Keep the protective allow rules at the top of the evaluation order.')\n // Reversed: each --first pushes to the top, so applying them back-to-front\n // leaves the array's own order intact once all of them have run.\n for (const r of [...protective].reverse()) {\n lines.push(`vercel firewall rules reorder ${JSON.stringify(r.name)} --first --yes`)\n }\n }\n if (recommendations.length) {\n lines.push('')\n lines.push('vercel firewall diff')\n lines.push('echo \"Review above, then: vercel firewall publish --yes\"')\n }\n return lines.join('\\n')\n}\n"]}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { A as AgentIntent } from './policy-CEJ-j-Lz.cjs';
|
|
2
|
+
import './types-Dw43eu7D.cjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recommend Vercel WAF rules from observed agent traffic.
|
|
6
|
+
*
|
|
7
|
+
* This generates *proposals*, never live changes. Every recommendation comes out
|
|
8
|
+
* with `action: 'log'`, because a firewall rule's blast radius is unpredictable
|
|
9
|
+
* until real traffic hits it and a bad `deny` takes out real users or your SEO.
|
|
10
|
+
* Vercel's own guidance is log → review → preview → production; the `eventual`
|
|
11
|
+
* field records where a rule is meant to end up, and `cli` emits the command for
|
|
12
|
+
* the *current* stage only.
|
|
13
|
+
*
|
|
14
|
+
* Two hard rules, both from measurement rather than taste:
|
|
15
|
+
*
|
|
16
|
+
* 1. Retrieval agents and search crawlers are never proposed for blocking.
|
|
17
|
+
* 60% of AI traffic on one production site is retrieval — a person asked a
|
|
18
|
+
* question and an assistant went to read the page. Blocking that is
|
|
19
|
+
* blocking your own distribution. The recommender emits a `bypass` rule to
|
|
20
|
+
* protect them *first*, so later rules cannot catch them.
|
|
21
|
+
*
|
|
22
|
+
* 2. Training crawlers get rate limits, not denials, by default. The point is
|
|
23
|
+
* to bound cost, not to disappear from corpora.
|
|
24
|
+
*
|
|
25
|
+
* Only abuse gets a denial: an identity that failed cryptographic or IP
|
|
26
|
+
* verification, or a single address behaving like a scraper.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A Vercel WAF condition. Mirrors the CLI's `--condition` JSON. */
|
|
30
|
+
interface FirewallCondition {
|
|
31
|
+
type: 'user_agent' | 'ip_address' | 'geo_as_number' | 'geo_country' | 'path' | 'method' | 'environment' | 'ja4_digest';
|
|
32
|
+
op: 'eq' | 'neq' | 'sub' | 'pre' | 'suf' | 're' | 'inc' | 'ninc' | 'gt' | 'gte';
|
|
33
|
+
value?: string | number | Array<string | number>;
|
|
34
|
+
key?: string;
|
|
35
|
+
neg?: boolean;
|
|
36
|
+
}
|
|
37
|
+
type FirewallAction = 'log' | 'deny' | 'challenge' | 'bypass' | 'rate_limit';
|
|
38
|
+
interface RateLimitSpec {
|
|
39
|
+
/** Seconds, 10–3600. */
|
|
40
|
+
window: number;
|
|
41
|
+
/** Max requests per window. */
|
|
42
|
+
requests: number;
|
|
43
|
+
/** What happens on breach. */
|
|
44
|
+
action: 'rate_limit' | 'deny' | 'challenge' | 'log';
|
|
45
|
+
keys: Array<'ip' | 'ja4'>;
|
|
46
|
+
}
|
|
47
|
+
interface FirewallRecommendation {
|
|
48
|
+
name: string;
|
|
49
|
+
/** Why this rule is proposed, in one sentence. */
|
|
50
|
+
rationale: string;
|
|
51
|
+
/** The measurement behind it. Never propose a rule without evidence. */
|
|
52
|
+
evidence: string;
|
|
53
|
+
/** OR of ANDs: outer array is groups, inner is conditions within a group. */
|
|
54
|
+
groups: FirewallCondition[][];
|
|
55
|
+
/** Always `'log'` or `'bypass'` — see the module note. */
|
|
56
|
+
action: FirewallAction;
|
|
57
|
+
/** Where this rule is intended to end up after review. */
|
|
58
|
+
eventual: FirewallAction;
|
|
59
|
+
rateLimit?: RateLimitSpec;
|
|
60
|
+
/** How likely this is to catch traffic you wanted. */
|
|
61
|
+
risk: 'low' | 'medium' | 'high';
|
|
62
|
+
/** What could go wrong, when it is not obvious. */
|
|
63
|
+
caveat?: string;
|
|
64
|
+
/** Ready-to-run CLI for the *current* stage. */
|
|
65
|
+
cli: string;
|
|
66
|
+
/** Equivalent `--json` payload. */
|
|
67
|
+
json: unknown;
|
|
68
|
+
}
|
|
69
|
+
/** One aggregated slice of observed traffic. */
|
|
70
|
+
interface TrafficObservation {
|
|
71
|
+
userAgent: string;
|
|
72
|
+
botName: string;
|
|
73
|
+
intent: AgentIntent;
|
|
74
|
+
requests: number;
|
|
75
|
+
ip?: string;
|
|
76
|
+
/** Autonomous system number, if you resolved one. */
|
|
77
|
+
asn?: number;
|
|
78
|
+
/** Distinct paths this slice touched — a scraper sweeps, a reader does not. */
|
|
79
|
+
distinctPaths?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Seconds between this slice's first and last request. Combined with
|
|
82
|
+
* `requests` this gives a rate, which is the signal that separates a scraper
|
|
83
|
+
* from a reader when volume alone does not: on one production site the
|
|
84
|
+
* heaviest single address managed only 100 requests a day — far under any
|
|
85
|
+
* sane abuse threshold — but fetched 16 distinct pages in one second.
|
|
86
|
+
*/
|
|
87
|
+
spanSeconds?: number;
|
|
88
|
+
/** Verification verdict, if you ran one. */
|
|
89
|
+
verification?: 'verified' | 'spoofed' | 'unverifiable' | 'not-claimed';
|
|
90
|
+
country?: string;
|
|
91
|
+
}
|
|
92
|
+
interface RecommendOptions {
|
|
93
|
+
/**
|
|
94
|
+
* Requests-per-slice above which a single IP is considered abusive. Defaults
|
|
95
|
+
* to 10x the median across observations, floored at 500.
|
|
96
|
+
*/
|
|
97
|
+
abuseThreshold?: number;
|
|
98
|
+
/** Rate-limit budget proposed for training crawlers. Defaults to 600/hour. */
|
|
99
|
+
trainingBudget?: {
|
|
100
|
+
window: number;
|
|
101
|
+
requests: number;
|
|
102
|
+
};
|
|
103
|
+
/** Skip the protective bypass rule. Rarely a good idea. */
|
|
104
|
+
omitProtectiveBypass?: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Burst budget for the distributed-scraper rule, in *page* requests. Defaults
|
|
107
|
+
* to 30 per 60s, which no human reaches once static assets are excluded.
|
|
108
|
+
*/
|
|
109
|
+
burstBudget?: {
|
|
110
|
+
window: number;
|
|
111
|
+
requests: number;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* How many distinct low-volume addresses must look alike before they are
|
|
115
|
+
* treated as one coordinated pool. Defaults to 5.
|
|
116
|
+
*/
|
|
117
|
+
minPoolSize?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Path prefixes and extensions the burst rule must not count, because the WAF
|
|
120
|
+
* sees every asset request while your middleware probably does not. Defaults
|
|
121
|
+
* to Next.js internals and the usual static extensions.
|
|
122
|
+
*/
|
|
123
|
+
assetExclusions?: {
|
|
124
|
+
prefixes: readonly string[];
|
|
125
|
+
extensions: readonly string[];
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Turn observations into staged WAF proposals.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* const rules = recommendFirewallRules(observations)
|
|
134
|
+
* for (const r of rules) {
|
|
135
|
+
* console.log(`# ${r.name} — ${r.rationale}`)
|
|
136
|
+
* console.log(`# evidence: ${r.evidence}`)
|
|
137
|
+
* console.log(r.cli)
|
|
138
|
+
* }
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
declare function recommendFirewallRules(observations: readonly TrafficObservation[], opts?: RecommendOptions): FirewallRecommendation[];
|
|
142
|
+
/** Render recommendations as a runnable, commented shell script. */
|
|
143
|
+
declare function firewallScript(recommendations: readonly FirewallRecommendation[]): string;
|
|
144
|
+
|
|
145
|
+
export { type FirewallAction, type FirewallCondition, type FirewallRecommendation, type RateLimitSpec, type RecommendOptions, type TrafficObservation, firewallScript, recommendFirewallRules };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { A as AgentIntent } from './policy-ByqnGK1b.js';
|
|
2
|
+
import './types-Dw43eu7D.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recommend Vercel WAF rules from observed agent traffic.
|
|
6
|
+
*
|
|
7
|
+
* This generates *proposals*, never live changes. Every recommendation comes out
|
|
8
|
+
* with `action: 'log'`, because a firewall rule's blast radius is unpredictable
|
|
9
|
+
* until real traffic hits it and a bad `deny` takes out real users or your SEO.
|
|
10
|
+
* Vercel's own guidance is log → review → preview → production; the `eventual`
|
|
11
|
+
* field records where a rule is meant to end up, and `cli` emits the command for
|
|
12
|
+
* the *current* stage only.
|
|
13
|
+
*
|
|
14
|
+
* Two hard rules, both from measurement rather than taste:
|
|
15
|
+
*
|
|
16
|
+
* 1. Retrieval agents and search crawlers are never proposed for blocking.
|
|
17
|
+
* 60% of AI traffic on one production site is retrieval — a person asked a
|
|
18
|
+
* question and an assistant went to read the page. Blocking that is
|
|
19
|
+
* blocking your own distribution. The recommender emits a `bypass` rule to
|
|
20
|
+
* protect them *first*, so later rules cannot catch them.
|
|
21
|
+
*
|
|
22
|
+
* 2. Training crawlers get rate limits, not denials, by default. The point is
|
|
23
|
+
* to bound cost, not to disappear from corpora.
|
|
24
|
+
*
|
|
25
|
+
* Only abuse gets a denial: an identity that failed cryptographic or IP
|
|
26
|
+
* verification, or a single address behaving like a scraper.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A Vercel WAF condition. Mirrors the CLI's `--condition` JSON. */
|
|
30
|
+
interface FirewallCondition {
|
|
31
|
+
type: 'user_agent' | 'ip_address' | 'geo_as_number' | 'geo_country' | 'path' | 'method' | 'environment' | 'ja4_digest';
|
|
32
|
+
op: 'eq' | 'neq' | 'sub' | 'pre' | 'suf' | 're' | 'inc' | 'ninc' | 'gt' | 'gte';
|
|
33
|
+
value?: string | number | Array<string | number>;
|
|
34
|
+
key?: string;
|
|
35
|
+
neg?: boolean;
|
|
36
|
+
}
|
|
37
|
+
type FirewallAction = 'log' | 'deny' | 'challenge' | 'bypass' | 'rate_limit';
|
|
38
|
+
interface RateLimitSpec {
|
|
39
|
+
/** Seconds, 10–3600. */
|
|
40
|
+
window: number;
|
|
41
|
+
/** Max requests per window. */
|
|
42
|
+
requests: number;
|
|
43
|
+
/** What happens on breach. */
|
|
44
|
+
action: 'rate_limit' | 'deny' | 'challenge' | 'log';
|
|
45
|
+
keys: Array<'ip' | 'ja4'>;
|
|
46
|
+
}
|
|
47
|
+
interface FirewallRecommendation {
|
|
48
|
+
name: string;
|
|
49
|
+
/** Why this rule is proposed, in one sentence. */
|
|
50
|
+
rationale: string;
|
|
51
|
+
/** The measurement behind it. Never propose a rule without evidence. */
|
|
52
|
+
evidence: string;
|
|
53
|
+
/** OR of ANDs: outer array is groups, inner is conditions within a group. */
|
|
54
|
+
groups: FirewallCondition[][];
|
|
55
|
+
/** Always `'log'` or `'bypass'` — see the module note. */
|
|
56
|
+
action: FirewallAction;
|
|
57
|
+
/** Where this rule is intended to end up after review. */
|
|
58
|
+
eventual: FirewallAction;
|
|
59
|
+
rateLimit?: RateLimitSpec;
|
|
60
|
+
/** How likely this is to catch traffic you wanted. */
|
|
61
|
+
risk: 'low' | 'medium' | 'high';
|
|
62
|
+
/** What could go wrong, when it is not obvious. */
|
|
63
|
+
caveat?: string;
|
|
64
|
+
/** Ready-to-run CLI for the *current* stage. */
|
|
65
|
+
cli: string;
|
|
66
|
+
/** Equivalent `--json` payload. */
|
|
67
|
+
json: unknown;
|
|
68
|
+
}
|
|
69
|
+
/** One aggregated slice of observed traffic. */
|
|
70
|
+
interface TrafficObservation {
|
|
71
|
+
userAgent: string;
|
|
72
|
+
botName: string;
|
|
73
|
+
intent: AgentIntent;
|
|
74
|
+
requests: number;
|
|
75
|
+
ip?: string;
|
|
76
|
+
/** Autonomous system number, if you resolved one. */
|
|
77
|
+
asn?: number;
|
|
78
|
+
/** Distinct paths this slice touched — a scraper sweeps, a reader does not. */
|
|
79
|
+
distinctPaths?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Seconds between this slice's first and last request. Combined with
|
|
82
|
+
* `requests` this gives a rate, which is the signal that separates a scraper
|
|
83
|
+
* from a reader when volume alone does not: on one production site the
|
|
84
|
+
* heaviest single address managed only 100 requests a day — far under any
|
|
85
|
+
* sane abuse threshold — but fetched 16 distinct pages in one second.
|
|
86
|
+
*/
|
|
87
|
+
spanSeconds?: number;
|
|
88
|
+
/** Verification verdict, if you ran one. */
|
|
89
|
+
verification?: 'verified' | 'spoofed' | 'unverifiable' | 'not-claimed';
|
|
90
|
+
country?: string;
|
|
91
|
+
}
|
|
92
|
+
interface RecommendOptions {
|
|
93
|
+
/**
|
|
94
|
+
* Requests-per-slice above which a single IP is considered abusive. Defaults
|
|
95
|
+
* to 10x the median across observations, floored at 500.
|
|
96
|
+
*/
|
|
97
|
+
abuseThreshold?: number;
|
|
98
|
+
/** Rate-limit budget proposed for training crawlers. Defaults to 600/hour. */
|
|
99
|
+
trainingBudget?: {
|
|
100
|
+
window: number;
|
|
101
|
+
requests: number;
|
|
102
|
+
};
|
|
103
|
+
/** Skip the protective bypass rule. Rarely a good idea. */
|
|
104
|
+
omitProtectiveBypass?: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Burst budget for the distributed-scraper rule, in *page* requests. Defaults
|
|
107
|
+
* to 30 per 60s, which no human reaches once static assets are excluded.
|
|
108
|
+
*/
|
|
109
|
+
burstBudget?: {
|
|
110
|
+
window: number;
|
|
111
|
+
requests: number;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* How many distinct low-volume addresses must look alike before they are
|
|
115
|
+
* treated as one coordinated pool. Defaults to 5.
|
|
116
|
+
*/
|
|
117
|
+
minPoolSize?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Path prefixes and extensions the burst rule must not count, because the WAF
|
|
120
|
+
* sees every asset request while your middleware probably does not. Defaults
|
|
121
|
+
* to Next.js internals and the usual static extensions.
|
|
122
|
+
*/
|
|
123
|
+
assetExclusions?: {
|
|
124
|
+
prefixes: readonly string[];
|
|
125
|
+
extensions: readonly string[];
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Turn observations into staged WAF proposals.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* const rules = recommendFirewallRules(observations)
|
|
134
|
+
* for (const r of rules) {
|
|
135
|
+
* console.log(`# ${r.name} — ${r.rationale}`)
|
|
136
|
+
* console.log(`# evidence: ${r.evidence}`)
|
|
137
|
+
* console.log(r.cli)
|
|
138
|
+
* }
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
declare function recommendFirewallRules(observations: readonly TrafficObservation[], opts?: RecommendOptions): FirewallRecommendation[];
|
|
142
|
+
/** Render recommendations as a runnable, commented shell script. */
|
|
143
|
+
declare function firewallScript(recommendations: readonly FirewallRecommendation[]): string;
|
|
144
|
+
|
|
145
|
+
export { type FirewallAction, type FirewallCondition, type FirewallRecommendation, type RateLimitSpec, type RecommendOptions, type TrafficObservation, firewallScript, recommendFirewallRules };
|
package/dist/firewall.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
var x={prefixes:["/_next/"],extensions:["js","css","png","jpg","jpeg","gif","svg","webp","ico","woff","woff2","ttf","otf","map"]};function A(n){return `'${JSON.stringify(n).replace(/'/g,"'\\''")}'`}function _(n){let t=[`vercel firewall rules add ${JSON.stringify(n.name)}`];if(n.groups.forEach((o,s)=>{s>0&&t.push(" --or");for(let c of o)t.push(` --condition ${A(c)}`);}),t.push(` --action ${n.action}`),n.action==="rate_limit"&&n.rateLimit){t.push(` --rate-limit-window ${n.rateLimit.window}`),t.push(` --rate-limit-requests ${n.rateLimit.requests}`),t.push(` --rate-limit-action ${n.rateLimit.action}`);for(let o of n.rateLimit.keys)t.push(` --rate-limit-keys ${o}`);}return t.push(" --yes"),t.join(` \\
|
|
2
|
+
`)}function L(n){return {name:n.name,conditionGroup:n.groups.map(t=>({conditions:t})),action:{mitigate:{action:n.action}}}}function u(n){return {...n,cli:_(n),json:L(n)}}function R(n){if(!n.length)return 0;let t=[...n].sort((s,c)=>s-c),o=Math.floor(t.length/2);return t.length%2?t[o]:(t[o-1]+t[o])/2}function B(n,t={}){let o=[];if(!t.omitProtectiveBypass){let e=n.filter(r=>r.intent==="retrieval"||r.intent==="search"),i=e.reduce((r,p)=>r+p.requests,0),a=[...new Set(e.map(r=>r.botName))];o.push(u({name:"Allow retrieval and search agents",rationale:"Retrieval agents and search crawlers must never be caught by the rules below \u2014 they bring readers and rankings.",evidence:i?`${i.toLocaleString("en-US")} observed requests across ${a.length} vendors (${a.slice(0,6).join(", ")})`:"no retrieval or search traffic observed yet; installed pre-emptively",groups:[[{type:"user_agent",op:"inc",value:["ChatGPT-User","OAI-SearchBot","Claude-User","Claude-SearchBot","Perplexity-User","PerplexityBot","Googlebot","bingbot","DuckDuckBot","Applebot"]}]],action:"bypass",eventual:"bypass",risk:"low",caveat:"Place this rule first (`vercel firewall rules reorder ... --first`). A user-agent allowlist is spoofable, so pair with verification in middleware rather than relying on it for security \u2014 its job here is to stop your own rules misfiring."}));}let s=n.filter(e=>e.intent==="preview");if(!t.omitProtectiveBypass&&s.length){let e=s.reduce((a,r)=>a+r.requests,0),i=[...new Set(s.map(a=>a.botName))];o.push(u({name:"Allow link unfurlers",rationale:"Someone pasted your URL into a conversation and the platform fetched it to render a card. Blocking it makes your links look broken wherever they are shared.",evidence:`${e.toLocaleString("en-US")} requests across ${i.length} platform${i.length===1?"":"s"} (${i.slice(0,6).join(", ")})`,groups:[[{type:"user_agent",op:"inc",value:["facebookexternalhit","Twitterbot","LinkedInBot","Slackbot","Discordbot","TelegramBot","WhatsApp","redditbot"]}]],action:"bypass",eventual:"bypass",risk:"medium",caveat:"These user agents are trivially spoofed and none of these platforms publish verifiable IP ranges, so this rule hands a bypass to anyone who sets the header. Scope it to your public content paths and never to anything authenticated or expensive."}));}let c=n.filter(e=>e.verification==="spoofed"),h=[...new Set(c.map(e=>e.ip).filter(e=>!!e))];if(h.length){let e=c.reduce((a,r)=>a+r.requests,0),i=[...new Set(c.map(a=>a.botName))];o.push(u({name:"Deny impersonated crawler identities",rationale:"These addresses claimed a crawler identity that failed verification against the vendor\u2019s published ranges or signature.",evidence:`${e.toLocaleString("en-US")} requests from ${h.length} address${h.length===1?"":"es"} impersonating ${i.join(", ")}`,groups:[[{type:"ip_address",op:"inc",value:h}]],action:"log",eventual:"deny",risk:"low",caveat:"Verification failure is strong evidence, but confirm your edge controls x-forwarded-for before enforcing \u2014 behind a proxy that forwards a client-supplied header the verdict is worthless."}));}let v=n.filter(e=>e.ip&&e.verification!=="verified"),w=t.abuseThreshold??Math.max(500,Math.round(R(v.map(e=>e.requests))*10)),d=v.filter(e=>e.requests>=w).sort((e,i)=>i.requests-e.requests).slice(0,50);if(d.length){let e=d.filter(i=>(i.distinctPaths??0)>100);o.push(u({name:"Rate limit high-volume unverified addresses",rationale:"A single address making orders of magnitude more requests than the median, with no verified identity.",evidence:`${d.length} address${d.length===1?"":"es"} above ${w.toLocaleString("en-US")} requests`+(e.length?`; ${e.length} swept >100 distinct paths, which reads as a scrape rather than a reader`:""),groups:[[{type:"ip_address",op:"inc",value:d.map(i=>i.ip)}]],action:"log",eventual:"rate_limit",rateLimit:{window:60,requests:60,action:"rate_limit",keys:["ip"]},risk:"medium",caveat:"Shared egress means one address can front many real users \u2014 a corporate NAT, a mobile carrier, or a VPN. Review the dashboard before enforcing."}));}let g=n.filter(e=>e.ip!==void 0&&!d.some(i=>i.ip===e.ip)&&e.verification!=="verified"&&(e.intent==="unknown"||e.intent==="tooling")&&/Mozilla|Chrome|Safari/i.test(e.userAgent)),S=t.minPoolSize??5,y=[...new Set(g.map(e=>e.ip))];if(y.length>=S){let e=t.burstBudget??{window:60,requests:30},i=g.reduce((l,q)=>l+q.requests,0),a=[...new Set(g.map(l=>l.country).filter(Boolean))],r=g.filter(l=>l.spanSeconds!==void 0&&l.spanSeconds>0),p=r.filter(l=>l.requests/l.spanSeconds>e.requests/e.window),k=r.length?Math.max(...r.map(l=>l.requests/l.spanSeconds*60)):0,b=t.assetExclusions??x,$=[{type:"user_agent",op:"sub",value:"Mozilla"},...b.prefixes.map(l=>({type:"path",op:"pre",value:l,neg:true})),{type:"path",op:"re",value:`\\.(?:${b.extensions.join("|")})$`,neg:true}];o.push(u({name:"Burst limit page navigations",rationale:"A pool of addresses each too small to trip a per-address threshold, together behaving like one scraper. Rate is the only signal that separates them from real readers.",evidence:`${y.length} addresses across ${a.length} countries, ${i.toLocaleString("en-US")} requests, none individually above the abuse threshold`+(r.length?`; ${p.length} of ${r.length} measurable slices exceeded ${e.requests} requests/${e.window}s, peaking at ${Math.round(k).toLocaleString("en-US")}/min`:"; no timing supplied, so the burst budget is a default rather than a measurement"),groups:[$],action:"log",eventual:"rate_limit",rateLimit:{window:e.window,requests:e.requests,action:"rate_limit",keys:["ip"]},risk:"medium",caveat:"The asset exclusions are what make this safe \u2014 the WAF counts every request, including the dozens of assets behind a single page view, so verify they match how your app actually serves static files before enforcing. Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx."}));}let f=n.filter(e=>e.intent==="training");if(f.length){let e=f.reduce((r,p)=>r+p.requests,0),i=[...new Set(f.map(r=>r.botName))],a=t.trainingBudget??{window:3600,requests:600};o.push(u({name:"Rate limit training crawlers",rationale:"Bound what bulk corpus collection costs you without removing yourself from training sets.",evidence:`${e.toLocaleString("en-US")} training requests from ${i.length} vendors (${i.slice(0,6).join(", ")})`,groups:[[{type:"user_agent",op:"inc",value:["GPTBot","ClaudeBot","CCBot","Bytespider","Amazonbot","meta-externalagent"]}]],action:"log",eventual:"rate_limit",rateLimit:{window:a.window,requests:a.requests,action:"rate_limit",keys:["ip"]},risk:"medium",caveat:"Denying these removes you from future training sets, which may be exactly wrong for discoverability. Rate limit rather than deny unless you have decided otherwise. Note Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx."}));}let m=[...new Set(n.filter(e=>e.asn!==void 0&&/Mozilla|Chrome|Safari/i.test(e.userAgent)).filter(e=>e.verification!=="verified").map(e=>e.asn))];if(m.length){let i=n.filter(a=>a.asn!==void 0&&m.includes(a.asn)).reduce((a,r)=>a+r.requests,0);o.push(u({name:"Challenge browser user agents from datacenter networks",rationale:"A browser user agent arriving from a hosting network is automation wearing a costume \u2014 real browsers come from consumer ISPs.",evidence:`${i.toLocaleString("en-US")} requests across ${m.length} datacenter AS numbers`,groups:[[{type:"geo_as_number",op:"inc",value:m},{type:"user_agent",op:"sub",value:"Mozilla"}]],action:"log",eventual:"challenge",risk:"high",caveat:"Highest false-positive risk here. Corporate VPNs, privacy relays and some mobile carriers egress from hosting ASNs, and a challenge page breaks API clients and link unfurlers outright. Keep this in log mode for a full week before considering enforcement."}));}return o}function j(n){let t=["#!/usr/bin/env bash","# Vercel WAF proposals generated from observed agent traffic.","#","# Every rule starts in LOG mode and blocks nothing. Vercel stages rule","# changes as drafts, so nothing is live until you run:","#","# vercel firewall diff # review","# vercel firewall publish --yes # go live","#","# Review each rule in the dashboard before promoting it to its eventual","# action. Rules evaluate top to bottom, so keep the bypass rule first.","set -euo pipefail",""];n.forEach((s,c)=>{t.push(`# ${c+1}. ${s.name}`),t.push(`# why: ${s.rationale}`),t.push(`# evidence: ${s.evidence}`),t.push(`# risk: ${s.risk} \u2014 eventual action: ${s.eventual}`),s.caveat&&t.push(`# caveat: ${s.caveat}`),t.push(s.cli),t.push("");});let o=n.filter(s=>s.action==="bypass");if(o.length){t.push("# Keep the protective allow rules at the top of the evaluation order.");for(let s of [...o].reverse())t.push(`vercel firewall rules reorder ${JSON.stringify(s.name)} --first --yes`);}return n.length&&(t.push(""),t.push("vercel firewall diff"),t.push('echo "Review above, then: vercel firewall publish --yes"')),t.join(`
|
|
3
|
+
`)}export{j as firewallScript,B as recommendFirewallRules};//# sourceMappingURL=firewall.js.map
|
|
4
|
+
//# sourceMappingURL=firewall.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/firewall.ts"],"names":["DEFAULT_ASSET_EXCLUSIONS","shellQuote","json","toCli","r","parts","group","i","k","toJson","conditions","finish","median","ns","s","a","b","mid","recommendFirewallRules","observations","opts","out","wanted","o","requests","n","names","preview","spoofed","spoofedIps","v","vendors","perIp","threshold","heavy","sweeping","pool","h","minPool","poolIps","burst","countries","timed","overBudget","peak","assets","p","training","budget","headlessAsns","firewallScript","recommendations","lines","protective"],"mappings":"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","file":"firewall.js","sourcesContent":["/**\n * Recommend Vercel WAF rules from observed agent traffic.\n *\n * This generates *proposals*, never live changes. Every recommendation comes out\n * with `action: 'log'`, because a firewall rule's blast radius is unpredictable\n * until real traffic hits it and a bad `deny` takes out real users or your SEO.\n * Vercel's own guidance is log → review → preview → production; the `eventual`\n * field records where a rule is meant to end up, and `cli` emits the command for\n * the *current* stage only.\n *\n * Two hard rules, both from measurement rather than taste:\n *\n * 1. Retrieval agents and search crawlers are never proposed for blocking.\n * 60% of AI traffic on one production site is retrieval — a person asked a\n * question and an assistant went to read the page. Blocking that is\n * blocking your own distribution. The recommender emits a `bypass` rule to\n * protect them *first*, so later rules cannot catch them.\n *\n * 2. Training crawlers get rate limits, not denials, by default. The point is\n * to bound cost, not to disappear from corpora.\n *\n * Only abuse gets a denial: an identity that failed cryptographic or IP\n * verification, or a single address behaving like a scraper.\n */\n\nimport type { AgentIntent } from './policy.js'\n\n/** A Vercel WAF condition. Mirrors the CLI's `--condition` JSON. */\nexport interface FirewallCondition {\n type:\n | 'user_agent'\n | 'ip_address'\n | 'geo_as_number'\n | 'geo_country'\n | 'path'\n | 'method'\n | 'environment'\n | 'ja4_digest'\n op: 'eq' | 'neq' | 'sub' | 'pre' | 'suf' | 're' | 'inc' | 'ninc' | 'gt' | 'gte'\n value?: string | number | Array<string | number>\n key?: string\n neg?: boolean\n}\n\nexport type FirewallAction = 'log' | 'deny' | 'challenge' | 'bypass' | 'rate_limit'\n\nexport interface RateLimitSpec {\n /** Seconds, 10–3600. */\n window: number\n /** Max requests per window. */\n requests: number\n /** What happens on breach. */\n action: 'rate_limit' | 'deny' | 'challenge' | 'log'\n keys: Array<'ip' | 'ja4'>\n}\n\nexport interface FirewallRecommendation {\n name: string\n /** Why this rule is proposed, in one sentence. */\n rationale: string\n /** The measurement behind it. Never propose a rule without evidence. */\n evidence: string\n /** OR of ANDs: outer array is groups, inner is conditions within a group. */\n groups: FirewallCondition[][]\n /** Always `'log'` or `'bypass'` — see the module note. */\n action: FirewallAction\n /** Where this rule is intended to end up after review. */\n eventual: FirewallAction\n rateLimit?: RateLimitSpec\n /** How likely this is to catch traffic you wanted. */\n risk: 'low' | 'medium' | 'high'\n /** What could go wrong, when it is not obvious. */\n caveat?: string\n /** Ready-to-run CLI for the *current* stage. */\n cli: string\n /** Equivalent `--json` payload. */\n json: unknown\n}\n\n/** One aggregated slice of observed traffic. */\nexport interface TrafficObservation {\n userAgent: string\n botName: string\n intent: AgentIntent\n requests: number\n ip?: string\n /** Autonomous system number, if you resolved one. */\n asn?: number\n /** Distinct paths this slice touched — a scraper sweeps, a reader does not. */\n distinctPaths?: number\n /**\n * Seconds between this slice's first and last request. Combined with\n * `requests` this gives a rate, which is the signal that separates a scraper\n * from a reader when volume alone does not: on one production site the\n * heaviest single address managed only 100 requests a day — far under any\n * sane abuse threshold — but fetched 16 distinct pages in one second.\n */\n spanSeconds?: number\n /** Verification verdict, if you ran one. */\n verification?: 'verified' | 'spoofed' | 'unverifiable' | 'not-claimed'\n country?: string\n}\n\nexport interface RecommendOptions {\n /**\n * Requests-per-slice above which a single IP is considered abusive. Defaults\n * to 10x the median across observations, floored at 500.\n */\n abuseThreshold?: number\n /** Rate-limit budget proposed for training crawlers. Defaults to 600/hour. */\n trainingBudget?: { window: number; requests: number }\n /** Skip the protective bypass rule. Rarely a good idea. */\n omitProtectiveBypass?: boolean\n /**\n * Burst budget for the distributed-scraper rule, in *page* requests. Defaults\n * to 30 per 60s, which no human reaches once static assets are excluded.\n */\n burstBudget?: { window: number; requests: number }\n /**\n * How many distinct low-volume addresses must look alike before they are\n * treated as one coordinated pool. Defaults to 5.\n */\n minPoolSize?: number\n /**\n * Path prefixes and extensions the burst rule must not count, because the WAF\n * sees every asset request while your middleware probably does not. Defaults\n * to Next.js internals and the usual static extensions.\n */\n assetExclusions?: { prefixes: readonly string[]; extensions: readonly string[] }\n}\n\nconst DEFAULT_ASSET_EXCLUSIONS = {\n prefixes: ['/_next/'],\n extensions: [\n 'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico',\n 'woff', 'woff2', 'ttf', 'otf', 'map'\n ]\n} as const\n\n/* -------------------------------------------------------------------------- */\n\nfunction shellQuote(json: unknown): string {\n return `'${JSON.stringify(json).replace(/'/g, `'\\\\''`)}'`\n}\n\nfunction toCli(r: Omit<FirewallRecommendation, 'cli' | 'json'>): string {\n const parts = [`vercel firewall rules add ${JSON.stringify(r.name)}`]\n r.groups.forEach((group, i) => {\n if (i > 0) parts.push(' --or')\n for (const c of group) parts.push(` --condition ${shellQuote(c)}`)\n })\n parts.push(` --action ${r.action}`)\n if (r.action === 'rate_limit' && r.rateLimit) {\n parts.push(` --rate-limit-window ${r.rateLimit.window}`)\n parts.push(` --rate-limit-requests ${r.rateLimit.requests}`)\n parts.push(` --rate-limit-action ${r.rateLimit.action}`)\n for (const k of r.rateLimit.keys) parts.push(` --rate-limit-keys ${k}`)\n }\n parts.push(' --yes')\n return parts.join(' \\\\\\n')\n}\n\nfunction toJson(r: Omit<FirewallRecommendation, 'cli' | 'json'>): unknown {\n return {\n name: r.name,\n conditionGroup: r.groups.map((conditions) => ({ conditions })),\n action: { mitigate: { action: r.action } }\n }\n}\n\nfunction finish(r: Omit<FirewallRecommendation, 'cli' | 'json'>): FirewallRecommendation {\n return { ...r, cli: toCli(r), json: toJson(r) }\n}\n\nfunction median(ns: number[]): number {\n if (!ns.length) return 0\n const s = [...ns].sort((a, b) => a - b)\n const mid = Math.floor(s.length / 2)\n return s.length % 2 ? s[mid]! : (s[mid - 1]! + s[mid]!) / 2\n}\n\n/* -------------------------------------------------------------------------- */\n\n/**\n * Turn observations into staged WAF proposals.\n *\n * @example\n * ```ts\n * const rules = recommendFirewallRules(observations)\n * for (const r of rules) {\n * console.log(`# ${r.name} — ${r.rationale}`)\n * console.log(`# evidence: ${r.evidence}`)\n * console.log(r.cli)\n * }\n * ```\n */\nexport function recommendFirewallRules(\n observations: readonly TrafficObservation[],\n opts: RecommendOptions = {}\n): FirewallRecommendation[] {\n const out: FirewallRecommendation[] = []\n\n /* 1. Protect the traffic you want, first and above everything else. --------\n Rules are evaluated top to bottom, so this has to be rule #1 or a later\n user-agent rule will swallow the agents that bring you readers. */\n if (!opts.omitProtectiveBypass) {\n const wanted = observations.filter((o) => o.intent === 'retrieval' || o.intent === 'search')\n const requests = wanted.reduce((n, o) => n + o.requests, 0)\n const names = [...new Set(wanted.map((o) => o.botName))]\n out.push(\n finish({\n name: 'Allow retrieval and search agents',\n rationale:\n 'Retrieval agents and search crawlers must never be caught by the rules below — they bring readers and rankings.',\n evidence: requests\n ? `${requests.toLocaleString('en-US')} observed requests across ${names.length} vendors (${names.slice(0, 6).join(', ')})`\n : 'no retrieval or search traffic observed yet; installed pre-emptively',\n groups: [\n [\n {\n type: 'user_agent',\n op: 'inc',\n value: [\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'Perplexity-User',\n 'PerplexityBot',\n 'Googlebot',\n 'bingbot',\n 'DuckDuckBot',\n 'Applebot'\n ]\n }\n ]\n ],\n action: 'bypass',\n eventual: 'bypass',\n risk: 'low',\n caveat:\n 'Place this rule first (`vercel firewall rules reorder ... --first`). A user-agent allowlist is spoofable, so pair with verification in middleware rather than relying on it for security — its job here is to stop your own rules misfiring.'\n })\n )\n }\n\n /* 1b. Link unfurlers, protected separately from the agents above. ----------\n Kept out of the rule above deliberately. Those tokens belong to vendors who\n publish IP ranges and increasingly sign their requests; `facebookexternalhit`\n and friends do neither and are among the most-forged strings on the web. A\n `bypass` skips every managed ruleset too, so folding them into a rule\n labelled `low` risk would understate what it hands out. Same protection,\n honest label, and an operator can decline this one on its own. */\n const preview = observations.filter((o) => o.intent === 'preview')\n if (!opts.omitProtectiveBypass && preview.length) {\n const requests = preview.reduce((n, o) => n + o.requests, 0)\n const names = [...new Set(preview.map((o) => o.botName))]\n out.push(\n finish({\n name: 'Allow link unfurlers',\n rationale:\n 'Someone pasted your URL into a conversation and the platform fetched it to render a card. Blocking it makes your links look broken wherever they are shared.',\n evidence: `${requests.toLocaleString('en-US')} requests across ${names.length} platform${names.length === 1 ? '' : 's'} (${names.slice(0, 6).join(', ')})`,\n groups: [\n [\n {\n type: 'user_agent',\n op: 'inc',\n value: [\n 'facebookexternalhit',\n 'Twitterbot',\n 'LinkedInBot',\n 'Slackbot',\n 'Discordbot',\n 'TelegramBot',\n 'WhatsApp',\n 'redditbot'\n ]\n }\n ]\n ],\n action: 'bypass',\n eventual: 'bypass',\n risk: 'medium',\n caveat:\n 'These user agents are trivially spoofed and none of these platforms publish verifiable IP ranges, so this rule hands a bypass to anyone who sets the header. Scope it to your public content paths and never to anything authenticated or expensive.'\n })\n )\n }\n\n /* 2. Failed verification — the only class that earns a denial. ------------- */\n const spoofed = observations.filter((o) => o.verification === 'spoofed')\n const spoofedIps = [...new Set(spoofed.map((o) => o.ip).filter((v): v is string => !!v))]\n if (spoofedIps.length) {\n const requests = spoofed.reduce((n, o) => n + o.requests, 0)\n const vendors = [...new Set(spoofed.map((o) => o.botName))]\n out.push(\n finish({\n name: 'Deny impersonated crawler identities',\n rationale:\n 'These addresses claimed a crawler identity that failed verification against the vendor’s published ranges or signature.',\n evidence: `${requests.toLocaleString('en-US')} requests from ${spoofedIps.length} address${spoofedIps.length === 1 ? '' : 'es'} impersonating ${vendors.join(', ')}`,\n groups: [[{ type: 'ip_address', op: 'inc', value: spoofedIps }]],\n action: 'log',\n eventual: 'deny',\n risk: 'low',\n caveat:\n 'Verification failure is strong evidence, but confirm your edge controls x-forwarded-for before enforcing — behind a proxy that forwards a client-supplied header the verdict is worthless.'\n })\n )\n }\n\n /* 3. Single addresses behaving like scrapers. ------------------------------ */\n const perIp = observations.filter((o) => o.ip && o.verification !== 'verified')\n const threshold =\n opts.abuseThreshold ?? Math.max(500, Math.round(median(perIp.map((o) => o.requests)) * 10))\n const heavy = perIp\n .filter((o) => o.requests >= threshold)\n .sort((a, b) => b.requests - a.requests)\n .slice(0, 50)\n if (heavy.length) {\n const sweeping = heavy.filter((o) => (o.distinctPaths ?? 0) > 100)\n out.push(\n finish({\n name: 'Rate limit high-volume unverified addresses',\n rationale:\n 'A single address making orders of magnitude more requests than the median, with no verified identity.',\n evidence:\n `${heavy.length} address${heavy.length === 1 ? '' : 'es'} above ${threshold.toLocaleString('en-US')} requests` +\n (sweeping.length\n ? `; ${sweeping.length} swept >100 distinct paths, which reads as a scrape rather than a reader`\n : ''),\n groups: [[{ type: 'ip_address', op: 'inc', value: heavy.map((o) => o.ip!) }]],\n action: 'log',\n eventual: 'rate_limit',\n rateLimit: { window: 60, requests: 60, action: 'rate_limit', keys: ['ip'] },\n risk: 'medium',\n caveat:\n 'Shared egress means one address can front many real users — a corporate NAT, a mobile carrier, or a VPN. Review the dashboard before enforcing.'\n })\n )\n }\n\n /* 3b. Many small addresses behaving as one. --------------------------------\n Rule 3 asks \"is any single address abusive?\" and a rotating proxy pool is\n built precisely so the answer is no. Observed on one production site: 34\n addresses across 11 countries, one user agent each, none above 100 requests\n a day — every one of them invisible to a per-address threshold, while\n collectively sweeping the site and inflating its analytics.\n\n Volume cannot separate that from real readers, so this keys on rate. It is\n safe to do so only because the condition excludes static assets: a person\n loading one page fires dozens of asset requests that the WAF counts and\n your middleware does not, and a naive limit on `Mozilla` would throttle\n real users on their first page view. */\n const pool = observations.filter(\n (o) =>\n o.ip !== undefined &&\n !heavy.some((h) => h.ip === o.ip) &&\n o.verification !== 'verified' &&\n (o.intent === 'unknown' || o.intent === 'tooling') &&\n /Mozilla|Chrome|Safari/i.test(o.userAgent)\n )\n const minPool = opts.minPoolSize ?? 5\n const poolIps = [...new Set(pool.map((o) => o.ip!))]\n if (poolIps.length >= minPool) {\n const burst = opts.burstBudget ?? { window: 60, requests: 30 }\n const requests = pool.reduce((n, o) => n + o.requests, 0)\n const countries = [...new Set(pool.map((o) => o.country).filter(Boolean))]\n\n // Only slices that actually carry timing can be shown to exceed the budget.\n // Say how many were measurable rather than implying the rest were clean.\n const timed = pool.filter((o) => o.spanSeconds !== undefined && o.spanSeconds > 0)\n const overBudget = timed.filter(\n (o) => o.requests / o.spanSeconds! > burst.requests / burst.window\n )\n const peak = timed.length\n ? Math.max(...timed.map((o) => (o.requests / o.spanSeconds!) * 60))\n : 0\n\n const assets = opts.assetExclusions ?? DEFAULT_ASSET_EXCLUSIONS\n const conditions: FirewallCondition[] = [\n { type: 'user_agent', op: 'sub', value: 'Mozilla' },\n ...assets.prefixes.map(\n (p): FirewallCondition => ({ type: 'path', op: 'pre', value: p, neg: true })\n ),\n {\n type: 'path',\n op: 're',\n value: `\\\\.(?:${assets.extensions.join('|')})$`,\n neg: true\n }\n ]\n\n out.push(\n finish({\n name: 'Burst limit page navigations',\n rationale:\n 'A pool of addresses each too small to trip a per-address threshold, together behaving like one scraper. Rate is the only signal that separates them from real readers.',\n evidence:\n `${poolIps.length} addresses across ${countries.length} countries, ${requests.toLocaleString('en-US')} requests, none individually above the abuse threshold` +\n (timed.length\n ? `; ${overBudget.length} of ${timed.length} measurable slices exceeded ${burst.requests} requests/${burst.window}s, peaking at ${Math.round(peak).toLocaleString('en-US')}/min`\n : '; no timing supplied, so the burst budget is a default rather than a measurement'),\n groups: [conditions],\n action: 'log',\n eventual: 'rate_limit',\n rateLimit: { window: burst.window, requests: burst.requests, action: 'rate_limit', keys: ['ip'] },\n risk: 'medium',\n caveat:\n 'The asset exclusions are what make this safe — the WAF counts every request, including the dozens of assets behind a single page view, so verify they match how your app actually serves static files before enforcing. Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx.'\n })\n )\n }\n\n /* 4. Training crawlers: bound the cost, do not disappear from corpora. ----- */\n const training = observations.filter((o) => o.intent === 'training')\n if (training.length) {\n const requests = training.reduce((n, o) => n + o.requests, 0)\n const vendors = [...new Set(training.map((o) => o.botName))]\n const budget = opts.trainingBudget ?? { window: 3600, requests: 600 }\n out.push(\n finish({\n name: 'Rate limit training crawlers',\n rationale:\n 'Bound what bulk corpus collection costs you without removing yourself from training sets.',\n evidence: `${requests.toLocaleString('en-US')} training requests from ${vendors.length} vendors (${vendors.slice(0, 6).join(', ')})`,\n groups: [\n [\n {\n type: 'user_agent',\n op: 'inc',\n value: ['GPTBot', 'ClaudeBot', 'CCBot', 'Bytespider', 'Amazonbot', 'meta-externalagent']\n }\n ]\n ],\n action: 'log',\n eventual: 'rate_limit',\n rateLimit: { window: budget.window, requests: budget.requests, action: 'rate_limit', keys: ['ip'] },\n risk: 'medium',\n caveat:\n 'Denying these removes you from future training sets, which may be exactly wrong for discoverability. Rate limit rather than deny unless you have decided otherwise. Note Vercel counters are per region, so N regions can collectively exceed the limit by ~Nx.'\n })\n )\n }\n\n /* 5. Datacenter ASNs presenting browser user agents. ---------------------- */\n const headlessAsns = [\n ...new Set(\n observations\n .filter((o) => o.asn !== undefined && /Mozilla|Chrome|Safari/i.test(o.userAgent))\n .filter((o) => o.verification !== 'verified')\n .map((o) => o.asn!)\n )\n ]\n if (headlessAsns.length) {\n const slices = observations.filter((o) => o.asn !== undefined && headlessAsns.includes(o.asn))\n const requests = slices.reduce((n, o) => n + o.requests, 0)\n out.push(\n finish({\n name: 'Challenge browser user agents from datacenter networks',\n rationale:\n 'A browser user agent arriving from a hosting network is automation wearing a costume — real browsers come from consumer ISPs.',\n evidence: `${requests.toLocaleString('en-US')} requests across ${headlessAsns.length} datacenter AS numbers`,\n groups: [\n [\n { type: 'geo_as_number', op: 'inc', value: headlessAsns },\n { type: 'user_agent', op: 'sub', value: 'Mozilla' }\n ]\n ],\n action: 'log',\n eventual: 'challenge',\n risk: 'high',\n caveat:\n 'Highest false-positive risk here. Corporate VPNs, privacy relays and some mobile carriers egress from hosting ASNs, and a challenge page breaks API clients and link unfurlers outright. Keep this in log mode for a full week before considering enforcement.'\n })\n )\n }\n\n return out\n}\n\n/** Render recommendations as a runnable, commented shell script. */\nexport function firewallScript(recommendations: readonly FirewallRecommendation[]): string {\n const lines = [\n '#!/usr/bin/env bash',\n '# Vercel WAF proposals generated from observed agent traffic.',\n '#',\n '# Every rule starts in LOG mode and blocks nothing. Vercel stages rule',\n '# changes as drafts, so nothing is live until you run:',\n '#',\n '# vercel firewall diff # review',\n '# vercel firewall publish --yes # go live',\n '#',\n '# Review each rule in the dashboard before promoting it to its eventual',\n '# action. Rules evaluate top to bottom, so keep the bypass rule first.',\n 'set -euo pipefail',\n ''\n ]\n recommendations.forEach((r, i) => {\n lines.push(`# ${i + 1}. ${r.name}`)\n lines.push(`# why: ${r.rationale}`)\n lines.push(`# evidence: ${r.evidence}`)\n lines.push(`# risk: ${r.risk} — eventual action: ${r.eventual}`)\n if (r.caveat) lines.push(`# caveat: ${r.caveat}`)\n lines.push(r.cli)\n lines.push('')\n })\n const protective = recommendations.filter((r) => r.action === 'bypass')\n if (protective.length) {\n lines.push('# Keep the protective allow rules at the top of the evaluation order.')\n // Reversed: each --first pushes to the top, so applying them back-to-front\n // leaves the array's own order intact once all of them have run.\n for (const r of [...protective].reverse()) {\n lines.push(`vercel firewall rules reorder ${JSON.stringify(r.name)} --first --yes`)\n }\n }\n if (recommendations.length) {\n lines.push('')\n lines.push('vercel firewall diff')\n lines.push('echo \"Review above, then: vercel firewall publish --yes\"')\n }\n return lines.join('\\n')\n}\n"]}
|