Skip to content

fix!: harden the library and cut the root bundle 65% (0.12.0) - #21

Merged
Gdewilde merged 2 commits into
mainfrom
feat/hardening-0.12
Aug 2, 2026
Merged

fix!: harden the library and cut the root bundle 65% (0.12.0)#21
Gdewilde merged 2 commits into
mainfrom
feat/hardening-0.12

Conversation

@Gdewilde

@Gdewilde Gdewilde commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Addresses all eleven review findings, hardens the suite, and cuts the root bundle by 65%.

🔴 The identifier was reversible

distinctId was an unsalted 32-bit djb2 over ip:userAgent. The user agent ships in plaintext on the same event, so an attacker held half the preimage and only had to search the IP space:

distinctId on the event: anon_761389ea
recovered: 109.135.42.185 | tried 159,853,242 candidates in 74.8s

Single-threaded, over one /8 slice. That is pseudonymisation, not anonymisation — it does not survive GDPR Recital 26, which is the exact distinction the "no consent banner" positioning rests on.

It matters concretely: skipBrowsers admits headless traffic, and claude-code events arrive from developers' home addresses — 4,486 distinct residential IPs in 30 days on one site.

Now HMAC-SHA-256 via Web Crypto, keyed by idSecret / AGENT_ANALYTICS_ID_SECRET, truncated to 64 bits. With no secret configured a random per-instance one is generated, so the safe path is the default; ids then correlate only within an instance, and the library says so once at startup rather than silently.

🟠 High

  • markdownServeDecision returned before stripping .md. A bot following one of the .md links this library itself advertises got the extension back, so callers requested /md/docs/intro.md.md and silently degraded to a pointer document. The AEO path was broken for exactly the agents it courts.
  • Adapters swallowed every non-2xx. A mistyped API key was indistinguishable from success. Now CaptureTransportError, routed to a new onError. Capture still never throws into the response path.
  • Caller properties could clobber computed fieldsis_ai_bot: 'custom' on a request positively identified as ClaudeBot. Spread first now; computed fields win, non-colliding keys unaffected.

🟡 Medium

  • 3s AbortSignal on outbound captures (timeoutMs to change).
  • headless_score / headless_likely fired on 99% of events — omitted now except for browser-shaped UAs, where they actually discriminate.
  • Headless automation was labelled bot_name: 'Browser'79% of one site's agent traffic, hidden behind the obvious bot_name != 'Browser' filter. Now 'Headless'.
  • Range tables compiled at module load for all 412 prefixes even when nothing verified. Lazy per vendor now.

🟢 Low

Removed unreachable cursor|windsurf|cline|aider branches; 64-bit ids (32-bit collided past ~65k pairs); IPv4 matching bucketed by first octet instead of walking all 372 prefixes.

📦 Size: −65%

before after
dist/index.js 27,710 B 9,643 B
gzipped 7,636 B 3,838 B

Verification moved behind @apideck/agent-analytics/verify, so the range tables no longer reach consumers that never verify — confirmed absent from dist/index.js. Minification enabled.

