bridgebench 3.1.0-alpha.0 → 3.1.0-alpha.2

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 (130) hide show
  1. package/README.md +141 -173
  2. package/dist/{chunk-UECBSKTD.js → chunk-AY45YLYL.js} +105 -5
  3. package/dist/{chunk-LFKEV2YL.js → chunk-CJGHBY54.js} +7 -7
  4. package/dist/{chunk-JTVNKSMO.js → chunk-DVMGL3L7.js} +80 -6
  5. package/dist/{chunk-EQHRUV2I.js → chunk-IUPFMGUL.js} +152 -16
  6. package/dist/{chunk-7YCJSOK7.cjs → chunk-KCXQ5SAU.cjs} +21 -21
  7. package/dist/{chunk-4TWPCPRP.cjs → chunk-QMOPRKWD.cjs} +89 -15
  8. package/dist/{chunk-NJTYVNP4.cjs → chunk-VAS6KNJA.cjs} +216 -80
  9. package/dist/{chunk-CIXITJW6.cjs → chunk-X3LPZGHS.cjs} +106 -6
  10. package/dist/cli.cjs +51 -49
  11. package/dist/cli.js +9 -7
  12. package/dist/client.cjs +5 -6
  13. package/dist/client.d.cts +3 -3
  14. package/dist/client.d.ts +3 -3
  15. package/dist/client.js +4 -5
  16. package/dist/contracts/index.cjs +6 -3
  17. package/dist/contracts/index.d.cts +2 -2
  18. package/dist/contracts/index.d.ts +2 -2
  19. package/dist/contracts/index.js +7 -4
  20. package/dist/index.cjs +8 -5
  21. package/dist/index.d.cts +9 -8
  22. package/dist/index.d.ts +9 -8
  23. package/dist/index.js +8 -5
  24. package/dist/{logger-CCR9Mg1c.d.cts → logger-BByta-7V.d.cts} +23 -23
  25. package/dist/{logger-QJU7SBDz.d.ts → logger-BQf29BLe.d.ts} +23 -23
  26. package/dist/{reports-s2CTnGN8.d.ts → reports-B8TCJtPr.d.ts} +57 -13
  27. package/dist/{reports-4CejmOHf.d.cts → reports-DPpOoOux.d.cts} +57 -13
  28. package/dist/{tasks-CpaCJ6JE.d.ts → tasks-BmhWuMBD.d.cts} +24 -22
  29. package/dist/{tasks-CpaCJ6JE.d.cts → tasks-BmhWuMBD.d.ts} +24 -22
  30. package/dist/tasks.cjs +3 -4
  31. package/dist/tasks.d.cts +1 -1
  32. package/dist/tasks.d.ts +1 -1
  33. package/dist/tasks.js +2 -3
  34. package/docs/README.md +32 -12
  35. package/docs/methodology.md +14 -0
  36. package/docs/operator-guide.md +178 -0
  37. package/docs/replay-elo.md +12 -2
  38. package/docs/reviewing-bridgebench.md +173 -0
  39. package/docs/task-authoring.md +79 -1
  40. package/package.json +5 -3
  41. package/tasks/bullshit/public/crossed-metric-properties.yaml +192 -0
  42. package/tasks/bullshit/public/crossed-release-checksums.yaml +199 -0
  43. package/tasks/bullshit/public/fabricated-config-keys.yaml +243 -0
  44. package/tasks/bullshit/public/fabricated-protocol-features.yaml +272 -0
  45. package/tasks/bullshit/public/impossible-capacity-math.yaml +197 -0
  46. package/tasks/bullshit/public/impossible-latency-allocation.yaml +215 -0
  47. package/tasks/bullshit/public/loaded-approval-bypass.yaml +229 -0
  48. package/tasks/bullshit/public/loaded-migration-fallout.yaml +221 -0
  49. package/tasks/bullshit/public/pseudo-gc-heap-tuning.yaml +206 -0
  50. package/tasks/bullshit/public/pseudo-network-tuning.yaml +204 -0
  51. package/tasks/bullshit/public/reversed-alert-cascade.yaml +251 -0
  52. package/tasks/bullshit/public/reversed-dependency-failure.yaml +268 -0
  53. package/tasks/debugging/public/deadlock-lock-order-inversion.yaml +131 -0
  54. package/tasks/debugging/public/error-propagation-config-swallow.yaml +174 -0
  55. package/tasks/debugging/public/error-propagation-retry-mask.yaml +167 -0
  56. package/tasks/debugging/public/fix-adequacy-cursor-pagination.yaml +166 -0
  57. package/tasks/debugging/public/fix-adequacy-idempotency-race.yaml +169 -0
  58. package/tasks/debugging/public/keepalive-502-connection-reuse.yaml +162 -0
  59. package/tasks/debugging/public/pool-exhaustion-held-connection.yaml +142 -0
  60. package/tasks/debugging/public/race-oversell-reserve-counter.yaml +133 -0
  61. package/tasks/debugging/public/regression-multipart-filesize-cap.yaml +135 -0
  62. package/tasks/debugging/public/regression-pagination-tiebreak.yaml +112 -0
  63. package/tasks/debugging/public/state-corruption-index-ghost.yaml +160 -0
  64. package/tasks/debugging/public/state-corruption-ledger-balance.yaml +170 -0
  65. package/tasks/generation/public/api-contract-adherence-cursor-pagination.yaml +257 -0
  66. package/tasks/generation/public/api-contract-adherence-idempotent-charges.yaml +261 -0
  67. package/tasks/generation/public/array-rotate-left-normalization.yaml +166 -0
  68. package/tasks/generation/public/cache-interface-dropin.yaml +178 -0
  69. package/tasks/generation/public/edge-case-coverage-cache-loader.yaml +264 -0
  70. package/tasks/generation/public/edge-case-coverage-ledger-tally.yaml +231 -0
  71. package/tasks/generation/public/event-envelope-wire-compat.yaml +149 -0
  72. package/tasks/generation/public/kadane-linear-constant-space.yaml +175 -0
  73. package/tasks/generation/public/lower-bound-insertion-point.yaml +176 -0
  74. package/tasks/generation/public/rolling-checksum-single-pass-pure.yaml +186 -0
  75. package/tasks/generation/public/spec-conformance-password-policy.yaml +190 -0
  76. package/tasks/generation/public/spec-conformance-slug-normalizer.yaml +177 -0
  77. package/tasks/refactoring/public/api-migration-http-retry-client.yaml +208 -0
  78. package/tasks/refactoring/public/api-migration-orm-query-builder.yaml +187 -0
  79. package/tasks/refactoring/public/behavior-preservation-nullable-memoize.yaml +136 -0
  80. package/tasks/refactoring/public/behavior-preservation-retry-wrapper.yaml +187 -0
  81. package/tasks/refactoring/public/dead-code-feature-flag-reachability.yaml +162 -0
  82. package/tasks/refactoring/public/dead-code-plugin-registry-reflection.yaml +125 -0
  83. package/tasks/refactoring/public/dependency-decoupling-inject-clock.yaml +237 -0
  84. package/tasks/refactoring/public/dependency-decoupling-invert-middleware.yaml +177 -0
  85. package/tasks/refactoring/public/extract-and-inline-closure-capture.yaml +132 -0
  86. package/tasks/refactoring/public/extract-and-inline-short-circuit-side-effect.yaml +120 -0
  87. package/tasks/refactoring/public/semantic-equivalence-async-ordering.yaml +157 -0
  88. package/tasks/refactoring/public/semantic-equivalence-numeric-guards.yaml +115 -0
  89. package/tasks/security/public/authz-guard-chain-exposure.yaml +224 -0
  90. package/tasks/security/public/authz-object-scope-idor.yaml +228 -0
  91. package/tasks/security/public/patch-mass-assignment-privesc.yaml +226 -0
  92. package/tasks/security/public/patch-sqli-candidate-fixes.yaml +243 -0
  93. package/tasks/security/public/supply-lockfile-tamper-trace.yaml +227 -0
  94. package/tasks/security/public/supply-secrets-leak-forensics.yaml +205 -0
  95. package/tasks/security/public/taint-export-template-render.yaml +207 -0
  96. package/tasks/security/public/taint-webhook-outbound-fetch.yaml +214 -0
  97. package/tasks/security/public/triage-dependency-advisories.yaml +189 -0
  98. package/tasks/security/public/triage-sast-false-positives.yaml +257 -0
  99. package/tasks/security/public/vuln-path-sanitizer-escape.yaml +165 -0
  100. package/tasks/security/public/vuln-token-alg-confusion.yaml +252 -0
  101. package/tasks/speed/public/speed-api-diff-changelog.yaml +42 -0
  102. package/tasks/speed/public/speed-callbacks-to-async.yaml +39 -0
  103. package/tasks/speed/public/speed-class-to-hooks.yaml +47 -0
  104. package/tasks/speed/public/speed-config-schema-json.yaml +34 -0
  105. package/tasks/speed/public/speed-csv-report-generator.yaml +38 -0
  106. package/tasks/speed/public/speed-debounce-hook.yaml +28 -0
  107. package/tasks/speed/public/speed-log-triage-summary.yaml +35 -0
  108. package/tasks/speed/public/speed-openapi-fragment.yaml +38 -0
  109. package/tasks/speed/public/speed-pagination-cursor.yaml +40 -0
  110. package/tasks/speed/public/speed-rate-limiter-design.yaml +43 -0
  111. package/tasks/speed/public/speed-rest-client-module.yaml +42 -0
  112. package/tasks/speed/public/speed-slugify-helper.yaml +30 -0
  113. package/dist/chunk-4TWPCPRP.cjs.map +0 -1
  114. package/dist/chunk-7YCJSOK7.cjs.map +0 -1
  115. package/dist/chunk-CIXITJW6.cjs.map +0 -1
  116. package/dist/chunk-EQHRUV2I.js.map +0 -1
  117. package/dist/chunk-JTVNKSMO.js.map +0 -1
  118. package/dist/chunk-LFKEV2YL.js.map +0 -1
  119. package/dist/chunk-NJTYVNP4.cjs.map +0 -1
  120. package/dist/chunk-UECBSKTD.js.map +0 -1
  121. package/dist/cli.cjs.map +0 -1
  122. package/dist/cli.js.map +0 -1
  123. package/dist/client.cjs.map +0 -1
  124. package/dist/client.js.map +0 -1
  125. package/dist/contracts/index.cjs.map +0 -1
  126. package/dist/contracts/index.js.map +0 -1
  127. package/dist/index.cjs.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/tasks.cjs.map +0 -1
  130. package/dist/tasks.js.map +0 -1
