bridgebench 3.1.0-alpha.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 (86) hide show
  1. package/CITATION.cff +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/dist/chunk-4TWPCPRP.cjs +1097 -0
  5. package/dist/chunk-4TWPCPRP.cjs.map +1 -0
  6. package/dist/chunk-7YCJSOK7.cjs +398 -0
  7. package/dist/chunk-7YCJSOK7.cjs.map +1 -0
  8. package/dist/chunk-CIXITJW6.cjs +249 -0
  9. package/dist/chunk-CIXITJW6.cjs.map +1 -0
  10. package/dist/chunk-EQHRUV2I.js +1466 -0
  11. package/dist/chunk-EQHRUV2I.js.map +1 -0
  12. package/dist/chunk-JTVNKSMO.js +1096 -0
  13. package/dist/chunk-JTVNKSMO.js.map +1 -0
  14. package/dist/chunk-LFKEV2YL.js +398 -0
  15. package/dist/chunk-LFKEV2YL.js.map +1 -0
  16. package/dist/chunk-NJTYVNP4.cjs +1467 -0
  17. package/dist/chunk-NJTYVNP4.cjs.map +1 -0
  18. package/dist/chunk-UECBSKTD.js +244 -0
  19. package/dist/chunk-UECBSKTD.js.map +1 -0
  20. package/dist/cli.cjs +409 -0
  21. package/dist/cli.cjs.map +1 -0
  22. package/dist/cli.d.cts +1 -0
  23. package/dist/cli.d.ts +1 -0
  24. package/dist/cli.js +408 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/client.cjs +42 -0
  27. package/dist/client.cjs.map +1 -0
  28. package/dist/client.d.cts +93 -0
  29. package/dist/client.d.ts +93 -0
  30. package/dist/client.js +42 -0
  31. package/dist/client.js.map +1 -0
  32. package/dist/contracts/index.cjs +47 -0
  33. package/dist/contracts/index.cjs.map +1 -0
  34. package/dist/contracts/index.d.cts +14 -0
  35. package/dist/contracts/index.d.ts +14 -0
  36. package/dist/contracts/index.js +47 -0
  37. package/dist/contracts/index.js.map +1 -0
  38. package/dist/index.cjs +171 -0
  39. package/dist/index.cjs.map +1 -0
  40. package/dist/index.d.cts +214 -0
  41. package/dist/index.d.ts +214 -0
  42. package/dist/index.js +171 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/logger-CCR9Mg1c.d.cts +319 -0
  45. package/dist/logger-QJU7SBDz.d.ts +319 -0
  46. package/dist/reports-4CejmOHf.d.cts +454 -0
  47. package/dist/reports-s2CTnGN8.d.ts +454 -0
  48. package/dist/tasks-CpaCJ6JE.d.cts +151 -0
  49. package/dist/tasks-CpaCJ6JE.d.ts +151 -0
  50. package/dist/tasks.cjs +22 -0
  51. package/dist/tasks.cjs.map +1 -0
  52. package/dist/tasks.d.cts +39 -0
  53. package/dist/tasks.d.ts +39 -0
  54. package/dist/tasks.js +22 -0
  55. package/dist/tasks.js.map +1 -0
  56. package/docs/README.md +25 -0
  57. package/docs/glossary.md +49 -0
  58. package/docs/methodology.md +58 -0
  59. package/docs/private-packs.md +74 -0
  60. package/docs/replay-elo.md +79 -0
  61. package/docs/task-authoring.md +80 -0
  62. package/package.json +137 -0
  63. package/tasks/hallucination/public/boundary-coverage-audit.yaml +274 -0
  64. package/tasks/hallucination/public/boundary-migration-audit.yaml +284 -0
  65. package/tasks/hallucination/public/conflict-dependency-versions.yaml +324 -0
  66. package/tasks/hallucination/public/conflict-runbook-versions.yaml +229 -0
  67. package/tasks/hallucination/public/fabrication-agent-tools.yaml +224 -0
  68. package/tasks/hallucination/public/fabrication-api-surface.yaml +239 -0
  69. package/tasks/hallucination/public/fidelity-commit-attribution.yaml +304 -0
  70. package/tasks/hallucination/public/fidelity-config-drift.yaml +307 -0
  71. package/tasks/hallucination/public/missing-deploy-window.yaml +204 -0
  72. package/tasks/hallucination/public/missing-latency-baseline.yaml +239 -0
  73. package/tasks/hallucination/public/premise-quota-breach.yaml +202 -0
  74. package/tasks/hallucination/public/premise-rollback-cause.yaml +235 -0
  75. package/tasks/reasoning/public/constraint-capacity-allocation.yaml +196 -0
  76. package/tasks/reasoning/public/constraint-deployment-policy.yaml +203 -0
  77. package/tasks/reasoning/public/counterexample-authorization-rule.yaml +278 -0
  78. package/tasks/reasoning/public/counterexample-scheduler-starvation.yaml +290 -0
  79. package/tasks/reasoning/public/root-cache-tenant-leak.yaml +225 -0
  80. package/tasks/reasoning/public/root-event-ordering.yaml +184 -0
  81. package/tasks/reasoning/public/stateful-lease-handoff.yaml +213 -0
  82. package/tasks/reasoning/public/stateful-retry-budget.yaml +222 -0
  83. package/tasks/reasoning/public/synthesis-api-contract.yaml +214 -0
  84. package/tasks/reasoning/public/synthesis-permission-migration.yaml +190 -0
  85. package/tasks/reasoning/public/uncertainty-conflicting-telemetry.yaml +242 -0
  86. package/tasks/reasoning/public/uncertainty-incomplete-incident.yaml +223 -0