I tried packing the range data into space-delimited strings first. It saved 12% and would have made the weekly refresh diff (#19) unreviewable — a bad trade for a security-relevant list, so the data stays one CIDR per line and the win comes from tree-shaking instead.

🧪 Tests: 216 → 233

Two existing tests were pinning the bugs as intended behaviour and had to be inverted — one asserted the missing .md strip, the other asserted property clobbering as a feature. Worth noting on its own: the suite was actively protecting two defects.

New coverage: error surfacing, abort signals, secret rotation, absence of raw IPs anywhere in the payload, headless field omission, dead-branch detection, and a differential test that checks the bucketed IPv4 matcher against a naive linear scan over every bundled prefix, probing both boundaries and one address either side.

⚠️ Breaking — see the upgrade guide in the README

- void trackVisit(req, { analytics, verifyIdentity: true })
+ import { verifyRequest } from '@apideck/agent-analytics/verify'
+ void trackVisit(req, { analytics, verify: verifyRequest, idSecret: process.env.AGENT_ANALYTICS_ID_SECRET })

Existing distinctId values will not match the new ones — that is unavoidable when fixing a reversible identifier, and correlating old to new would defeat the fix.

#20 (agentPolicy) will need a rebase: it imports verify directly, which now lives behind the subpath.

🤖 Generated with Claude Code

Addresses every finding from the review, plus the two tests that were pinning
bugs as intended behaviour.

CRITICAL — the anonymous identifier was reversible.

  distinctId was an unsalted 32-bit djb2 over `ip:userAgent`. The user agent
  ships in plaintext on the same event, so an attacker held half the preimage
  and only had to search the IPv4 space. Demonstrated: a residential IP
  recovered in 75 seconds, single-threaded, over a /8 slice. That is
  pseudonymisation, not anonymisation, and it does not survive GDPR Recital 26.
  It mattered concretely because skipBrowsers admits headless traffic and
  claude-code events arrive from developers' home addresses — 4,486 distinct
  residential IPs in 30 days on one site.

  Now HMAC-SHA-256 via Web Crypto, keyed by `idSecret` /
  AGENT_ANALYTICS_ID_SECRET, truncated to 64 bits. With no secret configured a
  random per-instance one is generated, so the safe path is the default; ids
  then correlate only within an instance, and we say so once at startup rather
  than silently.

HIGH

- markdownServeDecision returned before stripping `.md`, so a bot following one
  of the `.md` links this library itself advertises got the extension back and
  callers requested `/md/docs/intro.md.md`, silently degrading to a pointer
  document. Stripping now happens once, up front, on every branch.
- Adapters swallowed every non-2xx. A mistyped API key was indistinguishable
  from success. They now throw CaptureTransportError; trackVisit routes it to a
  new `onError` and still never throws into the response path.
- Caller `properties` were spread last and could overwrite bot_name, is_ai_bot
  and path — corrupting the classification they were meant to annotate. Spread
  first; computed fields win.

MEDIUM

- Outbound captures had no timeout. 3s AbortSignal, configurable per adapter.
- headless_score / headless_likely fired on 99% of captured events, reading as
  signal while carrying none. Emitted only for browser-shaped UAs now.
- Headless automation was labelled bot_name 'Browser' — 79% of one site's agent
  traffic, hidden behind the obvious `bot_name != 'Browser'` filter. Now
  'Headless'.
- Range tables compiled at module load for all 412 prefixes even when nothing
  verified. Compiled lazily, per vendor, on first use.

LOW

- Removed unreachable cursor|windsurf|cline|aider branches from the client-side
  agent pattern: those vendors publish no feed, so they exit earlier.
- 32-bit ids collided past ~65k distinct pairs; 64-bit now.
- IPv4 matching walked all 372 prefixes per request. Bucketed by first octet,
  with a separate list for prefixes wider than /8.

SIZE — root entry 27,710 -> 9,643 bytes (-65%), 7,636 -> 3,838 gzipped (-50%).

  Verification moved behind `@apideck/agent-analytics/verify`, so the range
  tables no longer reach consumers that never verify — confirmed absent from
  dist/index.js. Minification enabled. Range data stays one-CIDR-per-line in
  source: packing it saved 12% and would have made the weekly refresh diff
  unreviewable, which is a bad trade for a security-relevant list.

TESTS 216 -> 233.

  Two existing tests asserted the buggy behaviour and had to be inverted: one
  pinned the missing `.md` strip, the other pinned property clobbering as a
  feature. Added coverage for error surfacing, abort signals, secret rotation,
  the absence of raw IPs in the payload, headless field omission, and a
  differential test that checks the bucketed IPv4 matcher against a naive
  linear scan over every bundled prefix with probes either side of each
  boundary.
CI caught this on the matrix: every trackVisit test failed on Node 18 with
'Cannot read properties of undefined', because the new keyed hash reaches for
globalThis.crypto and Node 18 only exposes it behind
--experimental-global-webcrypto. It became a default in Node 19.

Dropping Node 18 rather than adding a node:crypto fallback. A static import of
a Node builtin breaks bundling for the edge runtimes this library primarily
targets, and Node 18 reached end of life in April 2025 — carrying that risk for
a dead runtime is the wrong trade.

Runtimes genuinely lacking Web Crypto now throw a message naming the
requirement instead of dereferencing undefined. CI matrix moves to 20/22/24.
@Gdewilde
Gdewilde merged commit 231c0d2 into main Aug 2, 2026
3 checks passed
@Gdewilde
Gdewilde deleted the feat/hardening-0.12 branch August 2, 2026 08:41
Gdewilde added a commit that referenced this pull request Aug 2, 2026
Rebased onto 0.12. The original version imported verifyRequest directly, which
would have pulled the published IP range tables back into the root bundle and
undone the size work in #21.

agentPolicy now takes the same injected-verifier shape as trackVisit:
`verify` is a function rather than a boolean, supplied from
`@apideck/agent-analytics/verify` by callers who want spoofed identities to
block. Root bundle stays free of range data — verified against dist.
Gdewilde added a commit that referenced this pull request Aug 2, 2026
* feat: agentPolicy() — decide what to do with an agent, by intent

Classification tells you which agent knocked. It doesn't tell you what to do
about it, and the obvious answer — "block the AI bots" — is wrong in a way
that costs real money.

A vendor's bulk crawler and its user-facing fetcher are opposite economics
wearing similar names. GPTBot is corpus collection: you get nothing per fetch.
ChatGPT-User is a person who just asked about you and an assistant going to
read the page on their behalf — that is a distribution channel, and charging
for it means charging for your own marketing. Same for ClaudeBot vs
Claude-User, PerplexityBot vs Perplexity-User.

Measured on 30 days of apideck.com AI traffic, the split is not marginal:

  retrieval  310,882 events  60.1%   <- allow, this is demand
  training   199,013 events  38.5%   <- meter or charge
  unknown      7,579 events   1.5%
  tooling          4 events   0.0%

A blanket bot block would have discarded 60% of that traffic, all of it people
asking questions about the product.

agentPolicy() maps a request to one of allow / meter / charge / block via an
`intent` of retrieval / training / search / tooling / unknown, with a knob per
intent and an allowList that overrides everything. Defaults are deliberately
conservative: meter training, allow everything else.

Notes on the design:

- Intent is an explicit list, not a pattern guess. A `-User` suffix is not the
  marker: OpenAI's ChatGPT-User fetches server-side from Azure while Claude
  Code's Claude-User runs on a developer's laptop. Guessing here is how you
  end up billing your own demand.
- Applebot is `search`; Applebot-Extended is `training`. Same vendor, and the
  distinction is the entire point of Apple publishing two tokens.
- Only 'spoofed' blocks when verify is on. 'unverifiable' must not block, or
  you refuse every vendor without a published feed plus every coding agent
  running on someone's own machine.
- No payment rails, no network calls, no dependencies. This is the policy
  layer; settlement belongs to Stripe's MPP or an x402 SDK, and the library
  should not hold money or inherit PCI scope.

Tests: 216 -> 229.

* refactor(policy): inject the verifier instead of importing it (0.13.0)

Rebased onto 0.12. The original version imported verifyRequest directly, which
would have pulled the published IP range tables back into the root bundle and
undone the size work in #21.

agentPolicy now takes the same injected-verifier shape as trackVisit:
`verify` is a function rather than a boolean, supplied from
`@apideck/agent-analytics/verify` by callers who want spoofed identities to
block. Root bundle stays free of range data — verified against dist.
Gdewilde added a commit that referenced this pull request Aug 2, 2026
The root bundle had grown from 9,643 to 22,497 bytes — undoing most of the 65%
reduction in #21 — because payments, gateway, entitlement and firewall all
exported from the package root. Every consumer paid for them whether or not they
charged anyone, and the firewall recommender in particular is an offline
analysis tool that will never execute in middleware.

  dist/index.js     22,497 -> 11,608 B   (8,695 -> 4,546 gzipped)
  dist/pay.js                 10,861 B   opt-in
  dist/firewall.js             6,821 B   opt-in

Entry points now:

  @apideck/agent-analytics            detection, classification, agentPolicy, trackVisit
  @apideck/agent-analytics/verify     Web Bot Auth + published IP range tables
  @apideck/agent-analytics/payments   402 challenges, gateways, entitlements
  @apideck/agent-analytics/firewall   WAF recommendations (offline)
  @apideck/agent-analytics/markdown   Markdown-twin negotiation

agentPolicy stays in the root: classification without a policy to apply it to is
half a library, and it costs ~2 kB. That is why the root lands at 11.6 kB rather
than back at 9.6 kB.

README gains an entry-point table with the real numbers, and every import
example in the README, the testing guide and the site now points at the right
subpath. Verified from a clean install of the packed package that all five entry
points resolve.

Found while pulling accurate figures for the site's install section, which was
still advertising "216 tests" and "CI on Node 18, 20 and 22" — Node 18 was
dropped in 0.12 for lacking globalThis.crypto, so the site was claiming support
for a runtime the library refuses to run on. Both corrected.
Gdewilde added a commit that referenced this pull request Aug 2, 2026
The root entry ships in every consumer's edge middleware and runs on every
request, so its size is a feature rather than a vanity metric. It silently
doubled anyway: #21 cut it from 27.7 kB to 9.6 kB, then payments, gateway,
entitlement and firewall were each exported from the root by reflex and it
climbed back to 22.5 kB. Nothing failed. Every test passed. It surfaced weeks
later only because someone needed an accurate figure for a marketing page.

Same shape as the unpublished-0.10.0 bug and the agentIntent divergence: the
mechanism existed, nothing asserted the invariant, and it stayed broken until
something unrelated exposed it. So assert it.

scripts/check-size.mjs measures each entry gzipped against size-budget.json and
fails the build over budget. Zero dependencies, in keeping with the package —
node:zlib does the work.

  entry               gzipped     budget  used
  dist/index.js       4.44 kB    4.88 kB   91%
  dist/verify.js      6.25 kB    7.42 kB   84%
  dist/pay.js         4.05 kB    4.49 kB   90%
  dist/firewall.js    2.80 kB    3.13 kB   90%
  dist/markdown.js    1.03 kB    1.27 kB   81%

Verified by reintroducing the exact regression — re-exporting pay and firewall
from the root put index.js at 174% of budget and exited 1, with a message
naming the likely cause rather than just a number.

Budgets sit ~10% above current. verify.js gets more headroom because the
published IP range tables grow whenever the weekly refresh adds prefixes, and a
routine range update should not fail CI. Raising a budget is deliberate:
`npm run size -- --update`, and justify it in the commit.

The table prints on every run, not only on failure, so the trend is visible in
CI logs.
Gdewilde added a commit that referenced this pull request Aug 2, 2026
* refactor: move payments and the firewall recommender behind subpaths

The root bundle had grown from 9,643 to 22,497 bytes — undoing most of the 65%
reduction in #21 — because payments, gateway, entitlement and firewall all
exported from the package root. Every consumer paid for them whether or not they
charged anyone, and the firewall recommender in particular is an offline
analysis tool that will never execute in middleware.

  dist/index.js     22,497 -> 11,608 B   (8,695 -> 4,546 gzipped)
  dist/pay.js                 10,861 B   opt-in
  dist/firewall.js             6,821 B   opt-in

Entry points now:

  @apideck/agent-analytics            detection, classification, agentPolicy, trackVisit
  @apideck/agent-analytics/verify     Web Bot Auth + published IP range tables
  @apideck/agent-analytics/payments   402 challenges, gateways, entitlements
  @apideck/agent-analytics/firewall   WAF recommendations (offline)
  @apideck/agent-analytics/markdown   Markdown-twin negotiation

agentPolicy stays in the root: classification without a policy to apply it to is
half a library, and it costs ~2 kB. That is why the root lands at 11.6 kB rather
than back at 9.6 kB.

README gains an entry-point table with the real numbers, and every import
example in the README, the testing guide and the site now points at the right
subpath. Verified from a clean install of the packed package that all five entry
points resolve.

Found while pulling accurate figures for the site's install section, which was
still advertising "216 tests" and "CI on Node 18, 20 and 22" — Node 18 was
dropped in 0.12 for lacking globalThis.crypto, so the site was claiming support
for a runtime the library refuses to run on. Both corrected.

* ci: enforce a bundle budget per entry point

The root entry ships in every consumer's edge middleware and runs on every
request, so its size is a feature rather than a vanity metric. It silently
doubled anyway: #21 cut it from 27.7 kB to 9.6 kB, then payments, gateway,
entitlement and firewall were each exported from the root by reflex and it
climbed back to 22.5 kB. Nothing failed. Every test passed. It surfaced weeks
later only because someone needed an accurate figure for a marketing page.

Same shape as the unpublished-0.10.0 bug and the agentIntent divergence: the
mechanism existed, nothing asserted the invariant, and it stayed broken until
something unrelated exposed it. So assert it.

scripts/check-size.mjs measures each entry gzipped against size-budget.json and
fails the build over budget. Zero dependencies, in keeping with the package —
node:zlib does the work.

  entry               gzipped     budget  used
  dist/index.js       4.44 kB    4.88 kB   91%
  dist/verify.js      6.25 kB    7.42 kB   84%
  dist/pay.js         4.05 kB    4.49 kB   90%
  dist/firewall.js    2.80 kB    3.13 kB   90%
  dist/markdown.js    1.03 kB    1.27 kB   81%

Verified by reintroducing the exact regression — re-exporting pay and firewall
from the root put index.js at 174% of budget and exited 1, with a message
naming the likely cause rather than just a number.

Budgets sit ~10% above current. verify.js gets more headroom because the
published IP range tables grow whenever the weekly refresh adds prefixes, and a
routine range update should not fail CI. Raising a budget is deliberate:
`npm run size -- --update`, and justify it in the commit.

The table prints on every run, not only on failure, so the trend is visible in
CI logs.

* chore: 0.16.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant