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.
- package/README.md +141 -173
- package/dist/{chunk-UECBSKTD.js → chunk-AY45YLYL.js} +105 -5
- package/dist/{chunk-LFKEV2YL.js → chunk-CJGHBY54.js} +7 -7
- package/dist/{chunk-JTVNKSMO.js → chunk-DVMGL3L7.js} +80 -6
- package/dist/{chunk-EQHRUV2I.js → chunk-IUPFMGUL.js} +152 -16
- package/dist/{chunk-7YCJSOK7.cjs → chunk-KCXQ5SAU.cjs} +21 -21
- package/dist/{chunk-4TWPCPRP.cjs → chunk-QMOPRKWD.cjs} +89 -15
- package/dist/{chunk-NJTYVNP4.cjs → chunk-VAS6KNJA.cjs} +216 -80
- package/dist/{chunk-CIXITJW6.cjs → chunk-X3LPZGHS.cjs} +106 -6
- package/dist/cli.cjs +51 -49
- package/dist/cli.js +9 -7
- package/dist/client.cjs +5 -6
- package/dist/client.d.cts +3 -3
- package/dist/client.d.ts +3 -3
- package/dist/client.js +4 -5
- package/dist/contracts/index.cjs +6 -3
- package/dist/contracts/index.d.cts +2 -2
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +7 -4
- package/dist/index.cjs +8 -5
- package/dist/index.d.cts +9 -8
- package/dist/index.d.ts +9 -8
- package/dist/index.js +8 -5
- package/dist/{logger-CCR9Mg1c.d.cts → logger-BByta-7V.d.cts} +23 -23
- package/dist/{logger-QJU7SBDz.d.ts → logger-BQf29BLe.d.ts} +23 -23
- package/dist/{reports-s2CTnGN8.d.ts → reports-B8TCJtPr.d.ts} +57 -13
- package/dist/{reports-4CejmOHf.d.cts → reports-DPpOoOux.d.cts} +57 -13
- package/dist/{tasks-CpaCJ6JE.d.ts → tasks-BmhWuMBD.d.cts} +24 -22
- package/dist/{tasks-CpaCJ6JE.d.cts → tasks-BmhWuMBD.d.ts} +24 -22
- package/dist/tasks.cjs +3 -4
- package/dist/tasks.d.cts +1 -1
- package/dist/tasks.d.ts +1 -1
- package/dist/tasks.js +2 -3
- package/docs/README.md +32 -12
- package/docs/methodology.md +14 -0
- package/docs/operator-guide.md +178 -0
- package/docs/replay-elo.md +12 -2
- package/docs/reviewing-bridgebench.md +173 -0
- package/docs/task-authoring.md +79 -1
- package/package.json +5 -3
- package/tasks/bullshit/public/crossed-metric-properties.yaml +192 -0
- package/tasks/bullshit/public/crossed-release-checksums.yaml +199 -0
- package/tasks/bullshit/public/fabricated-config-keys.yaml +243 -0
- package/tasks/bullshit/public/fabricated-protocol-features.yaml +272 -0
- package/tasks/bullshit/public/impossible-capacity-math.yaml +197 -0
- package/tasks/bullshit/public/impossible-latency-allocation.yaml +215 -0
- package/tasks/bullshit/public/loaded-approval-bypass.yaml +229 -0
- package/tasks/bullshit/public/loaded-migration-fallout.yaml +221 -0
- package/tasks/bullshit/public/pseudo-gc-heap-tuning.yaml +206 -0
- package/tasks/bullshit/public/pseudo-network-tuning.yaml +204 -0
- package/tasks/bullshit/public/reversed-alert-cascade.yaml +251 -0
- package/tasks/bullshit/public/reversed-dependency-failure.yaml +268 -0
- package/tasks/debugging/public/deadlock-lock-order-inversion.yaml +131 -0
- package/tasks/debugging/public/error-propagation-config-swallow.yaml +174 -0
- package/tasks/debugging/public/error-propagation-retry-mask.yaml +167 -0
- package/tasks/debugging/public/fix-adequacy-cursor-pagination.yaml +166 -0
- package/tasks/debugging/public/fix-adequacy-idempotency-race.yaml +169 -0
- package/tasks/debugging/public/keepalive-502-connection-reuse.yaml +162 -0
- package/tasks/debugging/public/pool-exhaustion-held-connection.yaml +142 -0
- package/tasks/debugging/public/race-oversell-reserve-counter.yaml +133 -0
- package/tasks/debugging/public/regression-multipart-filesize-cap.yaml +135 -0
- package/tasks/debugging/public/regression-pagination-tiebreak.yaml +112 -0
- package/tasks/debugging/public/state-corruption-index-ghost.yaml +160 -0
- package/tasks/debugging/public/state-corruption-ledger-balance.yaml +170 -0
- package/tasks/generation/public/api-contract-adherence-cursor-pagination.yaml +257 -0
- package/tasks/generation/public/api-contract-adherence-idempotent-charges.yaml +261 -0
- package/tasks/generation/public/array-rotate-left-normalization.yaml +166 -0
- package/tasks/generation/public/cache-interface-dropin.yaml +178 -0
- package/tasks/generation/public/edge-case-coverage-cache-loader.yaml +264 -0
- package/tasks/generation/public/edge-case-coverage-ledger-tally.yaml +231 -0
- package/tasks/generation/public/event-envelope-wire-compat.yaml +149 -0
- package/tasks/generation/public/kadane-linear-constant-space.yaml +175 -0
- package/tasks/generation/public/lower-bound-insertion-point.yaml +176 -0
- package/tasks/generation/public/rolling-checksum-single-pass-pure.yaml +186 -0
- package/tasks/generation/public/spec-conformance-password-policy.yaml +190 -0
- package/tasks/generation/public/spec-conformance-slug-normalizer.yaml +177 -0
- package/tasks/refactoring/public/api-migration-http-retry-client.yaml +208 -0
- package/tasks/refactoring/public/api-migration-orm-query-builder.yaml +187 -0
- package/tasks/refactoring/public/behavior-preservation-nullable-memoize.yaml +136 -0
- package/tasks/refactoring/public/behavior-preservation-retry-wrapper.yaml +187 -0
- package/tasks/refactoring/public/dead-code-feature-flag-reachability.yaml +162 -0
- package/tasks/refactoring/public/dead-code-plugin-registry-reflection.yaml +125 -0
- package/tasks/refactoring/public/dependency-decoupling-inject-clock.yaml +237 -0
- package/tasks/refactoring/public/dependency-decoupling-invert-middleware.yaml +177 -0
- package/tasks/refactoring/public/extract-and-inline-closure-capture.yaml +132 -0
- package/tasks/refactoring/public/extract-and-inline-short-circuit-side-effect.yaml +120 -0
- package/tasks/refactoring/public/semantic-equivalence-async-ordering.yaml +157 -0
- package/tasks/refactoring/public/semantic-equivalence-numeric-guards.yaml +115 -0
- package/tasks/security/public/authz-guard-chain-exposure.yaml +224 -0
- package/tasks/security/public/authz-object-scope-idor.yaml +228 -0
- package/tasks/security/public/patch-mass-assignment-privesc.yaml +226 -0
- package/tasks/security/public/patch-sqli-candidate-fixes.yaml +243 -0
- package/tasks/security/public/supply-lockfile-tamper-trace.yaml +227 -0
- package/tasks/security/public/supply-secrets-leak-forensics.yaml +205 -0
- package/tasks/security/public/taint-export-template-render.yaml +207 -0
- package/tasks/security/public/taint-webhook-outbound-fetch.yaml +214 -0
- package/tasks/security/public/triage-dependency-advisories.yaml +189 -0
- package/tasks/security/public/triage-sast-false-positives.yaml +257 -0
- package/tasks/security/public/vuln-path-sanitizer-escape.yaml +165 -0
- package/tasks/security/public/vuln-token-alg-confusion.yaml +252 -0
- package/tasks/speed/public/speed-api-diff-changelog.yaml +42 -0
- package/tasks/speed/public/speed-callbacks-to-async.yaml +39 -0
- package/tasks/speed/public/speed-class-to-hooks.yaml +47 -0
- package/tasks/speed/public/speed-config-schema-json.yaml +34 -0
- package/tasks/speed/public/speed-csv-report-generator.yaml +38 -0
- package/tasks/speed/public/speed-debounce-hook.yaml +28 -0
- package/tasks/speed/public/speed-log-triage-summary.yaml +35 -0
- package/tasks/speed/public/speed-openapi-fragment.yaml +38 -0
- package/tasks/speed/public/speed-pagination-cursor.yaml +40 -0
- package/tasks/speed/public/speed-rate-limiter-design.yaml +43 -0
- package/tasks/speed/public/speed-rest-client-module.yaml +42 -0
- package/tasks/speed/public/speed-slugify-helper.yaml +30 -0
- package/dist/chunk-4TWPCPRP.cjs.map +0 -1
- package/dist/chunk-7YCJSOK7.cjs.map +0 -1
- package/dist/chunk-CIXITJW6.cjs.map +0 -1
- package/dist/chunk-EQHRUV2I.js.map +0 -1
- package/dist/chunk-JTVNKSMO.js.map +0 -1
- package/dist/chunk-LFKEV2YL.js.map +0 -1
- package/dist/chunk-NJTYVNP4.cjs.map +0 -1
- package/dist/chunk-UECBSKTD.js.map +0 -1
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.cjs.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/contracts/index.cjs.map +0 -1
- package/dist/contracts/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tasks.cjs.map +0 -1
- 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]
|