@fjall/eslint-plugin 2.18.1

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.
@@ -0,0 +1,673 @@
1
+ /**
2
+ * ESLint Rule: mask-error-message-at-boundary
3
+ *
4
+ * Flags error-shaped leaf accesses that reach a sink without being wrapped
5
+ * in `maskSensitiveOutput(...)`.
6
+ *
7
+ * Leaf access shapes flagged:
8
+ * - `<expr>.error.message` / `<expr>.error?.message`
9
+ * - `<expr>.error.reason` / `<expr>.error?.reason`
10
+ * - `<bareErrorBinding>.message` / `.reason` where the binding name is
11
+ * `err`, `error`, `cause`, or `rejection` (common error-handler param)
12
+ * - `<expr>.reason` (Promise rejection / settled-result shape)
13
+ * - `<expr>.error` / `<expr>.errorMessage` — server-error envelope reads.
14
+ * The webapp's JSON failure shape is `{ error: string }` or
15
+ * `{ errorMessage: string }`. Reading either and routing the string to
16
+ * a state setter / PostHog trackEvent / logger without masking lands an
17
+ * unmasked server-side credential straight into the UI sink. This
18
+ * classification SKIPS when the access is itself the .object of another
19
+ * non-computed MemberExpression (so `data.error.message` falls back to
20
+ * the established `.error.message` path) and when the receiver is an
21
+ * ERROR_BINDING name (so `err.error` / `err.errorMessage` — wrapped-
22
+ * error shapes — are not double-flagged).
23
+ * - `getErrorMessage(...)` / `getErrorStack(...)` / `formatErrorString(...)`
24
+ * / `extractMessage(...)` / `formatAwsError(...)` / `formatAwsErrorMessage(...)`
25
+ * — helper-indirection shapes that delegate to `error.message` /
26
+ * `error.stack` internally. The CallExpression itself is the leaf;
27
+ * `findEnclosingSink` is reused to determine whether it reaches a
28
+ * flagged sink without `maskSensitiveOutput` in the ancestor chain.
29
+ * `getErrorCode` is excluded — return values are short identifier
30
+ * codes (ENOENT, AccessDenied), not credential vectors.
31
+ * - `<errBinding>.toString()` and `String(<errBinding>)` — coercion
32
+ * shapes that produce the same string surface as `.message`.
33
+ * Binding name must be in ERROR_BINDING_NAMES; arity must match
34
+ * the canonical shape (zero-arg `.toString()` / single-arg `String`).
35
+ *
36
+ * Sinks covered:
37
+ * - `logger.{info,warn,error,fatal}` / `serverLogger.{…}` (any object
38
+ * whose name ends in /logger$/i; `debug` is exempt — verbose opt-in)
39
+ * - `console.{log,warn,error}`
40
+ * - `*.set<Capital>(...)` — React/Ink state setter (UI sink)
41
+ * - bare `set<Capital>(...)` — useState idiom
42
+ * - `*.record<Capital>(...)` — tracker / activity-API persistence sink
43
+ * - `*.on<Capital>(...)` — callback-shaped boundary (callbacks.onError,
44
+ * callbacks.onLog, callbacks.onProgress, callbacks.onStackComplete, …)
45
+ * - `new XxxError(...)` — custom-Error ctor sink (any name matching
46
+ * /Error$/ except built-in `Error`). The ctor stores the unmasked
47
+ * value in `Error.message`, which then propagates to a Result via
48
+ * `failure(new XxxError(...))` and reaches downstream sinks
49
+ * (loggers, persistence, tracker.recordFailure) without further
50
+ * masking opportunity. Catching at the ctor closes the gap before
51
+ * the value crosses the Result boundary.
52
+ *
53
+ * Complements `require-masked-log-payload`, which targets credential-prone
54
+ * keys in 3rd-arg logger payload OBJECTS. This rule targets leaf access
55
+ * shapes regardless of payload structure (template literals, direct
56
+ * setX(args), record* persistence, callback invocations).
57
+ *
58
+ * Per `.claude/rules/security-standards.md § "Credential masking"`
59
+ * (subsection "Helper-indirection counts"):
60
+ *
61
+ * "Mask at the outermost boundary where output leaves your control —
62
+ * callbacks, logs, UI sinks, persistence."
63
+ *
64
+ * Required shape:
65
+ * deps.setError(maskSensitiveOutput(result.error.message))
66
+ * logger.error("X", `failed: ${maskSensitiveOutput(err.error.message)}`)
67
+ * callbacks.onError?.(new Error(maskSensitiveOutput(error.message)))
68
+ * failure(new AwsExecError(`Failed: ${maskSensitiveOutput(error.message)}`))
69
+ * Rejected shape:
70
+ * deps.setError(result.error.message)
71
+ * logger.error("X", `failed: ${err.error.message}`)
72
+ * callbacks.onError?.(new Error(error.message))
73
+ * failure(new AwsExecError(`Failed: ${error.message}`))
74
+ *
75
+ * Exempt contexts:
76
+ * - any ancestor CallExpression whose callee is `maskSensitiveOutput`
77
+ * - inside `throw` (intentional propagation; caller responsibility) —
78
+ * wins over the NewExpression sink, so `throw new XxxError(<leaf>)`
79
+ * stays exempt
80
+ * - inside `failure(...)` / `success(...)` Result wrappers when the
81
+ * wrapped value is a built-in `new Error(...)` or a non-ctor value
82
+ * (callers mask at their sinks). When the wrapped value is a custom
83
+ * `new XxxError(...)` ctor, the ctor itself is the sink — failure()
84
+ * no longer exempts it.
85
+ * - built-in `new Error(...)` is not itself a sink — walking continues
86
+ * so `callbacks.onError(new Error(<leaf>))` is caught at the callback
87
+ * boundary, and `throw new Error(...)` / `failure(new Error(...))`
88
+ * remain exempt because the throw / failure ancestor catches them.
89
+ *
90
+ * Escape hatch: `// eslint-disable-next-line fjall/mask-error-message-at-boundary`
91
+ * with a one-line justification when the value is provably non-sensitive
92
+ * (e.g. an internal-only enum tag wrapped as Error.message).
93
+ *
94
+ * Known limitations (interprocedural flow):
95
+ * The rule is AST-local. It tracks flow across `VariableDeclarator` /
96
+ * `AssignmentExpression` to closure reads, template-literal bind, and
97
+ * `makeError(...)` construction within the same function scope. It does
98
+ * NOT follow flow across function boundaries — when an error-derived
99
+ * string is bound inside one function and returned to a caller that
100
+ * then routes it to a sink, the rule cannot connect the two sites.
101
+ *
102
+ * Specifically uncaught:
103
+ * - Helper returns an unmasked error message; caller passes the return
104
+ * value to a logger / state setter / persistence call.
105
+ * - Method on a class stores `err.message` on `this.lastError`; another
106
+ * method on the same class reads `this.lastError` and routes it.
107
+ * - Async producer rejects with an unmasked Error; consumer's
108
+ * `.catch(err => sink(err.message))` is caught (local), but
109
+ * `.catch(handleError)` where `handleError` is defined elsewhere
110
+ * and routes to a sink is not.
111
+ *
112
+ * These shapes still require manual discipline at the boundary. See
113
+ * `.claude/rules/security-standards.md § "Helper-indirection counts"`
114
+ * for the rule the linter cannot mechanise.
115
+ */
116
+
117
+ const STATE_SETTER_RE = /^set[A-Z]/;
118
+ const STATE_SETTER_DENYLIST = new Set([
119
+ "setTimeout",
120
+ "setInterval",
121
+ "setImmediate"
122
+ ]);
123
+ const RECORDER_RE = /^record[A-Z]/;
124
+ const CALLBACK_RE = /^on[A-Z]/;
125
+ const CUSTOM_ERROR_CTOR_RE = /Error$/;
126
+ const LOGGER_RECEIVER_RE = /logger$/i;
127
+ const LOGGER_LEVEL_NAMES = new Set(["info", "warn", "error", "fatal"]);
128
+ const CONSOLE_LEVEL_NAMES = new Set(["log", "warn", "error"]);
129
+ const INTERNAL_PASSTHROUGH_CALLEES = new Set(["failure", "success"]);
130
+ const BUILT_IN_ERROR_CTORS = new Set([
131
+ "Error",
132
+ "TypeError",
133
+ "RangeError",
134
+ "SyntaxError",
135
+ "URIError",
136
+ "EvalError",
137
+ "ReferenceError"
138
+ ]);
139
+ const ERROR_BINDING_NAMES = new Set([
140
+ "err",
141
+ "error",
142
+ "e",
143
+ "ex",
144
+ "caught",
145
+ "cause",
146
+ "rejection"
147
+ ]);
148
+ const ERROR_EXTRACTOR_CALLEES = new Set([
149
+ "getErrorMessage",
150
+ "getErrorStack",
151
+ "formatErrorString",
152
+ "extractMessage",
153
+ "formatAwsError",
154
+ "formatAwsErrorMessage"
155
+ ]);
156
+ // `failure(makeError(...))` passthrough would hide this otherwise; sink-status fires at construction.
157
+ const ERROR_BUILDER_CALLEES = new Set(["makeError"]);
158
+ const BOOLEAN_RETURN_METHODS = new Set([
159
+ "includes",
160
+ "startsWith",
161
+ "endsWith",
162
+ "test",
163
+ "match",
164
+ "search",
165
+ "indexOf",
166
+ "lastIndexOf"
167
+ ]);
168
+ // Lockstep with masking helpers in webapp/scripts/deploymentJobHandler/helpers.ts (maskAndTruncate)
169
+ // and cli/src/util/api/FjallApiClientErrors.ts (maskAndBound). Both call maskSensitiveOutput internally.
170
+ const MASKING_HELPER_CALLEES = new Set([
171
+ "maskSensitiveOutput",
172
+ "maskAndTruncate",
173
+ "maskAndBound"
174
+ ]);
175
+
176
+ /** @type {import('eslint').Rule.RuleModule} */
177
+ export default {
178
+ meta: {
179
+ type: "problem",
180
+ docs: {
181
+ description:
182
+ "Require maskSensitiveOutput() around <expr>.error.message values that reach logger / state-setter / tracker / activity sinks.",
183
+ category: "Possible Errors",
184
+ recommended: true
185
+ },
186
+ messages: {
187
+ unmasked:
188
+ "Unmasked `{{leaf}}` reaches sink `{{sink}}`. Wrap with maskSensitiveOutput(...) per security-standards.md § 'Where to Mask'."
189
+ },
190
+ schema: []
191
+ },
192
+
193
+ create(context) {
194
+ // Dedup by (ConditionalExpression, sink-label) — masking the whole ternary fixes all branches at once.
195
+ const reportedConditionalSinks = new Set();
196
+
197
+ function reportOnce(node, leafLabel, sinkLabel) {
198
+ let conditional = null;
199
+ let p = node.parent;
200
+ while (p) {
201
+ if (p.type === "ConditionalExpression") {
202
+ conditional = p;
203
+ break;
204
+ }
205
+ if (
206
+ p.type === "FunctionDeclaration" ||
207
+ p.type === "FunctionExpression" ||
208
+ p.type === "ArrowFunctionExpression" ||
209
+ p.type === "Program"
210
+ ) {
211
+ break;
212
+ }
213
+ p = p.parent;
214
+ }
215
+ if (conditional && conditional.range) {
216
+ const key = `${conditional.range[0]}-${conditional.range[1]}::${sinkLabel}`;
217
+ if (reportedConditionalSinks.has(key)) return;
218
+ reportedConditionalSinks.add(key);
219
+ }
220
+ context.report({
221
+ node,
222
+ messageId: "unmasked",
223
+ data: { leaf: leafLabel, sink: sinkLabel }
224
+ });
225
+ }
226
+
227
+ return {
228
+ MemberExpression(node) {
229
+ const leaf = classifyErrorLeaf(node, context);
230
+ if (!leaf) return;
231
+
232
+ const sinkInfo = findEnclosingSink(
233
+ node,
234
+ context,
235
+ undefined,
236
+ leaf.shape
237
+ );
238
+ if (!sinkInfo || sinkInfo.kind !== "sink") return;
239
+
240
+ reportOnce(node, leaf.label, sinkInfo.label);
241
+ },
242
+ CallExpression(node) {
243
+ const calleeName = getCalleeIdentifierName(node.callee);
244
+
245
+ if (calleeName !== null && ERROR_EXTRACTOR_CALLEES.has(calleeName)) {
246
+ const sinkInfo = findEnclosingSink(
247
+ node,
248
+ context,
249
+ undefined,
250
+ "strict"
251
+ );
252
+ if (!sinkInfo || sinkInfo.kind !== "sink") return;
253
+ reportOnce(node, `${calleeName}(...)`, sinkInfo.label);
254
+ return;
255
+ }
256
+
257
+ if (
258
+ calleeName === "String" &&
259
+ node.arguments.length === 1 &&
260
+ node.arguments[0].type === "Identifier" &&
261
+ ERROR_BINDING_NAMES.has(node.arguments[0].name)
262
+ ) {
263
+ const sinkInfo = findEnclosingSink(
264
+ node,
265
+ context,
266
+ undefined,
267
+ "strict"
268
+ );
269
+ if (!sinkInfo || sinkInfo.kind !== "sink") return;
270
+ reportOnce(node, `String(${node.arguments[0].name})`, sinkInfo.label);
271
+ return;
272
+ }
273
+
274
+ if (
275
+ node.arguments.length === 0 &&
276
+ node.callee.type === "MemberExpression" &&
277
+ !node.callee.computed &&
278
+ node.callee.property.type === "Identifier" &&
279
+ node.callee.property.name === "toString" &&
280
+ node.callee.object.type === "Identifier" &&
281
+ ERROR_BINDING_NAMES.has(node.callee.object.name)
282
+ ) {
283
+ const sinkInfo = findEnclosingSink(
284
+ node,
285
+ context,
286
+ undefined,
287
+ "strict"
288
+ );
289
+ if (!sinkInfo || sinkInfo.kind !== "sink") return;
290
+ reportOnce(
291
+ node,
292
+ `${node.callee.object.name}.toString()`,
293
+ sinkInfo.label
294
+ );
295
+ }
296
+ }
297
+ };
298
+ }
299
+ };
300
+
301
+ function classifyErrorLeaf(node, context) {
302
+ if (
303
+ node.type !== "MemberExpression" ||
304
+ node.computed ||
305
+ node.property.type !== "Identifier"
306
+ ) {
307
+ return null;
308
+ }
309
+ const propName = node.property.name;
310
+
311
+ if (propName === "message" || propName === "reason") {
312
+ let object = node.object;
313
+ if (object.type === "ChainExpression") object = object.expression;
314
+
315
+ if (
316
+ object.type === "MemberExpression" &&
317
+ !object.computed &&
318
+ object.property.type === "Identifier" &&
319
+ object.property.name === "error"
320
+ ) {
321
+ return { label: `*.error.${propName}`, shape: "strict" };
322
+ }
323
+
324
+ if (object.type === "Identifier" && ERROR_BINDING_NAMES.has(object.name)) {
325
+ return { label: `${object.name}.${propName}`, shape: "strict" };
326
+ }
327
+
328
+ if (propName === "reason" && object.type === "Identifier") {
329
+ return { label: `${object.name}.reason`, shape: "strict" };
330
+ }
331
+
332
+ if (
333
+ propName === "reason" &&
334
+ object.type === "MemberExpression" &&
335
+ !object.computed
336
+ ) {
337
+ const receiverName = extractReceiverName(object);
338
+ return {
339
+ label: receiverName ? `${receiverName}.reason` : "*.reason",
340
+ shape: "strict"
341
+ };
342
+ }
343
+
344
+ return null;
345
+ }
346
+
347
+ if (propName === "error" || propName === "errorMessage") {
348
+ let visualNode = node;
349
+ let visualParent = node.parent;
350
+ if (visualParent && visualParent.type === "ChainExpression") {
351
+ visualNode = visualParent;
352
+ visualParent = visualParent.parent;
353
+ }
354
+ if (
355
+ visualParent &&
356
+ visualParent.type === "MemberExpression" &&
357
+ visualParent.object === visualNode &&
358
+ !visualParent.computed
359
+ ) {
360
+ return null;
361
+ }
362
+ if (
363
+ visualParent &&
364
+ visualParent.type === "CallExpression" &&
365
+ visualParent.callee === visualNode
366
+ ) {
367
+ return null;
368
+ }
369
+
370
+ let object = node.object;
371
+ if (object.type === "ChainExpression") object = object.expression;
372
+ if (object.type === "Identifier" && ERROR_BINDING_NAMES.has(object.name)) {
373
+ return null;
374
+ }
375
+ if (context && isZodSafeParseReceiver(object, context)) {
376
+ return null;
377
+ }
378
+
379
+ const receiverName = extractReceiverName(object);
380
+ return {
381
+ label: receiverName ? `${receiverName}.${propName}` : `*.${propName}`,
382
+ shape: "envelope"
383
+ };
384
+ }
385
+
386
+ return null;
387
+ }
388
+
389
+ /**
390
+ * Returns true when `objectNode` is an Identifier bound from a
391
+ * `<schema>.safeParse(...)` call. `parsed.error` in that shape is a
392
+ * `ZodError` object, not a server-error envelope, and the rule's
393
+ * credential-leak premise doesn't apply.
394
+ */
395
+ function isZodSafeParseReceiver(objectNode, context) {
396
+ if (objectNode.type !== "Identifier") return false;
397
+ const scope = context.sourceCode.getScope(objectNode);
398
+ const variable = findVariableInScopeChain(scope, objectNode.name);
399
+ if (!variable) return false;
400
+ for (const ref of variable.references) {
401
+ if (!ref.writeExpr) continue;
402
+ const init = ref.writeExpr;
403
+ if (init.type !== "CallExpression") continue;
404
+ if (init.callee.type !== "MemberExpression") continue;
405
+ if (init.callee.property.type !== "Identifier") continue;
406
+ if (init.callee.property.name === "safeParse") return true;
407
+ }
408
+ return false;
409
+ }
410
+
411
+ /**
412
+ * Walks up from the leaf `.error.message` access. Returns:
413
+ * { kind: "exempt" } — wrapped/internal context, do not flag
414
+ * { kind: "sink", label } — reaches a flagged sink
415
+ * null — no sink reached (comparison, return value, etc.)
416
+ *
417
+ * Stops at function boundaries.
418
+ *
419
+ * Custom-Error NewExpression sinks (`new XxxError(...)`) are recorded as a
420
+ * candidate rather than fired immediately. This lets ThrowStatement and
421
+ * `maskSensitiveOutput()` ancestors win — `throw new XxxError(error.message)`
422
+ * stays exempt — while still firing inside `failure()` / `success()` and at
423
+ * the function boundary.
424
+ *
425
+ * When the walk reaches a `VariableDeclarator` init or `AssignmentExpression`
426
+ * right with an `Identifier` LHS, scope analysis traces the binding's read
427
+ * references and recursively walks from each. This closes the assignment-
428
+ * then-use and template-literal-then-bind gaps. `visited` (keyed on the
429
+ * write site's range+name) prevents cycles when bindings reassign each other.
430
+ */
431
+ function findEnclosingSink(leaf, context, visited, leafShape) {
432
+ if (!visited) visited = new Set();
433
+ if (leafShape === undefined) leafShape = "strict";
434
+ let previous = leaf;
435
+ let current = leaf.parent;
436
+ let candidateSink = null;
437
+ while (current) {
438
+ if (current.type === "ThrowStatement") {
439
+ return { kind: "exempt" };
440
+ }
441
+
442
+ // ConditionalExpression test branch — boolean check, not a value flow.
443
+ // `cond ? a : b` with leaf in `cond`: the binding receives `a` or `b`,
444
+ // never the leaf's string value. Stop tracking.
445
+ if (current.type === "ConditionalExpression" && current.test === previous) {
446
+ return candidateSink;
447
+ }
448
+
449
+ // LogicalExpression — same shape when the leaf is on the LEFT of `&&`/`||`
450
+ // and is being used as a boolean check (the right side is what propagates).
451
+ // We can't always tell intent, but `.includes()`/`.startsWith()` etc. on a
452
+ // leaf are clearly boolean checks. Conservatively: when the leaf reaches a
453
+ // CallExpression whose callee is a MemberExpression on the leaf chain
454
+ // (e.g. `err.message.includes(...)`), the value being propagated is the
455
+ // method's return type, not the leaf string.
456
+ if (
457
+ current.type === "MemberExpression" &&
458
+ current.object === previous &&
459
+ !current.computed &&
460
+ current.property.type === "Identifier" &&
461
+ BOOLEAN_RETURN_METHODS.has(current.property.name)
462
+ ) {
463
+ // Walk continues, but mark that the in-flight value is a boolean.
464
+ // Since the parent CallExpression returns boolean, the leaf string
465
+ // doesn't reach the binding. Record a marker by short-circuiting.
466
+ // Find the enclosing CallExpression and skip past it.
467
+ let methodCall = current.parent;
468
+ if (
469
+ methodCall &&
470
+ methodCall.type === "CallExpression" &&
471
+ methodCall.callee === current
472
+ ) {
473
+ previous = methodCall;
474
+ current = methodCall.parent;
475
+ continue;
476
+ }
477
+ }
478
+
479
+ if (current.type === "NewExpression") {
480
+ const ctorName = getCalleeIdentifierName(current.callee);
481
+ if (
482
+ candidateSink === null &&
483
+ ctorName !== null &&
484
+ !BUILT_IN_ERROR_CTORS.has(ctorName) &&
485
+ CUSTOM_ERROR_CTOR_RE.test(ctorName) &&
486
+ leafShape !== "envelope"
487
+ ) {
488
+ candidateSink = { kind: "sink", label: `new ${ctorName}` };
489
+ }
490
+ }
491
+
492
+ if (current.type === "CallExpression") {
493
+ const calleeName = getCalleeIdentifierName(current.callee);
494
+ if (calleeName !== null && MASKING_HELPER_CALLEES.has(calleeName)) {
495
+ return { kind: "exempt" };
496
+ }
497
+ if (calleeName !== null && INTERNAL_PASSTHROUGH_CALLEES.has(calleeName)) {
498
+ return candidateSink ?? { kind: "exempt" };
499
+ }
500
+ if (calleeName !== null && ERROR_BUILDER_CALLEES.has(calleeName)) {
501
+ return candidateSink ?? { kind: "sink", label: `${calleeName}(...)` };
502
+ }
503
+
504
+ const sinkLabel = classifyCallee(current.callee);
505
+ if (sinkLabel) {
506
+ return candidateSink ?? { kind: "sink", label: sinkLabel };
507
+ }
508
+
509
+ // Order-dependent: passthroughs and sinks return above; only reached
510
+ // when the leaf is an argument to an unrecognised callable.
511
+ if (previous !== current.callee) {
512
+ return candidateSink;
513
+ }
514
+ }
515
+
516
+ if (
517
+ context &&
518
+ current.type === "VariableDeclarator" &&
519
+ current.id.type === "Identifier" &&
520
+ current.init !== null
521
+ ) {
522
+ const bindingSink = traceBindingFromWrite(
523
+ current.id,
524
+ context,
525
+ visited,
526
+ leafShape
527
+ );
528
+ return bindingSink ?? candidateSink;
529
+ }
530
+
531
+ if (
532
+ context &&
533
+ current.type === "AssignmentExpression" &&
534
+ current.operator === "=" &&
535
+ current.left.type === "Identifier"
536
+ ) {
537
+ const bindingSink = traceBindingFromWrite(
538
+ current.left,
539
+ context,
540
+ visited,
541
+ leafShape
542
+ );
543
+ return bindingSink ?? candidateSink;
544
+ }
545
+
546
+ if (
547
+ current.type === "FunctionDeclaration" ||
548
+ current.type === "FunctionExpression" ||
549
+ current.type === "ArrowFunctionExpression"
550
+ ) {
551
+ return candidateSink;
552
+ }
553
+
554
+ previous = current;
555
+ current = current.parent;
556
+ }
557
+ return candidateSink;
558
+ }
559
+
560
+ /**
561
+ * Trace forward read-references of a binding written at `writeIdNode`.
562
+ * Returns a sink if any read reference walks up into one, else null.
563
+ */
564
+ function traceBindingFromWrite(writeIdNode, context, visited, leafShape) {
565
+ const key = writeIdNode.range
566
+ ? `${writeIdNode.range[0]}-${writeIdNode.range[1]}-${writeIdNode.name}`
567
+ : null;
568
+ if (key !== null) {
569
+ if (visited.has(key)) return null;
570
+ visited.add(key);
571
+ }
572
+
573
+ const scope = context.sourceCode.getScope(writeIdNode);
574
+ const variable = findVariableInScopeChain(scope, writeIdNode.name);
575
+ if (!variable) return null;
576
+
577
+ for (const ref of variable.references) {
578
+ if (!ref.isRead()) continue;
579
+ if (ref.identifier === writeIdNode) continue;
580
+ const refSink = findEnclosingSink(
581
+ ref.identifier,
582
+ context,
583
+ visited,
584
+ leafShape
585
+ );
586
+ if (refSink && refSink.kind === "sink") return refSink;
587
+ }
588
+ return null;
589
+ }
590
+
591
+ function findVariableInScopeChain(scope, name) {
592
+ let s = scope;
593
+ while (s) {
594
+ const v = s.variables.find((v) => v.name === name);
595
+ if (v) return v;
596
+ s = s.upper;
597
+ }
598
+ return null;
599
+ }
600
+
601
+ /**
602
+ * Returns a label when `callee` matches a sink we flag, else null.
603
+ */
604
+ function classifyCallee(callee) {
605
+ if (callee.type === "ChainExpression") callee = callee.expression;
606
+
607
+ if (callee.type === "Identifier") {
608
+ if (
609
+ STATE_SETTER_RE.test(callee.name) &&
610
+ !STATE_SETTER_DENYLIST.has(callee.name)
611
+ ) {
612
+ return callee.name;
613
+ }
614
+ return null;
615
+ }
616
+
617
+ if (callee.type !== "MemberExpression" || callee.computed) return null;
618
+ if (callee.property.type !== "Identifier") return null;
619
+ const propName = callee.property.name;
620
+
621
+ const receiver = callee.object;
622
+
623
+ if (LOGGER_LEVEL_NAMES.has(propName)) {
624
+ const receiverName = extractReceiverName(receiver);
625
+ if (receiverName && LOGGER_RECEIVER_RE.test(receiverName)) {
626
+ return `${receiverName}.${propName}`;
627
+ }
628
+ }
629
+
630
+ if (
631
+ receiver.type === "Identifier" &&
632
+ receiver.name === "console" &&
633
+ CONSOLE_LEVEL_NAMES.has(propName)
634
+ ) {
635
+ return `console.${propName}`;
636
+ }
637
+
638
+ if (STATE_SETTER_RE.test(propName) && !STATE_SETTER_DENYLIST.has(propName)) {
639
+ return calleePathLabel(receiver, propName);
640
+ }
641
+
642
+ if (RECORDER_RE.test(propName)) {
643
+ return calleePathLabel(receiver, propName);
644
+ }
645
+
646
+ if (CALLBACK_RE.test(propName)) {
647
+ return calleePathLabel(receiver, propName);
648
+ }
649
+
650
+ return null;
651
+ }
652
+
653
+ function extractReceiverName(node) {
654
+ if (!node) return null;
655
+ if (node.type === "Identifier") return node.name;
656
+ if (node.type === "MemberExpression" && !node.computed) {
657
+ if (node.property.type === "Identifier") return node.property.name;
658
+ }
659
+ if (node.type === "ThisExpression") return "this";
660
+ return null;
661
+ }
662
+
663
+ function calleePathLabel(receiver, propName) {
664
+ const receiverName = extractReceiverName(receiver);
665
+ return receiverName ? `${receiverName}.${propName}` : propName;
666
+ }
667
+
668
+ function getCalleeIdentifierName(callee) {
669
+ if (!callee) return null;
670
+ if (callee.type === "ChainExpression") callee = callee.expression;
671
+ if (callee.type === "Identifier") return callee.name;
672
+ return null;
673
+ }