@osqd/bothandlerjs 0.4.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.
Files changed (44) hide show
  1. package/dist/adapters/index.cjs +3 -0
  2. package/dist/adapters/index.cjs.map +1 -1
  3. package/dist/adapters/index.js +3 -0
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/cli.cjs +634 -25
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +634 -25
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +10 -0
  10. package/dist/core.d.ts +15 -0
  11. package/dist/corpus/index.cjs +84 -0
  12. package/dist/corpus/index.cjs.map +1 -1
  13. package/dist/corpus/index.js +84 -0
  14. package/dist/corpus/index.js.map +1 -1
  15. package/dist/corpus/schema.d.ts +8 -0
  16. package/dist/crawler-ranges.d.ts +31 -0
  17. package/dist/dashboard/client/feed.d.ts +16 -0
  18. package/dist/dashboard/client/format.d.ts +18 -0
  19. package/dist/dashboard/client/pager.d.ts +32 -0
  20. package/dist/dashboard/client/store.d.ts +60 -0
  21. package/dist/dashboard/client.generated.d.ts +1 -1
  22. package/dist/detectors/crawler-verification.d.ts +34 -1
  23. package/dist/detectors/id-enumeration.d.ts +31 -0
  24. package/dist/detectors/index.d.ts +12 -1
  25. package/dist/detectors/known-bots.d.ts +60 -1
  26. package/dist/detectors/parameter-sweep.d.ts +39 -0
  27. package/dist/detectors/probe-volume.d.ts +26 -0
  28. package/dist/detectors/transport-coherence.d.ts +31 -0
  29. package/dist/element/index.cjs +349 -40
  30. package/dist/element/index.cjs.map +1 -1
  31. package/dist/element/index.js +349 -40
  32. package/dist/element/index.js.map +1 -1
  33. package/dist/index.cjs +576 -31
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +571 -31
  37. package/dist/index.js.map +1 -1
  38. package/dist/state.d.ts +76 -1
  39. package/dist/types.d.ts +36 -0
  40. package/docs/course/05-detectors.md +4 -0
  41. package/docs/detection/detectors.md +122 -0
  42. package/docs/detection/signatures.md +44 -0
  43. package/docs/operations/dashboard.md +26 -0
  44. package/package.json +1 -1
package/dist/config.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { PresetName } from "./policy/presets.js";
3
3
  import type { Clock } from "./internal/clock.js";
4
4
  import type { DnsResolver } from "./internal/dns.js";
5
5
  import type { Detector } from "./detectors/types.js";
6
+ import type { CrawlerVerificationOptions } from "./detectors/crawler-verification.js";
6
7
  import type { BotSignature } from "./detectors/known-bots.js";
7
8
  import type { ActionParams, FalsePositivePolicy, Rule } from "./policy/types.js";
8
9
  import type { CustomHandler } from "./actions/types.js";
