forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrenderer.ts
More file actions
306 lines (291 loc) · 12.7 KB
/
Copy pathrenderer.ts
File metadata and controls
306 lines (291 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
import Mustache from "mustache";
import type { RenderedMessage, TemplateContext } from "../types/index.js";
import { applyPipeline } from "./filters.js";
import { FILTER_RE } from "./filter-regex.js";
import { logger } from "../logger.js";
export interface CompiledTemplate {
text: string;
// Placeholder for future block-kit support.
blocks?: unknown;
}
export interface Renderer {
render(tmpl: CompiledTemplate, ctx: TemplateContext): RenderedMessage;
}
// A7 / HF13-D1: the filter-pipeline regex lives in `./filter-regex.ts` so
// both the renderer (here, via extractFilters) and the rule-loader (via
// validateFilterNames) share a single source of truth. The leading
// `(?<!\{)` and trailing `(?!\})` negative look-arounds prevent FILTER_RE
// from matching inside a `{{{ x | f }}}` triple-brace span. rule-loader's
// `validateTripleBrace` already rejects most shapes at load time, but
// loosening that validation (or a template slipping past it through an
// edge case) would otherwise strip a brace and corrupt splat-replacement.
// Defensive belt-and-braces.
// Slack incoming webhooks accept a little over 40KB. We leave headroom so the
// JSON wrapping, quoting, and escaping never push us over the real ceiling.
const SLACK_BODY_SOFT_LIMIT_BYTES = 38 * 1024;
// Unique sentinel — must not appear in any legitimate template or context
// value, and must be inert to Mustache (no `{{`, `}}`, `&`, `<`, `>`, `#`, `^`,
// `/`). Previously `\u0000` null bytes, but those can be stripped or
// rejected by downstream JSON/Slack transports in unpredictable ways. We use
// a zero-width-no-break-space (BOM, U+FEFF) as the fence instead — valid
// Unicode, printable-but-invisible, and vanishingly unlikely to appear in
// legitimate template text. The scoped process-unique ID rules out
// accidental collisions.
const SENTINEL_FENCE = "\uFEFF";
const SENTINEL_PROCESS_ID = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
const FILTER_SENTINEL_PREFIX = `${SENTINEL_FENCE}OPS_FILTER_${SENTINEL_PROCESS_ID}_`;
const FILTER_SENTINEL_SUFFIX = `_END${SENTINEL_FENCE}`;
const DANGEROUS_PATH_SEGMENTS = new Set([
"__proto__",
"prototype",
"constructor",
]);
/** Own-property check — prevents prototype-chain walking from templates. */
function hasOwn(obj: unknown, key: string): boolean {
return (
obj != null &&
(typeof obj === "object" || typeof obj === "function") &&
Object.prototype.hasOwnProperty.call(obj, key)
);
}
/**
* Array `.length` is a special case — it IS an own property of arrays, so
* the generic own-property guard would allow it. Historically the filter
* path rejected it while Mustache `{{#foo.length}}` sections silently
* worked (Mustache bypasses our resolvePath). To unify the two rendering
* paths, we explicitly PERMIT array `.length` reads here — the
* inconsistency previously confused template authors when the same
* expression surfaced `undefined` via the filter pipeline but a truthy
* count via a section. Non-array `.length` is still blocked (strings get
* a length via the shape check below) so we can't leak string metadata.
*/
function isArrayLengthAccess(obj: unknown, key: string): boolean {
return Array.isArray(obj) && key === "length";
}
/**
* Two-phase expansion to prevent template injection via filter output.
*
* The old single-phase approach spliced filter output directly into the
* template string, then handed the result to Mustache. If a filtered value
* happened to contain `{{something}}`, Mustache would re-interpret it,
* exposing the entire context to a partially-controlled source. This is a
* double-interpolation bug — filter output was being eval'd a second time.
*
* Instead, we:
* 1. Replace each `{{ x | f }}` expression with an opaque sentinel and
* stash the filtered value in a map.
* 2. Let Mustache render the remaining template (no pipe expressions left).
* 3. Replace sentinels in the Mustache output with the stashed values.
*
* Filter output therefore never reaches Mustache and can contain any
* characters — including literal `{{` sequences — without being re-evaluated.
*
* Authors should still pipe user-controlled fields through `slackEscape` when
* the surrounding template targets Slack mrkdwn. Fields that are trust-by-
* default in our signal space: `rule.*`, `event.*`, `env.*`. Fields that
* should be escaped when user-controlled: `signal.*` content originating from
* third-party probe payloads (CI logs, webhook bodies, etc.).
*/
function extractFilters(
text: string,
ctx: TemplateContext,
): { template: string; values: Map<string, string> } {
const values = new Map<string, string>();
let idx = 0;
const template = text.replace(FILTER_RE, (_match, path, pipelineRaw) => {
const pathStr = String(path).trim();
const value = resolvePath(ctx, pathStr);
const stages = String(pipelineRaw)
.split("|")
.map((s) => s.trim())
.filter(Boolean);
// HF-A4: filter failure MUST abort rendering. The previous
// `[filter-error]` sentinel shipped that literal string to Slack —
// operators got "Deploy failed for [filter-error] at [filter-error]"
// which is worse than no alert at all. Throwing propagates out of
// `Renderer.render` and the dispatcher's try/catch in `sendToTargets`
// treats it as a target failure: no dedupe gets recorded, so the next
// tick retries rather than the outage getting wallpapered over. Keep
// the error log so operators can grep for the root cause.
let out: string;
try {
out = applyPipeline(value, stages);
} catch (err) {
logger.error("renderer: filter pipeline threw, aborting render", {
path: pathStr,
stages,
err: String(err),
});
throw err;
}
const key = `${FILTER_SENTINEL_PREFIX}${idx++}${FILTER_SENTINEL_SUFFIX}`;
values.set(key, out);
return key;
});
return { template, values };
}
function splatSentinels(rendered: string, values: Map<string, string>): string {
let out = rendered;
for (const [key, val] of values) {
// split+join avoids regex-escaping concerns and also handles the case where
// a sentinel is referenced more than once (which shouldn't happen today,
// but is cheap to be safe about).
out = out.split(key).join(val);
}
return out;
}
function resolvePath(obj: unknown, path: string): unknown {
const segments = path.split(".");
for (const seg of segments) {
if (DANGEROUS_PATH_SEGMENTS.has(seg)) {
logger.warn("renderer: refusing to walk dangerous path segment", {
path,
segment: seg,
});
return undefined;
}
}
let cur: unknown = obj;
for (const seg of segments) {
if (cur == null) {
logger.debug("renderer: missing path during resolve", { path, at: seg });
return undefined;
}
// Own-property-only descent prevents prototype walking entirely
// (blocks `.slice`/`.toString` on arrays/objects and any prototype
// pollution accessors the `DANGEROUS_PATH_SEGMENTS` deny-list might
// not cover).
//
// Array `.length` is permitted as a documented exception: it's an
// own property AND Mustache sections already accept it, so the
// filter pipeline must accept it too for symmetry. Without this,
// `{{ signal.failed.length | truncateUtf8 10 }}` returned empty
// while `{{#signal.failed.length}}…{{/signal.failed.length}}`
// rendered truthy — a silent trap for template authors.
if (isArrayLengthAccess(cur, seg)) {
cur = (cur as unknown[]).length;
continue;
}
if (!hasOwn(cur, seg)) {
logger.debug("renderer: refusing non-own-property access", {
path,
at: seg,
});
return undefined;
}
cur = (cur as Record<string, unknown>)[seg];
}
if (cur === undefined) {
logger.debug("renderer: path resolved to undefined", { path });
}
return cur;
}
function enforceSoftLimit(text: string): string {
const bytes = Buffer.byteLength(text, "utf8");
if (bytes <= SLACK_BODY_SOFT_LIMIT_BYTES) return text;
logger.warn("renderer: rendered body exceeds Slack soft limit, truncating", {
bytes,
limit: SLACK_BODY_SOFT_LIMIT_BYTES,
});
const suffix = "\n[truncated]";
const suffixBytes = Buffer.byteLength(suffix, "utf8");
const budget = SLACK_BODY_SOFT_LIMIT_BYTES - suffixBytes;
// Walk codepoints so we never split mid-codepoint.
let used = 0;
let out = "";
for (const ch of text) {
const n = Buffer.byteLength(ch, "utf8");
if (used + n > budget) break;
out += ch;
used += n;
}
return out + suffix;
}
/**
* JSON-safe post-processing: Mustache outputs a plain string; the structured
* payload wrapper ensures that string is only ever serialized via JSON.stringify,
* so all control chars (0x00-0x1F), quotes, and backslashes escape correctly.
*
* Threat model — Slack mrkdwn `|` injection via signal.* paths:
*
* `signal.*` is internally trusted: probe payloads originate from code we
* control (deploy/e2e/drift probes, CI job metadata the orchestrator
* stamps onto WriteOutcome). Operator-controlled fields like `signal.runUrl`
* / `signal.runId` / `signal.jobUrl` MUST NOT carry user input. Because
* `|` is the Slack-mrkdwn separator inside `<url|text>` link syntax,
* any value crossing the trust boundary that contained a literal `|`
* would break out of the link and let an attacker forge channel mentions
* or disguised links.
*
* Mustache's default `{{ }}` performs HTML escape (`<`/`>`/`&`) which
* blunts link-angle-bracket forgery but does NOT escape `|` — that's the
* gap F1.8 regression tests pin. Triple-brace `{{{ }}}` is explicit
* opt-out, gate-kept by `rule-loader.validateTripleBrace` against
* `slackSafeFields` (a per-dimension allow-list of known-safe signal keys).
*
* New signal sources added to probes MUST either:
* - flow through `| slackEscape` in every template that renders them, OR
* - be added to `slackSafeFields` ONLY when the field is structurally
* produced by our own code with no user-input path (e.g. a fixed
* enum, a GHCR image ref, a Railway service name).
*
* The F1.8 regression test (`double-brace HTML-escapes signal.*` in
* renderer.test.ts) will fail the moment someone changes the default
* escape; that's the trip-wire for the threat model above.
*/
/**
* Strip any literal U+FEFF BOM characters from the pre-rendered template
* text. The renderer uses U+FEFF as a sentinel-fence character in the
* two-phase filter-expansion scheme; a BOM sneaking in via template
* authoring (or via a filter value legitimately carrying one) would
* collide with the sentinel delimiters and corrupt splat-replacement.
*
* We strip only from the static template body — filter-produced values
* go through the sentinel map, not through Mustache, so they're
* structurally separated.
*/
function stripBom(s: string): string {
return s.replace(/\uFEFF/g, "");
}
/**
* Build the source-env prefix prepended to every alert body. Operators
* triaging a red probe need to know whether staging or production is
* affected — the per-rule YAML templates never carried that context, so a
* "smoke test failing" ping was ambiguous about which environment broke.
*
* The label is the harness's own deploy environment (derived from
* RAILWAY_ENVIRONMENT_NAME in the orchestrator). An empty / missing value
* falls back to `unknown` rather than dropping the tag entirely — a missing
* env var should surface as a visible "we don't know" rather than silently
* looking like an un-prefixed legacy alert.
*
* Format mimics the existing `*bold*`/emoji alert idiom: a leading
* `[staging]` / `[production]` tag on its own line so the rest of the
* rendered template body is untouched. Exported so the alert-engine's
* aggregation flush path (which bypasses `render`) can reuse the exact
* same prefix.
*/
export function sourceEnvPrefix(sourceEnv: string | undefined): string {
const env = sourceEnv && sourceEnv.trim().length > 0 ? sourceEnv : "unknown";
return `[${env}] `;
}
export function createRenderer(): Renderer {
return {
render(tmpl, ctx) {
const safeText = stripBom(tmpl.text);
const { template, values } = extractFilters(safeText, ctx);
const rendered = Mustache.render(template, ctx);
const withFilters = splatSentinels(rendered, values);
// Prefix the source-env tag so every alert states which deploy
// environment it came from. Applied here (the single chokepoint for
// per-key, cron, and on-error dispatch) rather than in each YAML
// template, so coverage is automatic for current AND future rules.
const prefixed = sourceEnvPrefix(ctx.env.sourceEnv) + withFilters;
const text = enforceSoftLimit(prefixed);
return {
payload: { text },
contentType: "application/json",
};
},
};
}