@@ -0,0 +1,242 @@
1
+ id: uncertainty-conflicting-telemetry
2
+ version: 2.0.0
3
+ category: reasoning
4
+ cluster: uncertainty-adversarial
5
+ difficulty: expert
6
+ title: Checkout Latency Spike Across Contradictory Dashboards
7
+ summary: A checkout API tail-latency spike coincides with an alarming database dashboard, a recent deploy, garbage-collection pauses, and a few auth errors. The signals partly contradict one another and several are unreliable for concrete reasons. Rank the candidate causes, identify which signals cannot be trusted and why, commit to the single best-supported root cause and mechanism, explain why the database is not it, and name the one measurement that would be decisive.
8
+ prompt: |
9
+ The checkout API showed a tail-latency spike for a few minutes. On-call is split: one engineer
10
+ blames the database (a dashboard shows database p95 hitting ~1.9s), another points at a deploy
11
+ minutes earlier, and a few auth errors are in the logs. The evidence partly contradicts itself,
12
+ and some of it is measured in ways that make it unreliable. Despite the noise, one conclusion is
13
+ best supported by the artifacts.
14
+
15
+ Times are shown per artifact in that artifact's clock. Note the clock status before aligning
16
+ anything across hosts. This is not a "cannot be determined" task: the internally consistent,
17
+ synchronized-clock evidence is sufficient to reach one best-supported conclusion.
18
+
19
+ Deliverables (answer every one, and show the reasoning that supports it):
20
+
21
+ 1. Rank the candidate causes from best-supported to least-supported: (a) the deploy, (b) database
22
+ contention, (c) the auth dependency, (d) network or DNS. For each, weigh the specific evidence
23
+ for and against.
24
+
25
+ 2. Identify which signals are unreliable and state the concrete artifact-grounded reason for each
26
+ (for example clock skew, aggregation across unrelated work, or sampling and truncation). Say
27
+ what each unreliable signal can and cannot be used for.
28
+
29
+ 3. State the single best-supported conclusion: the root cause and the mechanism that produced the
30
+ observed tail-latency signature (be specific about why the median stayed flat).
31
+
32
+ 4. Explain specifically why the database is not the cause, despite the database dashboard showing
33
+ a ~1.9s p95.
34
+
35
+ 5. Give the one additional measurement that would make the conclusion decisive, and state which
36
+ result would confirm the conclusion and which would refute it.
37
+ artifacts:
38
+ - id: incident-summary
39
+ type: note
40
+ label: Incident summary, change events, and on-call theories
41
+ content: |
42
+ SEV-2 checkout-api latency. Customer-facing p99 rose from ~150 ms to ~1.9 s between 14:05 and
43
+ 14:08 (api-host clock) and recovered by 14:09. Median latency barely moved. The client library
44
+ aborts a checkout request at a 2 s timeout.
45
+
46
+ Change events (deploy pipeline clock, NTP healthy):
47
+ 11:20:04 checkout-api d-8790 dependency bump, no config change
48
+ 14:03:10 checkout-api d-8842 config: catalogCache.maxEntries 5000 -> 50000 (in-process
49
+ cache), warmed on boot
50
+ 14:03:10 database no schema migration or config change in the window
51
+ 14:03:10 auth-service no change in the window
52
+ d-8842 raised the in-process catalog cache capacity tenfold. No database or auth change
53
+ shipped during the incident window.
54
+
55
+ On-call theories (do not assume any is correct):
56
+ - Engineer A: "The database dashboard shows query p95 hitting 1.9 s at 14:07. The database
57
+ is overloaded and is stalling checkout." A wants to fail over the primary.
58
+ - Engineer B: "There was a checkout-api deploy at 14:03, minutes before the spike."
59
+ - A third note: the auth service returned three HTTP 500s during the window, so an auth
60
+ outage is 'possible.'
61
+ Decide the cause from the artifacts before any disruptive action (failover, rollback) is taken.
62
+ - id: api-latency
63
+ type: table
64
+ label: checkout-api latency percentiles (api-host clock, NTP healthy)
65
+ content: |
66
+ Per-minute percentiles:
67
+ minute | p50_ms | p90_ms | p95_ms | p99_ms
68
+ 14:01 | 55 | 78 | 90 | 140
69
+ 14:02 | 56 | 79 | 92 | 150
70
+ 14:03 | 58 | 96 | 120 | 300
71
+ 14:04 | 57 | 150 | 260 | 900
72
+ 14:05 | 58 | 320 | 620 | 1950
73
+ 14:06 | 59 | 360 | 700 | 1880
74
+ 14:07 | 57 | 340 | 650 | 1820
75
+ 14:08 | 56 | 180 | 300 | 900
76
+ 14:09 | 55 | 96 | 150 | 210
77
+ 14:10 | 55 | 80 | 95 | 150
78
+
79
+ Per-second p99 within the worst minute (14:05:xx), showing the spike is spiky, not sustained:
80
+ 14:05:03 160 ms | 14:05:07 1910 ms | 14:05:12 150 ms | 14:05:19 170 ms
81
+ 14:05:41 1780 ms | 14:05:44 160 ms | 14:05:55 155 ms
82
+
83
+ The median is flat (~55-59 ms) throughout, and even p90 stays modest. Only the far tail (p95,
84
+ p99) spikes, and within a minute only a few seconds carry it. p99 peaks near the value of a
85
+ single ~1.8 s stall added to a normal ~55 ms request.
86
+
87
+ Request rate (requests per minute) for context:
88
+ 14:01 | 2980 14:03 | 3010 14:05 | 3040 14:07 | 2995 14:09 | 3005
89
+ Traffic is flat across the incident (no surge, no drop), so a load spike or a mass client
90
+ retry storm is not in play; whatever slowed the tail did so without changing offered load.
91
+ - id: resource-metrics
92
+ type: table
93
+ label: checkout-api host runtime metrics (api-host clock, NTP healthy)
94
+ content: |
95
+ minute | oldGen_used_pct | heap_used_mb | gc_time_ms_per_min | sys_cpu_pct
96
+ 14:01 | 42 | 2100 | 60 | 30
97
+ 14:02 | 41 | 2080 | 55 | 31
98
+ 14:03 | 55 | 2750 | 120 | 34
99
+ 14:04 | 78 | 3600 | 900 | 45
100
+ 14:05 | 95 | 4300 | 5400 | 88
101
+ 14:06 | 96 | 4350 | 5200 | 90
102
+ 14:07 | 95 | 4320 | 5100 | 86
103
+ 14:08 | 90 | 4050 | 2600 | 70
104
+ 14:09 | 72 | 3100 | 300 | 40
105
+ 14:10 | 55 | 2400 | 70 | 33
106
+
107
+ Old-generation occupancy and heap climb from 14:03 (the deploy) and peak at ~95% during the
108
+ spike, then recede. Time spent in garbage collection per minute jumps by ~90x during the
109
+ spike, and system CPU tracks it (CPU spent collecting rather than serving).
110
+
111
+ The heap growth begins at 14:03 and not before, and it recedes only after 14:08 once the
112
+ collector reclaims the promoted objects; the shape is a step change at the deploy, not a slow
113
+ organic drift. The 5,400 ms/min of GC time at 14:05 is close to the sum of the Full GC pauses
114
+ in gc-log for that minute, i.e. the host spent a large fraction of the minute stopped.
115
+ - id: gc-log
116
+ type: log
117
+ label: checkout-api garbage-collection log (api-host clock)
118
+ content: |
119
+ 14:01:30 GC(minor) pause 12ms
120
+ 14:02:44 GC(minor) pause 14ms
121
+ 14:03:15 GC(minor) pause 18ms (post-deploy heap warmup)
122
+ 14:04:02 GC(minor) pause 40ms promoted 512M -> oldGen
123
+ 14:04:51 GC(minor) pause 55ms promoted 640M -> oldGen
124
+ 14:05:07 Full GC (Allocation Failure) pause 1.81s oldGen 4180M -> 3020M
125
+ 14:05:41 Full GC (Allocation Failure) pause 1.78s oldGen 4205M -> 3050M
126
+ 14:06:12 Full GC (Allocation Failure) pause 1.72s oldGen 4210M -> 3080M
127
+ 14:07:20 Full GC (Allocation Failure) pause 1.68s oldGen 4190M -> 3050M
128
+ 14:08:33 GC (Allocation Failure) pause 0.90s oldGen 3900M -> 2600M
129
+ 14:10:05 GC(minor) pause 15ms
130
+
131
+ Before 14:04 only sub-60 ms minor collections. Several ~1.7-1.8 s stop-the-world Full GCs
132
+ occur inside the spike window (14:05-14:07), each freeing old-gen space that quickly refills.
133
+ The Full GC timestamps line up second-for-second with the per-second p99 spikes in
134
+ api-latency (14:05:07, 14:05:41, ...), on the same api-host clock.
135
+ - id: trace-sample
136
+ type: log
137
+ label: Distributed trace samples (api-host clock; head-sampled at 1%)
138
+ content: |
139
+ Sampling: head-sampled at 1% of requests. Requests that exceed the 2 s client timeout are
140
+ cancelled before their spans export, so the very slowest requests are absent from this sample.
141
+
142
+ trace T-19 14:05:07 total 1.812s
143
+ span http.handler 1.795s [on-CPU/runnable-stalled 1.740s]
144
+ span db.query 0.038s
145
+ span auth.verify 0.024s
146
+ span serialize.response 0.014s
147
+ trace T-27 14:05:41 total 1.774s
148
+ span http.handler 1.756s [on-CPU/runnable-stalled 1.690s]
149
+ span db.query 0.041s
150
+ span auth.verify 0.022s
151
+ trace T-44 14:06:12 total 1.719s
152
+ span http.handler 1.701s [on-CPU/runnable-stalled 1.640s]
153
+ span db.query 0.036s
154
+ span auth.verify 0.026s
155
+ trace T-31 14:05:50 total 0.056s (normal request, same window)
156
+ span db.query 0.035s
157
+ span auth.verify 0.020s
158
+ span serialize.response 0.001s
159
+
160
+ In every slow trace the db.query and auth.verify spans are normal (~40 ms and ~24 ms); the
161
+ extra ~1.6-1.7 s is on-CPU/runnable-stalled time inside the handler, and its start aligns with
162
+ a Full GC timestamp in gc-log. Normal requests in the same window are ~56 ms end to end.
163
+
164
+ On the sampling limitation: 1% head sampling and the loss of >2 s timed-out requests mean this
165
+ set understates HOW MANY and HOW SLOW the worst requests were. It does not bias WHERE the time
166
+ is spent within a captured slow request: the db and auth spans are measured directly and are
167
+ small, and a stall that lived inside the db or auth call would have shown up as a large db.query
168
+ or auth.verify span, which it does not. So the sample is unusable for counting the tail but
169
+ valid for attributing it.
170
+ - id: db-metrics
171
+ type: table
172
+ label: Database query latency (database-host clock; see clock-note)
173
+ content: |
174
+ Pooled p95 across ALL schemas (single percentile over every query on the host):
175
+ minute | p95_ms
176
+ 14:03 | 92
177
+ 14:04 | 95
178
+ 14:05 | 120
179
+ 14:06 | 900
180
+ 14:07 | 1900
181
+ 14:08 | 1500
182
+ 14:09 | 300
183
+ 14:10 | 110
184
+
185
+ Per-schema p95 (same host, same queries, decomposed by schema):
186
+ minute | checkout_ms | analytics_ms | sessions_ms
187
+ 14:05 | 40 | 210 | 33
188
+ 14:06 | 44 | 3200 | 35
189
+ 14:07 | 41 | 4200 | 34
190
+ 14:08 | 39 | 3100 | 36
191
+ 14:09 | 37 | 260 | 33
192
+
193
+ A batch job report_rollup ran on the analytics schema from 14:06:40 to 14:09:30 (database-host
194
+ clock). Because the pooled p95 is one percentile computed over ALL schemas' queries together,
195
+ the heavy analytics batch dominates the pooled tail. The checkout schema's own p95 stayed
196
+ ~37-44 ms throughout, and the sessions schema was flat too.
197
+ - id: clock-note
198
+ type: note
199
+ label: Time synchronization status
200
+ content: |
201
+ The api host, its garbage-collection log, and the trace collector all stamp events with the
202
+ api-host clock, which held NTP sync throughout (measured offset < 20 ms). Those three sources
203
+ are therefore mutually alignable second-for-second.
204
+
205
+ The database metrics host lost NTP sync at ~13:40. An audit at 14:15 measured its clock
206
+ running +2 minutes 51 seconds ahead of true time. Database-host timestamps in db-metrics are
207
+ therefore roughly three minutes ahead of true time and cannot be aligned tick-for-tick to the
208
+ api-host timeline; the database dashboard's "14:07" is not the api host's 14:07. The
209
+ per-schema decomposition WITHIN the database host is still internally valid, because it
210
+ compares schemas measured on the same (skewed) clock at the same instants.
211
+
212
+ Practical consequence: any argument of the form "the database spiked at 14:07 and the API
213
+ spiked at 14:05-14:08, so they line up" is unsound, because the database "14:07" is really
214
+ ~14:04 true time, and the alignment is an artifact of comparing two clocks that are three
215
+ minutes apart. Cross-host causality here needs synchronized timestamps or shared trace IDs,
216
+ neither of which exists for the database path in this window.
217
+ - id: auth-dependency
218
+ type: table
219
+ label: Auth service latency and errors (auth-host clock, NTP healthy)
220
+ content: |
221
+ Auth verify p95 (ms):
222
+ 14:01 | 22
223
+ 14:02 | 21
224
+ 14:03 | 24
225
+ 14:04 | 23
226
+ 14:05 | 25
227
+ 14:06 | 23
228
+ 14:07 | 26
229
+ 14:08 | 24
230
+ 14:09 | 22
231
+ 14:10 | 22
232
+
233
+ HTTP 500s recorded (auth-host clock):
234
+ 14:01:12 endpoint=/token/refresh (1 event)
235
+ 14:12:40 endpoint=/token/refresh (1 event)
236
+ 14:14:05 endpoint=/token/refresh (1 event)
237
+
238
+ Auth verify latency (the path checkout actually calls) is flat 21-26 ms across the incident.
239
+ The three 500s are all on /token/refresh, a different endpoint that checkout does not call on
240
+ the read path, and they fall at 14:01 and after 14:12, outside the 14:05-14:08 spike. This
241
+ 500 rate matches the pre-incident baseline for that endpoint.
242
+ tags: [uncertainty, telemetry, garbage-collection, clock-skew, root-cause, calibration]
@@ -0,0 +1,223 @@
1
+ id: uncertainty-incomplete-incident
2
+ version: 2.0.0
3
+ category: reasoning
4
+ cluster: uncertainty-adversarial
5
+ difficulty: expert
6
+ title: Webhook Signature Failures That Look Like an Attack
7
+ summary: About 40 percent of inbound webhooks began failing signature verification, all seemingly from one IP, and on-call suspects a breach. Several signals are unreliable for concrete reasons, but the evidence still points to one best-supported, non-adversarial cause. Rank the hypotheses, identify the untrustworthy signals, commit to the conclusion, rule out tampering and the decoys, and give a proportionate remediation plan plus the decisive measurement.
8
+ prompt: |
9
+ Inbound webhooks from a partner payment gateway started failing signature verification at ~09:15.
10
+ Roughly 40 percent are now rejected as signature_mismatch, and an access log appears to show every
11
+ failure coming from a single IP, so on-call opened a SEV-2 suspecting a forged-webhook attack.
12
+ Some of the evidence is measured in ways that make it misleading, but the artifacts still support
13
+ one best-supported, non-adversarial conclusion.
14
+
15
+ This is not a "cannot be determined" task and it is not a security incident by default: weigh the
16
+ evidence and commit to the mechanism the artifacts most support, then respond proportionately.
17
+
18
+ Deliverables (answer every one, and show the reasoning that supports it):
19
+
20
+ 1. Rank these hypotheses from best-supported to least-supported, weighing specific evidence for and
21
+ against each: (a) an attacker forging or tampering with webhooks, (b) a signing-secret rotation
22
+ race, (c) proxy reserialization changing the signed bytes, (d) a clock or replay-window problem.
23
+
24
+ 2. Identify which signals are unreliable and give the concrete artifact-grounded reason for each
25
+ (for example sampled logs, a source-IP column that is really the proxy, or a truncated pod log).
26
+ Say what each unreliable signal can and cannot support.
27
+
28
+ 3. State the single best-supported conclusion and explain specifically why the reject rate is about
29
+ 40 percent rather than 0 or 100 percent.
30
+
31
+ 4. Explain why an attacker is not the cause, and why proxy reserialization and a clock/replay
32
+ problem are also ruled out. Cite the single strongest piece of evidence that discriminates
33
+ among these.
34
+
35
+ 5. Give proportionate remediation priorities and state explicitly what NOT to do. Your plan must
36
+ not expose the signing secret or full sensitive payloads, and must not take breach-response
37
+ actions without breach evidence.
38
+
39
+ 6. Give the one additional measurement that would make the conclusion decisive, and state which
40
+ result confirms it and which refutes it.
41
+ artifacts:
42
+ - id: incident-summary
43
+ type: note
44
+ label: Incident summary and initial theory
45
+ content: |
46
+ At ~09:15 the receiver service began rejecting inbound webhooks from partner payment-gateway
47
+ as signature_mismatch. About 40% of deliveries are now rejected; the rest succeed. The service
48
+ accepts webhooks behind an ingress proxy and verifies an HMAC signature carried in request
49
+ headers. Deliveries are load-balanced roughly evenly across eight receiver pods.
50
+
51
+ On-call's initial theory and proposed actions: "The access log shows every rejected request
52
+ coming from source IP 10.0.0.7. A single origin flooding us with bad signatures looks like an
53
+ attacker forging webhooks. We should (1) treat this as a breach, (2) rotate the signing secret
54
+ immediately, and (3) block 10.0.0.7 at the edge." Before any of those actions is taken, decide
55
+ from the artifacts whether the breach theory is actually supported, and whether each proposed
56
+ action would help or hurt.
57
+
58
+ One observation the on-call bridge kept returning to: the failures are not uniform. The same
59
+ partner traffic succeeds against some receiver pods and fails against others at the same
60
+ instant, which does not match "our shared secret was compromised" (that would fail everywhere)
61
+ and does not match a payload-mangling proxy (that would also fail everywhere). Whatever the
62
+ cause, it is something that differs pod to pod.
63
+ - id: rejection-metric
64
+ type: table
65
+ label: Receiver rejection counter (unsampled, receiver clock, NTP healthy)
66
+ content: |
67
+ Authoritative unsampled counter of every delivery outcome (all pods combined).
68
+
69
+ minute | delivered | accepted | rej_signature_mismatch | rej_timestamp_expired
70
+ 09:11 | 1190 | 1190 | 0 | 0
71
+ 09:12 | 1200 | 1200 | 0 | 0
72
+ 09:13 | 1180 | 1179 | 1 | 0
73
+ 09:14 | 1210 | 1208 | 2 | 0
74
+ 09:15 | 1230 | 742 | 488 | 0
75
+ 09:16 | 1250 | 760 | 490 | 0
76
+ 09:17 | 1220 | 748 | 472 | 0
77
+ 09:18 | 1240 | 769 | 471 | 0
78
+ 09:19 | 1215 | 742 | 473 | 0
79
+ 09:20 | 1225 | 761 | 464 | 0
80
+
81
+ Before 09:15 the reject rate is ~0. From 09:15 onward ~38-40% are rejected, ALL counted as
82
+ signature_mismatch. rej_timestamp_expired stays at exactly 0 the entire time. The reject rate
83
+ is stable, not growing, which is not the shape of an escalating attack.
84
+ - id: access-log-sample
85
+ type: log
86
+ label: Receiver access log (SAMPLED 1-in-10; see caveats)
87
+ content: |
88
+ Caveats: this access log is head-sampled at 1-in-10, so raw line counts understate true
89
+ volume roughly tenfold; use rejection-metric for volume. Also, pod recv-7 rotated its local
90
+ log file at 09:16 and the pre-rotation segment was lost, so recv-7's own reject count in this
91
+ file is undercounted. The `src` column is the connection source address; the real client is in
92
+ the `xff` (X-Forwarded-For) field.
93
+
94
+ 09:15:04 pod=recv-7 src=10.0.0.7 xff=198.51.100.20 key_id=v2 result=REJECT err=signature_mismatch
95
+ 09:15:09 pod=recv-2 src=10.0.0.7 xff=198.51.100.21 key_id=v2 result=ACCEPT
96
+ 09:15:15 pod=recv-5 src=10.0.0.7 xff=198.51.100.20 key_id=v2 result=REJECT err=signature_mismatch
97
+ 09:15:31 pod=recv-4 src=10.0.0.7 xff=198.51.100.22 key_id=v2 result=ACCEPT
98
+ 09:15:47 pod=recv-3 src=10.0.0.7 xff=198.51.100.24 key_id=v2 result=REJECT err=signature_mismatch
99
+ 09:16:02 pod=recv-7 src=10.0.0.7 xff=198.51.100.20 key_id=v2 result=REJECT err=signature_mismatch
100
+ 09:16:19 pod=recv-1 src=10.0.0.7 xff=198.51.100.25 key_id=v2 result=ACCEPT
101
+ 09:16:48 pod=recv-2 src=10.0.0.7 xff=198.51.100.23 key_id=v2 result=ACCEPT
102
+
103
+ Every line carries key_id=v2 and an xff inside 198.51.100.0/24. The SAME key_id=v2 traffic is
104
+ REJECTED on some pods (recv-3, recv-5, recv-7) and ACCEPTED on others (recv-1, recv-2, recv-4).
105
+ No line shows a client address outside 198.51.100.0/24.
106
+ - id: verify-code
107
+ type: code
108
+ label: receiver/verify.ts and the webhook handler
109
+ content: |
110
+ // receiver/secrets.ts
111
+ // Loads signing secrets from this pod's environment. A pod holds { v1 } or { v1, v2 }
112
+ // depending on how far the current secret rollout has reached it. The loader never logs
113
+ // secret material.
114
+ export function loadSecretsFromEnv(): Record<string, Buffer> {
115
+ const map: Record<string, Buffer> = {};
116
+ for (const version of (process.env.SIG_KEY_VERSIONS ?? '').split(',').filter(Boolean)) {
117
+ map[version] = readSecret(version); // reads SIG_SECRET_<version> from the pod env
118
+ }
119
+ return map;
120
+ }
121
+
122
+ // receiver/verify.ts
123
+ import { createHmac, timingSafeEqual } from 'node:crypto';
124
+ import { loadSecretsFromEnv } from './secrets';
125
+
126
+ const SECRETS: Record<string, Buffer> = loadSecretsFromEnv();
127
+ const REPLAY_WINDOW_MS = 5 * 60 * 1000;
128
+
129
+ export function verify(rawBody: Buffer, headers: Headers): { ok: boolean; code?: string } {
130
+ const keyId = headers.get('x-sig-key-id') ?? ''; // 'v1' or 'v2'
131
+ const sigHex = headers.get('x-sig') ?? '';
132
+ const tsMs = Number(headers.get('x-sig-ts') ?? '0');
133
+
134
+ if (Math.abs(Date.now() - tsMs) > REPLAY_WINDOW_MS) {
135
+ return { ok: false, code: 'timestamp_expired' }; // distinct code for clock/replay
136
+ }
137
+ const secret = SECRETS[keyId];
138
+ if (!secret) {
139
+ return { ok: false, code: 'signature_mismatch' }; // unknown key version, masked as mismatch
140
+ }
141
+ // Signs the RAW received bytes, before any JSON parse or re-serialize.
142
+ const expected = createHmac('sha256', secret).update(rawBody).digest();
143
+ const given = Buffer.from(sigHex, 'hex');
144
+ const ok = expected.length === given.length && timingSafeEqual(expected, given);
145
+ return ok ? { ok: true } : { ok: false, code: 'signature_mismatch' };
146
+ }
147
+
148
+ // receiver/handler.ts
149
+ import { verify } from './verify';
150
+ import { metrics } from './metrics';
151
+
152
+ export async function handleWebhook(req: Request): Promise<Response> {
153
+ const rawBody = Buffer.from(await req.arrayBuffer()); // exact received bytes, no parse first
154
+ const result = verify(rawBody, req.headers);
155
+ if (!result.ok) {
156
+ metrics.increment(`webhook.reject.${result.code}`); // feeds rejection-metric
157
+ return new Response('rejected', { status: 401 });
158
+ }
159
+ metrics.increment('webhook.accept');
160
+ return new Response('ok', { status: 200 });
161
+ }
162
+ - id: rotation-timeline
163
+ type: table
164
+ label: Signing-secret rotation and rollout timeline (NTP healthy on both sides)
165
+ content: |
166
+ time | actor | event
167
+ 08:00:00 | payment-gateway | signing key_id=v1 active (steady state before today)
168
+ 09:10:00 | secrets-ops | v2 secret generated and staged for rollout to both sides
169
+ 09:14:50 | payment-gateway | activated signing key_id=v2; all outbound webhooks now signed with v2 (was v1)
170
+ 09:15:00 | receiver-deploy | config push started, rolling SIG_KEY_VERSIONS=v1,v2 to receiver pods
171
+ 09:16:00 | receiver-deploy | rollout at 4 of 8 pods (v2 present)
172
+ 09:17:00 | receiver-deploy | rollout at 5 of 8 pods (v2 present); 3 pods still hold only v1
173
+ 09:40:00 | receiver-deploy | rollout expected to complete (all pods hold v1 and v2)
174
+
175
+ Sender and receiver clocks are NTP-synced (offsets < 30 ms). The sender activated v2 first; the
176
+ receiver fleet is being updated behind it via a rolling config push and is not yet fully rolled
177
+ out. The intended sequence was to roll the receivers first, then flip the sender; that order
178
+ was reversed today.
179
+ - id: pod-config
180
+ type: table
181
+ label: Receiver pod secret versions and per-pod outcomes since 09:15
182
+ content: |
183
+ pod | SIG_KEY_VERSIONS | delivered | accepted | rejected | reject_rate
184
+ recv-1 | v1,v2 | 152 | 152 | 0 | 0%
185
+ recv-2 | v1,v2 | 149 | 149 | 0 | 0%
186
+ recv-3 | v1 | 151 | 0 | 151 | 100%
187
+ recv-4 | v1,v2 | 150 | 150 | 0 | 0%
188
+ recv-5 | v1 | 148 | 0 | 148 | 100%
189
+ recv-6 | v1,v2 | 153 | 153 | 0 | 0%
190
+ recv-7 | v1 | 150 | 0 | 150 | 100%
191
+ recv-8 | v1,v2 | 147 | 147 | 0 | 0%
192
+
193
+ Exactly the 3 pods holding only v1 (recv-3, recv-5, recv-7) reject 100% of the v2-signed
194
+ traffic they receive; the 5 pods holding v2 accept 100%. Deliveries are load-balanced roughly
195
+ evenly, so ~3/8 of all deliveries land on a v1-only pod. (recv-7's local log undercounts due to
196
+ the 09:16 rotation, but this counter is authoritative.)
197
+ - id: sender-egress
198
+ type: note
199
+ label: Partner sender egress and payload shape
200
+ content: |
201
+ The partner payment-gateway sends from an allowlisted NAT egress pool, CIDR 198.51.100.0/24.
202
+ Every rejected delivery's X-Forwarded-For is within that range; no addresses outside the
203
+ allowlist appear in the sampled log or in any per-pod counter. The rejected requests are
204
+ well-formed webhooks: they carry an x-sig-key-id header of v2, an x-sig of the expected length,
205
+ and an x-sig-ts within the replay window. Critically, the identical requests that a v1-only pod
206
+ rejects are ACCEPTED when they happen to be routed to a pod that holds v2, so the bytes and the
207
+ signature are intact and valid under the v2 secret.
208
+ - id: proxy-note
209
+ type: note
210
+ label: Ingress proxy behavior
211
+ content: |
212
+ The ingress proxy terminates TLS and opens a new connection to the receiver pods from its own
213
+ address, 10.0.0.7. Every request therefore arrives at a pod with src=10.0.0.7 regardless of the
214
+ true client; the real client address is preserved only in X-Forwarded-For. The receiver access
215
+ log's `src` column is thus always the proxy, so "all failures from 10.0.0.7" is an artifact of
216
+ reading `src` instead of `xff`. Blocking 10.0.0.7 would drop ALL inbound webhook traffic, not
217
+ an attacker.
218
+
219
+ The proxy forwards the request body unchanged (byte-for-byte pass-through); it does not parse
220
+ or re-serialize the payload. The receiver handler additionally reads rawBody (the exact
221
+ received bytes) before any JSON parsing, so the bytes the HMAC is computed over are the bytes
222
+ the sender signed.
223
+ tags: [uncertainty, webhooks, security, hmac, secret-rotation, calibration]