@osqd/bothandlerjs 0.5.0 → 0.6.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.
@@ -1145,6 +1145,89 @@ var AI_CRAWLER_CASES = [
1145
1145
  // src/corpus/adversarial.ts
1146
1146
  var CHROME_UA = userAgentOf("chromeWindows");
1147
1147
  var ADVERSARIAL_CASES = [
1148
+ bot({
1149
+ id: "id-harvest-contiguous",
1150
+ title: "Every profile id in order, with a copied browser header set",
1151
+ audience: "hostile",
1152
+ category: "scraping",
1153
+ provenance: "Harvesting by identifier rather than by link: the shape of an IDOR sweep and of profile collection. Distinct-path breadth reads it as somebody who visited a lot of pages, which is also what it reads when a person works through a documentation site.",
1154
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
1155
+ expect: {
1156
+ // One `moderate` signal against a flawless header set, like the others here. What
1157
+ // changed is that the walk is now *visible* — before this detector it was scored
1158
+ // identically to a hundred and twenty scattered ids and to ordinary article paths.
1159
+ verdict: "unknown",
1160
+ detectors: ["id-enumeration"]
1161
+ },
1162
+ notes: "What separates this from reading is not which ids were asked for but that they cover a range: people arrive at ids through links, and links do not densely enumerate an integer interval. Held at `moderate` because products in one category often carry consecutive ids, so somebody browsing a catalogue makes a smaller version of this shape."
1163
+ }),
1164
+ bot({
1165
+ id: "wordlist-scan-mostly-misses",
1166
+ title: "A wordlist walked with a copied browser header set, almost all of it missing",
1167
+ audience: "hostile",
1168
+ category: "scanning",
1169
+ provenance: "The oldest tell there is, and the one this library could not see: it decides before the response exists, which is what lets it shape the response and also what hides the status from it. A person browsing does not generate thirty misses in a row; a wordlist does almost nothing else.",
1170
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
1171
+ expect: {
1172
+ // One `moderate` signal against an otherwise flawless header set does not cross the
1173
+ // line, and it should not: a site that has just moved its URLs produces the same
1174
+ // shape from ordinary readers. Raising the ceiling so this case reads better would
1175
+ // be tuning the detector to the test rather than to the traffic.
1176
+ verdict: "unknown",
1177
+ detectors: ["probe-volume"]
1178
+ },
1179
+ notes: "Only counts 404 and 410. A 403 is usually this library's own doing, and counting it would let a rule that challenges an actor manufacture the evidence for having challenged it; a 500 is the site's problem and says nothing about the client. Capped at `moderate` because a site that has just moved its URLs produces this from perfectly ordinary readers."
1180
+ }),
1181
+ bot({
1182
+ id: "browser-claim-over-http-1-0",
1183
+ title: "A perfect Chrome header set, arriving over HTTP/1.0",
1184
+ audience: "hostile",
1185
+ category: "impersonation",
1186
+ provenance: "Most tooling lets you set headers and does not let you choose an HTTP version, so the transport is the half a copied header set does not cover. No shipping browser has offered HTTP/1.0 to a server in well over a decade.",
1187
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
1188
+ expect: {
1189
+ // Contributes rather than concludes. On its own, against an otherwise flawless
1190
+ // header set, one `moderate` signal does not reach the threshold — and it should
1191
+ // not, because an intermediary can cause this. Beside anything sharper it does.
1192
+ verdict: "unknown",
1193
+ detectors: ["transport-coherence"]
1194
+ },
1195
+ notes: "Capped at `moderate` because it is not always the client's doing: a few older load balancers speak HTTP/1.0 to the origin, and behind one of those every request looks like this. That is what `transportCoherenceDetector({ legacyHttp: false })` is for, and why this may never deny anybody on its own."
1196
+ }),
1197
+ bot({
1198
+ id: "head-only-visit",
1199
+ title: "A visit made entirely of HEAD, claiming a browser",
1200
+ audience: "unwanted-bot",
1201
+ category: "scraping",
1202
+ provenance: "Checking what exists without reading any of it: link checkers, availability monitors and inventory watchers all do this, and a browser navigating never does.",
1203
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
1204
+ expect: {
1205
+ // As above: a shape worth reporting, not worth concluding from alone.
1206
+ verdict: "unknown",
1207
+ detectors: ["transport-coherence"]
1208
+ },
1209
+ notes: "One HEAD is a browser checking a link it is about to follow, or a cache revalidating; the shape only means anything across a visit, which is why it is counted on the actor rather than on the request. A link checker is a real and mostly harmless thing to be, so this stays `moderate`."
1210
+ }),
1211
+ bot({
1212
+ id: "catalogue-sweep-by-page",
1213
+ title: "A catalogue taken a page at a time, with the path never changing",
1214
+ audience: "unwanted-bot",
1215
+ category: "scraping",
1216
+ provenance: "How a catalogue is actually taken. The collector copies a browser's headers exactly and walks ?page=1..N, which leaves the path constant \u2014 so distinct-path breadth reads it as somebody rereading one page rather than as enumeration.",
1217
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
1218
+ expect: {
1219
+ // Not proven, and not even suspected at this pace. Said plainly because it is true:
1220
+ // headers this clean leave only behaviour, behaviour is weak by construction, and a
1221
+ // collector polite enough to space its requests stays under the line. What changed
1222
+ // is that it no longer scores *lower* than the identical crawl expressed as distinct
1223
+ // paths — measured at a faster pace before this detector existed, the two differed by
1224
+ // seven points and only the path version crossed; they now score the same at every
1225
+ // volume tried.
1226
+ verdict: "unknown",
1227
+ detectors: ["parameter-sweep"]
1228
+ },
1229
+ notes: "The counterpart to crawl-breadth rather than a replacement for it: breadth counts paths, this counts what is hung on them. Both stay weak, and both are worth having because a collector picks one shape or the other and nothing says which. Neither is a reason to deny anybody on its own."
1230
+ }),
1148
1231
  // ---------------------------------------------------------------------------
1149
1232
  // Forged identities. The narrow case where a lie is provable.
1150
1233
  // ---------------------------------------------------------------------------
@@ -3970,6 +4053,7 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
3970
4053
  const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
3971
4054
  const facts = toFacts(withClearance, fallbackIp, clock.now());
3972
4055
  const { assessment, decision, outcome } = await handler.handle(facts);
4056
+ if (request.status !== void 0) handler.recordOutcome(facts, request.status);
3973
4057
  requests.push({ assessment, decision, outcome });
3974
4058
  }
3975
4059
  const final = requests[requests.length - 1];