@@ -60,6 +61,15 @@ export interface BotHandlerConfig {
60
61
  detectors?: readonly Detector[];
61
62
  /** Appended to the built-in set. Ignored when `detectors` is given. */
62
63
  extraDetectors?: readonly Detector[];
64
+ /**
65
+ * How claimed crawler identities are confirmed or refuted.
66
+ *
67
+ * Chiefly `verifiers`: your own answer to "is this really Googlebot", for the many
68
+ * identities that publish no proof this library can check on its own. Also the two
69
+ * strictness flags, which were documented on the detector and reachable only by
70
+ * rebuilding the whole detector list.
71
+ */
72
+ crawlerVerification?: CrawlerVerificationOptions;
63
73
  /** A named starting policy. Combined with `rules`, which are evaluated first. */
64
74
  preset?: PresetName;
65
75
  /** Your own rules, evaluated before any preset's. */
package/dist/core.d.ts CHANGED
@@ -311,6 +311,21 @@ export declare class BotHandler {
311
311
  * `isHuman`, and it expires the same way a solved challenge does.
312
312
  */
313
313
  clearActor(key: string, forMs: number, context?: ChangeContext): void;
314
+ /**
315
+ * Tells the engine what the application answered.
316
+ *
317
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
318
+ * the response exists — that is what makes it useful, since it can shape the response —
319
+ * and so the status is knowledge only the application holds. Handed back, it closes the
320
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
321
+ * looking for something rather than reading anything, and no amount of header analysis
322
+ * shows that.
323
+ *
324
+ * Optional, and silent when the actor has already been forgotten. Nothing about
325
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
326
+ * nothing else. The bundled Node adapter wires it up for you.
327
+ */
328
+ recordOutcome(facts: RequestFacts, status: number): void;
314
329
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
315
330
  updateCrawlerRanges(signatureId: string, entries: readonly string[], context?: ChangeContext): void;
316
331
  /**
@@ -1208,6 +1208,89 @@ var AI_CRAWLER_CASES = [
1208
1208
  // src/corpus/adversarial.ts
1209
1209
  var CHROME_UA = userAgentOf("chromeWindows");
1210
1210
  var ADVERSARIAL_CASES = [
1211
+ bot({
1212
+ id: "id-harvest-contiguous",
1213
+ title: "Every profile id in order, with a copied browser header set",
1214
+ audience: "hostile",
1215
+ category: "scraping",
1216
+ 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.",
1217
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
1218
+ expect: {
1219
+ // One `moderate` signal against a flawless header set, like the others here. What
1220
+ // changed is that the walk is now *visible* — before this detector it was scored
1221
+ // identically to a hundred and twenty scattered ids and to ordinary article paths.
1222
+ verdict: "unknown",
1223
+ detectors: ["id-enumeration"]
1224
+ },
1225
+ 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."
1226
+ }),
1227
+ bot({
1228
+ id: "wordlist-scan-mostly-misses",
1229
+ title: "A wordlist walked with a copied browser header set, almost all of it missing",
1230
+ audience: "hostile",
1231
+ category: "scanning",
1232
+ 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.",
1233
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
1234
+ expect: {
1235
+ // One `moderate` signal against an otherwise flawless header set does not cross the
1236
+ // line, and it should not: a site that has just moved its URLs produces the same
1237
+ // shape from ordinary readers. Raising the ceiling so this case reads better would
1238
+ // be tuning the detector to the test rather than to the traffic.
1239
+ verdict: "unknown",
1240
+ detectors: ["probe-volume"]
1241
+ },
1242
+ 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."
1243
+ }),
1244
+ bot({
1245
+ id: "browser-claim-over-http-1-0",
1246
+ title: "A perfect Chrome header set, arriving over HTTP/1.0",
1247
+ audience: "hostile",
1248
+ category: "impersonation",
1249
+ 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.",
1250
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
1251
+ expect: {
1252
+ // Contributes rather than concludes. On its own, against an otherwise flawless
1253
+ // header set, one `moderate` signal does not reach the threshold — and it should
1254
+ // not, because an intermediary can cause this. Beside anything sharper it does.
1255
+ verdict: "unknown",
1256
+ detectors: ["transport-coherence"]
1257
+ },
1258
+ 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."
1259
+ }),
1260
+ bot({
1261
+ id: "head-only-visit",
1262
+ title: "A visit made entirely of HEAD, claiming a browser",
1263
+ audience: "unwanted-bot",
1264
+ category: "scraping",
1265
+ 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.",
1266
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
1267
+ expect: {
1268
+ // As above: a shape worth reporting, not worth concluding from alone.
1269
+ verdict: "unknown",
1270
+ detectors: ["transport-coherence"]
1271
+ },
1272
+ 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`."
1273
+ }),
1274
+ bot({
1275
+ id: "catalogue-sweep-by-page",
1276
+ title: "A catalogue taken a page at a time, with the path never changing",
1277
+ audience: "unwanted-bot",
1278
+ category: "scraping",
1279
+ 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.",
1280
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
1281
+ expect: {
1282
+ // Not proven, and not even suspected at this pace. Said plainly because it is true:
1283
+ // headers this clean leave only behaviour, behaviour is weak by construction, and a
1284
+ // collector polite enough to space its requests stays under the line. What changed
1285
+ // is that it no longer scores *lower* than the identical crawl expressed as distinct
1286
+ // paths — measured at a faster pace before this detector existed, the two differed by
1287
+ // seven points and only the path version crossed; they now score the same at every
1288
+ // volume tried.
1289
+ verdict: "unknown",
1290
+ detectors: ["parameter-sweep"]
1291
+ },
1292
+ 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."
1293
+ }),
1211
1294
  // ---------------------------------------------------------------------------
1212
1295
  // Forged identities. The narrow case where a lie is provable.
1213
1296
  // ---------------------------------------------------------------------------
@@ -4033,6 +4116,7 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
4033
4116
  const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
4034
4117
  const facts = toFacts(withClearance, fallbackIp, clock.now());
4035
4118
  const { assessment, decision, outcome } = await handler.handle(facts);
4119
+ if (request.status !== void 0) handler.recordOutcome(facts, request.status);
4036
4120
  requests.push({ assessment, decision, outcome });
4037
4121
  }
4038
4122
  const final = requests[requests.length - 1];