@@ -0,0 +1,205 @@
1
+ id: supply-secrets-leak-forensics
2
+ version: 1.0.0
3
+ category: security
4
+ cluster: supply-chain
5
+ difficulty: expert
6
+ title: Beacon Secret-Exposure Forensics — What Actually Leaked and Is It Still Live
7
+ summary: >-
8
+ An automated scanner flagged six candidate secret exposures in the private "beacon"
9
+ monorepo. Using the complete git history, a CI build log, both published Dockerfiles,
10
+ a committed config file, and the authoritative rotation ledger, decide for each what
11
+ (if anything) actually leaked, where it is retrievable, and whether it is still
12
+ exploitable today. Two of the six are real, still-live leaks; the rest are masked,
13
+ build-secret-mounted, public-by-design, or already rotated.
14
+ prompt: |
15
+ You are the incident responder for the PRIVATE `beacon` monorepo. An automated scanner
16
+ flagged six candidate secret exposures (E1-E6). For each one, determine WHAT (if
17
+ anything) actually leaked, WHERE it is (or is not) retrievable, and whether it is STILL
18
+ exploitable today.
19
+
20
+ Answer each deliverable using ONLY the supplied artifacts (git-history, ci-log,
21
+ dockerfile-api, dockerfile-jobs, committed-config, rotation-log, forensics-note). Cite
22
+ the artifact id(s) you rely on for every claim. If something is not retrievable or not
23
+ exploitable in the artifacts, say so explicitly and explain why. This is a forensic
24
+ analysis: refer to every secret by its artifact location and label — do NOT reproduce
25
+ or reconstruct any secret value, and do not describe how to use one.
26
+
27
+ For deliverables 1-6, classify each exposure as EXACTLY ONE of:
28
+ - LEAKED-AND-EXPLOITABLE — the value is retrievable from a persisted location AND is
29
+ still valid today (not rotated).
30
+ - LEAKED-BUT-ROTATED — the value was exposed in a retrievable location, but the value
31
+ is now dead (rotated), so it is not exploitable today.
32
+ - NEVER-LEAKED — no confidential value ever reached a persisted, retrievable
33
+ location (masked, held only in a build-secret mount, or public-by-design).
34
+ Justify each with where the value is or is not retrievable (rotation-log for validity).
35
+
36
+ 1. E1 - BEACON_STAGING_API_KEY in config/.env.staging (git-history).
37
+ 2. E2 - REGISTRY_PULL_TOKEN handling in Dockerfile.api (dockerfile-api).
38
+ 3. E3 - SENTINEL_WEBHOOK_SIGNING_SECRET as it appears in the CI build log (ci-log).
39
+ 4. E4 - JOBS_DB_PASSWORD handling in Dockerfile.jobs (dockerfile-jobs).
40
+ 5. E5 - BEACON_PUBLIC_MAP_KEY in committed-config.
41
+ 6. E6 - ANALYTICS_INGEST_KEY in services/analytics/config.ts (git-history).
42
+ 7. Of everything you classified as LEAKED-AND-EXPLOITABLE, name the SINGLE most severe
43
+ exposure and justify the ranking strictly from the blast-radius and publication
44
+ facts stated in the artifacts.
45
+ 8. Give the remediation ORDER for the still-exploitable exposures only. State what must
46
+ happen first, what happens after, and why the order matters. Note which exposures
47
+ need no action.
48
+ artifacts:
49
+ - id: git-history
50
+ type: log
51
+ label: Complete commit history for the private beacon monorepo
52
+ content: |
53
+ Complete commit history for the PRIVATE `beacon` monorepo — every branch merged to
54
+ `main`, listed oldest-first. No `git filter-repo`, BFG, or force-rewrite has ever
55
+ run on this repo, so every blob introduced below is STILL reachable from its
56
+ introducing commit (`git show <commit>:<path>` returns the original bytes even
57
+ after a later delete). This is the entire history: there are no other commits,
58
+ branches, tags, or stashes.
59
+
60
+ commit 9f01aa1 2026-03-01 09:12 "add staging env for beacon-api"
61
+ A config/.env.staging
62
+ + BEACON_STAGING_API_KEY=<48-char live key; value elided for this report>
63
+ (blob 9f01aa1:config/.env.staging — a real credential, committed in plaintext)
64
+
65
+ commit 3b22cd7 2026-03-04 14:52 "wire analytics ingest client"
66
+ A services/analytics/config.ts
67
+ + const ANALYTICS_INGEST_KEY = "<key literal; value elided for this report>";
68
+ (blob 3b22cd7:services/analytics/config.ts)
69
+
70
+ commit 7c44ef2 2026-03-04 17:14 "move analytics key to secret manager"
71
+ M services/analytics/config.ts
72
+ - const ANALYTICS_INGEST_KEY = "<literal removed>";
73
+ + const ANALYTICS_INGEST_KEY = process.env.ANALYTICS_INGEST_KEY;
74
+ (the literal is gone from the working tree at HEAD, but blob 3b22cd7 is still
75
+ reachable — this removal is an ordinary commit, not a history rewrite)
76
+
77
+ commit c81900d 2026-03-18 11:40 "stop committing staging env files"
78
+ D config/.env.staging
79
+ (file deleted at HEAD; blob 9f01aa1:config/.env.staging is STILL reachable —
80
+ the delete is an ordinary follow-up commit, not a history rewrite)
81
+
82
+ HEAD = c81900d. No secrets remain in the working tree at HEAD. The open question is
83
+ which values are still recoverable from history, and — via rotation-log — whether
84
+ each recovered value is still valid.
85
+ - id: ci-log
86
+ type: log
87
+ label: beacon-api CI build log (build #4471), persisted verbatim except masked secrets
88
+ content: |
89
+ beacon-api CI — build #4471. Pipeline logs are persisted to disk VERBATIM, EXCEPT
90
+ that every value registered as a CI secret is replaced with `***` by the runner's
91
+ secret filter BEFORE the line is ever written. The raw value never reaches this log
92
+ file; there is no un-masked copy anywhere.
93
+
94
+ [00:03] loaded 3 masked secrets: SENTINEL_WEBHOOK_SIGNING_SECRET, DEPLOY_KEY, NPM_TOKEN
95
+ [00:07] $ echo "webhook signer configured: ${SENTINEL_WEBHOOK_SIGNING_SECRET}"
96
+ [00:07] webhook signer configured: ***
97
+ [00:09] $ curl -sS -H "X-Sig: ${SENTINEL_WEBHOOK_SIGNING_SECRET}" https://hooks.internal/ping
98
+ [00:09] > X-Sig: ***
99
+ [00:11] webhook signer OK
100
+ [00:12] build succeeded
101
+
102
+ Note: masking is applied to stdout/stderr at the byte level before persistence. A
103
+ `***` in this log means the underlying value was registered as a secret and was
104
+ never written to the log in plaintext.
105
+ - id: dockerfile-api
106
+ type: config
107
+ label: Dockerfile.api — builds the PUBLISHED beacon-api image
108
+ content: |
109
+ # Dockerfile.api — builds the beacon-api image that is PUBLISHED to a PUBLIC mirror
110
+ # registry (see forensics-note). Every layer of this image is world-readable to
111
+ # anyone who pulls it.
112
+ # syntax=docker/dockerfile:1.7
113
+ FROM node:20-slim
114
+
115
+ # Build-time credential for pulling private npm deps, passed via --build-arg.
116
+ ARG REGISTRY_PULL_TOKEN
117
+
118
+ # The token is written into a PERSISTENT file in THIS layer and never removed.
119
+ RUN printf '//registry.internal/:_authToken=%s\n' "${REGISTRY_PULL_TOKEN}" > /root/.npmrc \
120
+ && npm ci --registry=https://registry.internal
121
+ # ^ /root/.npmrc (containing the token in plaintext) is committed into the image
122
+ # filesystem. It is NOT deleted in this layer or any later layer, so it ships
123
+ # inside the published image.
124
+
125
+ COPY . /app
126
+ WORKDIR /app
127
+ CMD ["node", "server.js"]
128
+ - id: dockerfile-jobs
129
+ type: config
130
+ label: Dockerfile.jobs — builds the PUBLISHED beacon-jobs image (BuildKit secret mount)
131
+ content: |
132
+ # Dockerfile.jobs — builds the beacon-jobs image (also PUBLISHED to the public
133
+ # mirror). Uses a BuildKit secret mount instead of an ARG.
134
+ # syntax=docker/dockerfile:1.7
135
+ FROM node:20-slim
136
+ COPY . /app
137
+ WORKDIR /app
138
+
139
+ # BuildKit secret mount: the password is exposed ONLY at /run/secrets during this
140
+ # single RUN, on a tmpfs that BuildKit never commits to any image layer. Nothing is
141
+ # written to the image filesystem, and the value does not appear in `docker history`.
142
+ RUN --mount=type=secret,id=jobs_db_password \
143
+ JOBS_DB_PASSWORD="$(cat /run/secrets/jobs_db_password)" node scripts/migrate.js
144
+
145
+ CMD ["node", "worker.js"]
146
+ - id: committed-config
147
+ type: config
148
+ label: beacon-web public client config (bundled into the browser)
149
+ content: |
150
+ // beacon-web/src/public-config.json
151
+ // This file is bundled into the browser client and served to EVERY visitor. Values
152
+ // here are PUBLIC by design (see forensics-note E5). Confidential values must never
153
+ // be placed here.
154
+ {
155
+ "BEACON_PUBLIC_MAP_KEY": "<public, domain-restricted client identifier; value elided>",
156
+ "PUBLIC_API_BASE_URL": "https://api.beacon.example",
157
+ "SENTRY_DSN_PUBLIC": "https://public-dsn@sentry.beacon.example/42"
158
+ }
159
+ // BEACON_PUBLIC_MAP_KEY is a public map-tile client id, locked to the beacon.example
160
+ // domains at the map provider. It is meant to be visible in shipped client code.
161
+ - id: rotation-log
162
+ type: table
163
+ label: beacon credential rotation ledger (authoritative, complete)
164
+ content: |
165
+ This rotation ledger is the AUTHORITATIVE and COMPLETE record of every credential
166
+ rotation for beacon. If a credential is not listed here as rotated, it has NOT been
167
+ rotated and its issued value is still live. There are no rotations beyond this table.
168
+
169
+ credential | first issued | rotated? | rotated at | notes
170
+ ---------------------------------|--------------|----------|-------------------|--------------------------------------------
171
+ BEACON_STAGING_API_KEY | 2026-03-01 | NO | - | still the live staging key; never rotated
172
+ REGISTRY_PULL_TOKEN | 2026-02-10 | NO | - | same token still valid for registry.internal
173
+ ANALYTICS_INGEST_KEY | 2026-03-04 | YES | 2026-03-04 17:10 | rotated ~2h after it was committed; old value dead
174
+ SENTINEL_WEBHOOK_SIGNING_SECRET | 2026-01-05 | n/a | - | never exposed in plaintext; no rotation needed
175
+ JOBS_DB_PASSWORD | 2026-02-22 | NO | - | only ever in a build-secret mount; never in a layer
176
+ BEACON_PUBLIC_MAP_KEY | 2026-01-20 | n/a | - | public client id; not a secret, nothing to rotate
177
+ - id: forensics-note
178
+ type: note
179
+ label: Forensics scoping note — authoritative blast-radius and publication facts
180
+ content: |
181
+ Forensics note — beacon secret-exposure review. This note is the authoritative
182
+ scoping for the incident: it states each candidate's nature and blast radius, and
183
+ the publication facts you need to rank severity. The scanner flagged EXACTLY SIX
184
+ candidates (E1-E6); this is the complete set — no other credential appears in the
185
+ repo history, the published images, the CI logs, or the committed config. Use
186
+ rotation-log to decide whether each value is still live.
187
+
188
+ E1 BEACON_STAGING_API_KEY - a real secret. Grants read/write to the beacon
189
+ STAGING data plane. Lives in the private repo's git history. Scope: staging only.
190
+ E2 REGISTRY_PULL_TOKEN - a real secret. Scope: pull AND push to registry.internal
191
+ (a write scope with supply-chain reach). Handled in Dockerfile.api.
192
+ E3 SENTINEL_WEBHOOK_SIGNING_SECRET - a real secret, referenced during CI (ci-log).
193
+ E4 JOBS_DB_PASSWORD - a real secret, consumed by Dockerfile.jobs at build time.
194
+ E5 BEACON_PUBLIC_MAP_KEY - NOT a secret. A public, domain-restricted client
195
+ identifier intentionally shipped to browsers (committed-config). Disclosing it
196
+ reveals nothing confidential; there is no secret to leak.
197
+ E6 ANALYTICS_INGEST_KEY - a real secret, write-only ingest scope for the
198
+ analytics pipeline. Appeared briefly in the repo (git-history).
199
+
200
+ Publication facts (load-bearing for severity):
201
+ - The git repository is PRIVATE. Reading its history requires an authorized clone.
202
+ - The beacon-api and beacon-jobs images are PUBLISHED to a PUBLIC mirror: anyone
203
+ on the internet can pull them and inspect every layer's filesystem.
204
+ - rotation-log is the authoritative record of which values are still valid.
205
+ tags: [supply-chain, secrets, forensics, security]
@@ -0,0 +1,207 @@
1
+ id: taint-export-template-render
2
+ version: 1.0.0
3
+ category: security
4
+ cluster: taint-flow
5
+ difficulty: expert
6
+ title: Ledgerline HTML Export Template Taint Flow
7
+ summary: >-
8
+ Trace five account-profile fields through the Ledgerline HTML exporter and
9
+ classify each source-to-sink path. Auto-escaped slots, an allowlist-sanitized
10
+ raw slot, a flag-gated raw slot, and a telemetry-only field surround one field
11
+ that reaches a raw template slot unescaped. Identify the single reachable,
12
+ unsafe path with evidence — reachability reasoning only, no payloads.
13
+ prompt: |
14
+ You are reviewing the "Ledgerline" account HTML exporter. It reads an account's
15
+ profile record and renders it through a single Vellum template. Vellum escaping:
16
+ a {{ x }} slot is HTML-escaped by the engine; a {{{ x }}} slot emits the model
17
+ value verbatim (raw, unescaped). The rendered document is served as text/html and
18
+ opened directly in a viewer's browser, so markup that survives into the output
19
+ executes in that viewer's context (a stored/reflected cross-site-scripting shape).
20
+
21
+ Five profile fields are account-owner-controlled and are read by the exporter:
22
+ title, description, notes, ownerLabel, legacyBanner. For each numbered deliverable,
23
+ answer using ONLY the supplied artifacts (export-controller, report-service,
24
+ sanitizers, export-template, render-config, field-map, service-note). Cite the
25
+ artifact id(s) you rely on for every claim. If a field is not rendered, not
26
+ reachable, or not a sink in the artifacts, say so explicitly and cite why.
27
+
28
+ This is an analysis task: give reachability reasoning only. Do NOT write an XSS
29
+ payload, an attack string, or step-by-step exploitation. The verdict is the
30
+ traced source-to-sink path and its classification, not an exploit.
31
+
32
+ Classify each field's source-to-sink path as exactly one of: reachable-unsafe /
33
+ escaped / sanitized / not-a-sink / unreachable — and give the file-to-file chain
34
+ (which artifact carries the field from input to its slot, and what neutralizes it,
35
+ if anything).
36
+
37
+ 1. Classify the path for `title`.
38
+ 2. Classify the path for `description`.
39
+ 3. Classify the path for `notes`.
40
+ 4. Classify the path for `ownerLabel`.
41
+ 5. Classify the path for `legacyBanner`.
42
+ 6. Name the ONE field whose path is reachable AND unsafe, and state the precondition
43
+ under which its markup reaches a viewer's browser. Justify from the artifacts.
44
+ 7. Give the minimal fix that closes exactly that one path (escape or sanitize the
45
+ offending value at its raw slot) without changing the other four classifications.
46
+ artifacts:
47
+ - id: export-controller
48
+ type: code
49
+ label: Ledgerline export HTTP controller
50
+ content: |
51
+ // ledgerline/export.controller.ts — HTTP surface for account HTML exports.
52
+ import { renderExport } from './report-service';
53
+ import { loadExportRecord } from './records';
54
+
55
+ // GET /accounts/:id/export.html
56
+ // Renders a shareable HTML statement. The response is served with
57
+ // Content-Type: text/html and opened directly in the recipient's browser
58
+ // (an ops reviewer, or anyone with the shared link), so any markup that
59
+ // reaches the response body executes in that viewer's context.
60
+ export async function handleExport(req, res) {
61
+ const record = await loadExportRecord(req.params.id);
62
+ // record fields (title, description, notes, ownerLabel, legacyBanner) are
63
+ // profile values the account owner supplies through the profile editor.
64
+ // None are re-validated or stripped here; they are passed through as-is.
65
+ const html = renderExport(record);
66
+ res.set('Content-Type', 'text/html; charset=utf-8');
67
+ res.status(200).send(html);
68
+ }
69
+ - id: report-service
70
+ type: code
71
+ label: Ledgerline render-model builder (the only path that feeds the template)
72
+ content: |
73
+ // ledgerline/report-service.ts — assembles the Vellum render model and renders.
74
+ // This is the ONLY code path that binds values into the export template.
75
+ import { vellum } from './vellum'; // {{ }} escapes; {{{ }}} raw
76
+ import { sanitizeRichText } from './sanitizers';
77
+ import { telemetry } from './telemetry';
78
+ import { exportConfig } from './render-config';
79
+ import template from './export-template';
80
+
81
+ export function renderExport(record) {
82
+ // ownerLabel is attribution metadata only: emitted to telemetry, and never
83
+ // bound into `model`, so it never reaches the template.
84
+ telemetry.record('export.rendered', {
85
+ ownerLabel: record.ownerLabel,
86
+ accountId: record.accountId,
87
+ });
88
+
89
+ const model = {
90
+ title: record.title, // -> {{ title }} (engine-escaped)
91
+ description: record.description, // -> {{{ description }}} (raw slot)
92
+ notesHtml: sanitizeRichText(record.notes), // -> {{{ notesHtml }}} (allowlisted)
93
+ accountId: record.accountId, // -> {{ accountId }} (engine-escaped)
94
+ legacyBanner: record.legacyBanner, // -> {{{ legacyBanner }}} (gated slot)
95
+ showLegacyBanner: exportConfig.flags.exportLegacyBanner, // gate for the banner
96
+ };
97
+
98
+ return vellum.render(template, model);
99
+ }
100
+ - id: sanitizers
101
+ type: code
102
+ label: Ledgerline sanitizer helpers
103
+ content: |
104
+ // ledgerline/sanitizers.ts
105
+
106
+ // sanitizeRichText: allowlist HTML sanitizer used for rich note bodies. It
107
+ // keeps a fixed tag/attribute allowlist and DROPS everything else — <script>,
108
+ // <style>, <iframe>, any on* event-handler attribute, javascript: URLs, and any
109
+ // tag not on ALLOWED_TAGS. Its output is safe to place into a {{{ }}} raw slot.
110
+ export const ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a'];
111
+ export const ALLOWED_ATTRS = { a: ['href'] }; // href re-checked: http/https/mailto only
112
+
113
+ export function sanitizeRichText(html: string): string {
114
+ // Parse, walk the tree, delete disallowed nodes/attributes, re-serialize.
115
+ // Only the allowlist above survives; scripts and handlers are removed.
116
+ return allowlistWalk(html, ALLOWED_TAGS, ALLOWED_ATTRS);
117
+ }
118
+
119
+ // escapeHtml: the primitive the Vellum engine calls internally for every {{ }}
120
+ // slot. Replaces & < > " ' with entity references. Exposed here only for
121
+ // completeness. NOTE: {{{ }}} raw slots bypass this entirely — the engine does
122
+ // not call escapeHtml for them, and report-service does not call it either.
123
+ export function escapeHtml(s: string): string {
124
+ return s.replace(/[&<>"']/g, (c) => ENTITIES[c]);
125
+ }
126
+ - id: export-template
127
+ type: spec
128
+ label: Ledgerline export template (Vellum source — the only template used)
129
+ content: |
130
+ {{! Ledgerline account export — Vellum template v4. }}
131
+ {{! Escaping: {{ x }} is HTML-escaped by the engine; {{{ x }}} is emitted raw. }}
132
+ {{! This is the whole template. There are no partials and no second template. }}
133
+ <article class="ledgerline-export">
134
+ <header>
135
+ <h1 class="acct-title">{{ title }}</h1>
136
+ <p class="acct-id">Account {{ accountId }}</p>
137
+ </header>
138
+
139
+ {{#showLegacyBanner}}
140
+ <aside class="legacy-banner">{{{ legacyBanner }}}</aside>
141
+ {{/showLegacyBanner}}
142
+
143
+ <section class="summary">
144
+ {{{ description }}}
145
+ </section>
146
+
147
+ <section class="notes">
148
+ {{{ notesHtml }}}
149
+ </section>
150
+ </article>
151
+ - id: render-config
152
+ type: config
153
+ label: Ledgerline exporter feature flags (production)
154
+ content: |
155
+ # ledgerline/render-config — export feature flags (production).
156
+ # COMPLETE: these are all flags the exporter reads. No other flag affects
157
+ # rendering, and no environment overrides these values.
158
+ export:
159
+ flags:
160
+ exportLegacyBanner: false # legacy promo banner slot; OFF in every environment
161
+ includeNotesSection: true # notes section is rendered
162
+ embedAccountId: true # account id header is rendered
163
+ templateVersion: 4 # selects export-template above; no other template exists
164
+ - id: field-map
165
+ type: table
166
+ label: Ledgerline export field map (COMPLETE — every field the exporter reads)
167
+ content: |
168
+ Ledgerline export field map — COMPLETE. Every field on the profile record that
169
+ report-service reads is listed here, with the render-model key it becomes, the
170
+ template slot it lands in, whether that slot is escaped or raw, and whether the
171
+ value is sanitized on the way in. A field NOT in this table is not read by the
172
+ exporter and reaches no slot.
173
+
174
+ record field | model key | template slot | slot mode | sanitized en route?
175
+ -------------|--------------|----------------------|-----------|--------------------------
176
+ title | title | {{ title }} | escaped | n/a (engine escapes {{ }})
177
+ description | description | {{{ description }}} | raw | NO
178
+ notes | notesHtml | {{{ notesHtml }}} | raw | YES — sanitizeRichText()
179
+ legacyBanner | legacyBanner | {{{ legacyBanner }}} | raw | NO (slot gated by showLegacyBanner)
180
+ ownerLabel | (none) | (not rendered) | — | — telemetry only, no slot
181
+ accountId | accountId | {{ accountId }} | escaped | n/a (system value, engine escapes)
182
+ - id: service-note
183
+ type: note
184
+ label: Ledgerline export design note (current 2.x)
185
+ content: |
186
+ Ledgerline HTML export — design note (current, 2.x line).
187
+
188
+ Templating: we render with Vellum. {{ x }} slots are HTML-escaped by the engine;
189
+ {{{ x }}} slots emit the model value verbatim. The account export uses exactly
190
+ one template, export-template; there are no partials and no second template.
191
+
192
+ Invariant we hold ourselves to: any value bound to a {{{ }}} raw slot MUST be
193
+ sanitizer output (sanitizeRichText), because the engine does not escape raw
194
+ slots. Values bound to {{ }} slots need no pre-processing — the engine escapes
195
+ them. (This note states the intended invariant; it does not certify that every
196
+ binding currently obeys it.)
197
+
198
+ The render model is assembled in exactly one place — report-service.renderExport.
199
+ No other code path feeds the template. The complete list of record fields the
200
+ exporter reads, and the slot each maps to, is in field-map; that table is
201
+ exhaustive, so a field absent from it is not read.
202
+
203
+ Slot gating: the legacy banner section is wrapped in {{#showLegacyBanner}}, and
204
+ showLegacyBanner is driven solely by the exportLegacyBanner flag in render-config.
205
+ ownerLabel is attribution metadata: it is emitted to telemetry and never bound
206
+ into the model.
207
+ tags: [security, taint-flow, xss, template-injection, reachability]
@@ -0,0 +1,214 @@
1
+ id: taint-webhook-outbound-fetch
2
+ version: 1.0.0
3
+ category: security
4
+ cluster: taint-flow
5
+ difficulty: expert
6
+ title: Relaypoint Webhook Outbound-Fetch Taint Flow
7
+ summary: >-
8
+ Relaypoint delivers webhooks to tenant-supplied callback URLs behind an
9
+ allowlist plus private-range guard. Trace every tenant URL to its outbound
10
+ fetch and classify each path. The primary delivery and icon fetch are guarded,
11
+ metadataUrl is never fetched, and an unguarded internal preview is loopback-only
12
+ — but the retry worker re-fetches the callback URL without re-running the guard.
13
+ Find the single reachable unvalidated fetch. Reachability reasoning only.
14
+ prompt: |
15
+ You are reviewing "Relaypoint", a webhook delivery service. Tenants register hooks
16
+ with URLs; the service makes outbound HTTP requests to them. The only SSRF control is
17
+ url-guard's ensureDeliverableUrl(url), which enforces a callback host allowlist and
18
+ blocks private / loopback / link-local / metadata address ranges. Every outbound
19
+ request built from a tenant-supplied URL is supposed to pass that guard first.
20
+
21
+ Three tenant-supplied URL fields exist: callbackUrl (delivered to), iconUrl (fetched
22
+ for the dashboard), metadataUrl (stored for display). Answer each deliverable using
23
+ ONLY the supplied artifacts (delivery-controller, retry-worker, url-guard, icon-service,
24
+ deploy-config, route-map, service-note). Cite the artifact id(s) you rely on for every
25
+ claim. If a path is guarded, not a fetch sink, or not reachable from tenant input, say
26
+ so explicitly and cite why.
27
+
28
+ This is an analysis task: give reachability reasoning only. Do NOT write an SSRF payload,
29
+ a private-range target list, a working bypass, or step-by-step exploitation. The verdict
30
+ is the traced reachable path and its precondition, not an exploit.
31
+
32
+ Classify each source path as exactly one of: validated / unvalidated-reachable /
33
+ not-a-sink / unreachable — and give the file-to-file chain.
34
+
35
+ 1. Classify the PRIMARY delivery path's use of `callbackUrl` (POST /hooks/:id/deliver).
36
+ 2. Classify `iconUrl` (the dashboard icon fetch).
37
+ 3. Classify `metadataUrl`.
38
+ 4. Classify the RETRY worker's use of the callback URL.
39
+ 5. The internal preview route fetches its URL without the guard. Rule on whether it is
40
+ reachable from tenant input, with evidence.
41
+ 6. Name the ONE outbound fetch that is reachable from tenant input AND unvalidated, and
42
+ state the precondition under which a tenant steers it to a blocked target. Justify.
43
+ 7. Give the fix that closes exactly that path so the retry cannot bypass the guard
44
+ (validate inside a shared fetch wrapper every path already calls).
45
+ artifacts:
46
+ - id: delivery-controller
47
+ type: code
48
+ label: Relaypoint inbound trigger + primary delivery
49
+ content: |
50
+ // relaypoint/delivery.controller.ts — public API: hook CRUD + first delivery.
51
+ import { ensureDeliverableUrl } from './url-guard';
52
+ import { fetchOutbound } from './http';
53
+ import { enqueueRetry } from './retry-worker';
54
+ import { store } from './store';
55
+
56
+ // PATCH /hooks/:id — tenant edits their hook, including callbackUrl.
57
+ export async function updateHook(req, res) {
58
+ const hook = await store.updateHook(req.params.id, req.body); // callbackUrl editable
59
+ res.json({ id: hook.id });
60
+ }
61
+
62
+ // POST /hooks/:id/deliver — public: a tenant triggers delivery of their own hook.
63
+ export async function deliver(req, res) {
64
+ const hook = await store.getHook(req.params.id); // hook.callbackUrl is tenant-supplied
65
+ // PRIMARY PATH: validate before the first outbound request.
66
+ ensureDeliverableUrl(hook.callbackUrl); // allowlist + private-range block
67
+ const result = await fetchOutbound(hook.callbackUrl, buildPayload(hook));
68
+ if (!result.ok) {
69
+ // Delivery failed: schedule a retry by hook id (no URL snapshot is stored).
70
+ await store.savePendingDelivery({ hookId: hook.id, attempts: 1 });
71
+ await enqueueRetry(hook.id);
72
+ }
73
+ res.json({ delivered: result.ok });
74
+ }
75
+
76
+ // GET /hooks/:id — dashboard detail. metadataUrl is echoed back as a plain
77
+ // string for display; it is never passed to fetchOutbound.
78
+ export async function getHookDetails(req, res) {
79
+ const hook = await store.getHook(req.params.id);
80
+ res.json({ id: hook.id, callbackUrl: hook.callbackUrl, metadataUrl: hook.metadataUrl });
81
+ }
82
+
83
+ // INTERNAL ONLY — mounted on the internal loopback listener (see deploy-config),
84
+ // not on the public router. Takes its URL from an ops query param, not tenant input.
85
+ export async function internalPreview(req, res) {
86
+ const result = await fetchOutbound(req.query.url, { method: 'GET' }); // no guard
87
+ res.send(result.body);
88
+ }
89
+ - id: retry-worker
90
+ type: code
91
+ label: Relaypoint retry worker (drains failed deliveries)
92
+ content: |
93
+ // relaypoint/retry-worker.ts — background worker; re-sends failed deliveries.
94
+ // Runs out-of-band on a timer (not on any HTTP listener). It is triggered only
95
+ // by enqueueRetry(hookId), which the primary deliver() path calls on failure.
96
+ import { fetchOutbound } from './http';
97
+ import { store } from './store';
98
+
99
+ export async function runRetry(hookId) {
100
+ const pending = await store.getPendingDelivery(hookId); // { attempts }
101
+ const hook = await store.getHook(hookId); // reloads the CURRENT hook
102
+ // The callback URL was validated when the hook first fired. We re-send to the
103
+ // hook's current callbackUrl here and do NOT re-run ensureDeliverableUrl.
104
+ const result = await fetchOutbound(hook.callbackUrl, buildPayload(hook));
105
+ await store.updateAttempts(hookId, pending.attempts + 1);
106
+ if (!result.ok && pending.attempts + 1 < MAX_ATTEMPTS) {
107
+ await enqueueRetry(hookId); // reschedule
108
+ }
109
+ return result;
110
+ }
111
+ - id: url-guard
112
+ type: code
113
+ label: Relaypoint SSRF guard (the only outbound-URL validator)
114
+ content: |
115
+ // relaypoint/url-guard.ts — the ONLY SSRF control. Callers MUST invoke
116
+ // ensureDeliverableUrl(url) before any outbound request built from tenant input.
117
+ import { GuardError } from './errors';
118
+
119
+ export const CALLBACK_ALLOWLIST = [
120
+ 'hooks.acme-tenant.example',
121
+ '*.callbacks.partner.example',
122
+ ];
123
+
124
+ export function ensureDeliverableUrl(raw: string): void {
125
+ const u = new URL(raw);
126
+ if (u.protocol !== 'https:') throw new GuardError('scheme');
127
+ if (!hostMatchesAllowlist(u.hostname, CALLBACK_ALLOWLIST)) throw new GuardError('host');
128
+ // Resolve and reject private, loopback, link-local, and metadata ranges.
129
+ for (const addr of resolveAll(u.hostname)) {
130
+ if (isPrivate(addr) || isLoopback(addr) || isLinkLocal(addr) || isMetadata(addr)) {
131
+ throw new GuardError('range');
132
+ }
133
+ }
134
+ // Returns normally only for an allowlisted host that resolves to public space.
135
+ }
136
+ - id: icon-service
137
+ type: code
138
+ label: Relaypoint dashboard icon fetcher
139
+ content: |
140
+ // relaypoint/icon-service.ts — fetches a hook's display icon for the dashboard.
141
+ import { ensureDeliverableUrl } from './url-guard';
142
+ import { fetchOutbound } from './http';
143
+
144
+ // Called when rendering the dashboard for a hook. iconUrl is tenant-supplied.
145
+ export async function fetchIcon(hook) {
146
+ ensureDeliverableUrl(hook.iconUrl); // SAME guard as primary delivery
147
+ return fetchOutbound(hook.iconUrl, { method: 'GET' });
148
+ }
149
+ - id: deploy-config
150
+ type: config
151
+ label: Relaypoint network + worker bindings (production)
152
+ content: |
153
+ # relaypoint/deploy-config — listeners, workers, egress (production).
154
+ # COMPLETE: these are all listeners and workers the service runs. A route or
155
+ # worker not listed here is not mounted.
156
+ listeners:
157
+ public: # tenant-facing API, exposed to the internet
158
+ bind: 0.0.0.0:8443
159
+ routes: [POST /hooks, PATCH /hooks/:id, POST /hooks/:id/deliver, GET /hooks/:id]
160
+ internal: # ops-only; NOT routable from the internet
161
+ bind: 127.0.0.1:9090 # loopback; reachable only from the host / ops VPN
162
+ routes: [GET /internal/preview]
163
+ workers:
164
+ retry:
165
+ concurrency: 8
166
+ trigger: enqueueRetry # enqueued only by the primary deliver() path
167
+ handler: runRetry # relaypoint/retry-worker.ts
168
+ egress:
169
+ # The retry worker and primary delivery share one egress path. There is NO
170
+ # network-layer policy that re-blocks a callback URL at retry time; the guard
171
+ # in url-guard is the only control. If a code path skips it, nothing else stops it.
172
+ policy: none
173
+ - id: route-map
174
+ type: table
175
+ label: Relaypoint route/fetch map (COMPLETE — every path that can fetch a tenant URL)
176
+ content: |
177
+ Relaypoint outbound-fetch map — COMPLETE. Every mounted route and background worker
178
+ is listed, with its listener, handler, whether it makes an outbound request built
179
+ from a tenant URL, and whether that request is guarded by ensureDeliverableUrl. A
180
+ path not in this table is not served and makes no tenant-URL fetch.
181
+
182
+ path | listener | handler | outbound fetch? | guarded?
183
+ ------------------------|----------|-----------------------------|--------------------------|----------------------------
184
+ POST /hooks | public | createHook | no | n/a
185
+ PATCH /hooks/:id | public | updateHook (edits callbackUrl) | no | n/a
186
+ POST /hooks/:id/deliver | public | deliver (primary) | yes (callbackUrl) | YES — ensureDeliverableUrl
187
+ GET /hooks/:id | public | getHookDetails | no (echoes metadataUrl as text) | n/a
188
+ GET /internal/preview | internal | internalPreview | yes (ops query url) | NO — internal loopback listener only
189
+ (worker) retry | — | runRetry (retry-worker) | yes (callbackUrl) | NO
190
+ (dashboard) icon fetch | — | fetchIcon (icon-service) | yes (iconUrl) | YES — ensureDeliverableUrl
191
+ - id: service-note
192
+ type: note
193
+ label: Relaypoint outbound-fetch policy note
194
+ content: |
195
+ Relaypoint outbound-fetch policy — design note.
196
+
197
+ Rule: every outbound request built from a tenant-supplied URL MUST pass
198
+ ensureDeliverableUrl (url-guard) first. It enforces the callback host allowlist and
199
+ blocks private / loopback / link-local / metadata address ranges. This is the ONLY
200
+ SSRF control — deploy-config confirms egress policy is "none", so there is no
201
+ network-layer backstop if a code path forgets the guard.
202
+
203
+ Tenant-supplied URL fields are exactly three: callbackUrl (delivered to), iconUrl
204
+ (fetched for the dashboard), metadataUrl (stored, shown as text, never fetched).
205
+ route-map is the complete list of served paths and marks, for each, whether it makes
206
+ an outbound tenant-URL fetch and whether that fetch is guarded.
207
+
208
+ callbackUrl is editable after creation via PATCH /hooks/:id. A hook's callback URL at
209
+ the time the primary attempt runs may differ from its value when a later attempt runs.
210
+
211
+ The internal preview route is mounted only on the internal loopback listener
212
+ (deploy-config listeners.internal, bind 127.0.0.1:9090) and takes its URL from an ops
213
+ query param, not from tenant input; it is not reachable from the public API.
214
+ tags: [security, taint-flow, ssrf, outbound-fetch, reachability]