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.
- package/CITATION.cff +15 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/chunk-4TWPCPRP.cjs +1097 -0
- package/dist/chunk-4TWPCPRP.cjs.map +1 -0
- package/dist/chunk-7YCJSOK7.cjs +398 -0
- package/dist/chunk-7YCJSOK7.cjs.map +1 -0
- package/dist/chunk-CIXITJW6.cjs +249 -0
- package/dist/chunk-CIXITJW6.cjs.map +1 -0
- package/dist/chunk-EQHRUV2I.js +1466 -0
- package/dist/chunk-EQHRUV2I.js.map +1 -0
- package/dist/chunk-JTVNKSMO.js +1096 -0
- package/dist/chunk-JTVNKSMO.js.map +1 -0
- package/dist/chunk-LFKEV2YL.js +398 -0
- package/dist/chunk-LFKEV2YL.js.map +1 -0
- package/dist/chunk-NJTYVNP4.cjs +1467 -0
- package/dist/chunk-NJTYVNP4.cjs.map +1 -0
- package/dist/chunk-UECBSKTD.js +244 -0
- package/dist/chunk-UECBSKTD.js.map +1 -0
- package/dist/cli.cjs +409 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +408 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.cjs +42 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +93 -0
- package/dist/client.d.ts +93 -0
- package/dist/client.js +42 -0
- package/dist/client.js.map +1 -0
- package/dist/contracts/index.cjs +47 -0
- package/dist/contracts/index.cjs.map +1 -0
- package/dist/contracts/index.d.cts +14 -0
- package/dist/contracts/index.d.ts +14 -0
- package/dist/contracts/index.js +47 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/index.cjs +171 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +214 -0
- package/dist/index.d.ts +214 -0
- package/dist/index.js +171 -0
- package/dist/index.js.map +1 -0
- package/dist/logger-CCR9Mg1c.d.cts +319 -0
- package/dist/logger-QJU7SBDz.d.ts +319 -0
- package/dist/reports-4CejmOHf.d.cts +454 -0
- package/dist/reports-s2CTnGN8.d.ts +454 -0
- package/dist/tasks-CpaCJ6JE.d.cts +151 -0
- package/dist/tasks-CpaCJ6JE.d.ts +151 -0
- package/dist/tasks.cjs +22 -0
- package/dist/tasks.cjs.map +1 -0
- package/dist/tasks.d.cts +39 -0
- package/dist/tasks.d.ts +39 -0
- package/dist/tasks.js +22 -0
- package/dist/tasks.js.map +1 -0
- package/docs/README.md +25 -0
- package/docs/glossary.md +49 -0
- package/docs/methodology.md +58 -0
- package/docs/private-packs.md +74 -0
- package/docs/replay-elo.md +79 -0
- package/docs/task-authoring.md +80 -0
- package/package.json +137 -0
- package/tasks/hallucination/public/boundary-coverage-audit.yaml +274 -0
- package/tasks/hallucination/public/boundary-migration-audit.yaml +284 -0
- package/tasks/hallucination/public/conflict-dependency-versions.yaml +324 -0
- package/tasks/hallucination/public/conflict-runbook-versions.yaml +229 -0
- package/tasks/hallucination/public/fabrication-agent-tools.yaml +224 -0
- package/tasks/hallucination/public/fabrication-api-surface.yaml +239 -0
- package/tasks/hallucination/public/fidelity-commit-attribution.yaml +304 -0
- package/tasks/hallucination/public/fidelity-config-drift.yaml +307 -0
- package/tasks/hallucination/public/missing-deploy-window.yaml +204 -0
- package/tasks/hallucination/public/missing-latency-baseline.yaml +239 -0
- package/tasks/hallucination/public/premise-quota-breach.yaml +202 -0
- package/tasks/hallucination/public/premise-rollback-cause.yaml +235 -0
- package/tasks/reasoning/public/constraint-capacity-allocation.yaml +196 -0
- package/tasks/reasoning/public/constraint-deployment-policy.yaml +203 -0
- package/tasks/reasoning/public/counterexample-authorization-rule.yaml +278 -0
- package/tasks/reasoning/public/counterexample-scheduler-starvation.yaml +290 -0
- package/tasks/reasoning/public/root-cache-tenant-leak.yaml +225 -0
- package/tasks/reasoning/public/root-event-ordering.yaml +184 -0
- package/tasks/reasoning/public/stateful-lease-handoff.yaml +213 -0
- package/tasks/reasoning/public/stateful-retry-budget.yaml +222 -0
- package/tasks/reasoning/public/synthesis-api-contract.yaml +214 -0
- package/tasks/reasoning/public/synthesis-permission-migration.yaml +190 -0
- package/tasks/reasoning/public/uncertainty-conflicting-telemetry.yaml +242 -0
- 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]
|