@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,154 @@
1
+ /**
2
+ * ESLint Rule: mask-before-truncate
3
+ *
4
+ * Flags `maskSensitiveOutput(<expr-with-truncation>)` where the truncation
5
+ * happens INSIDE the mask call. Truncating before masking can slice a
6
+ * credential mid-token and break the masking regex's anchors — a 30-char
7
+ * remnant of a 40-char AWS secret will not match `{40,}` and lands unmasked.
8
+ *
9
+ * Canonical bad shape:
10
+ * maskSensitiveOutput(raw.slice(0, 500) + "...")
11
+ * maskSensitiveOutput(raw.length > 500 ? raw.slice(0, 500) + "..." : raw)
12
+ *
13
+ * Canonical good shape (slice OUTSIDE mask, anchors intact):
14
+ * const masked = maskSensitiveOutput(raw);
15
+ * const bounded = masked.length > 500 ? masked.slice(0, 500) + "..." : masked;
16
+ *
17
+ * Per .claude/rules/security-standards.md § "Mask Before You Truncate".
18
+ * Recurrence-driven mechanisation (3 surfaces across 2 review passes:
19
+ * 14th-pass FjallApiClientErrors `maskAndBound` + 25th-pass ImportService × 2
20
+ * — pre-mask sites; the post-fix shape uses slice OUTSIDE the mask call).
21
+ */
22
+
23
+ const TRUNCATION_SUFFIX_RE = /\.\.\.|…|\[truncated\]|\[…\]/;
24
+ const TRUNCATING_METHODS = new Set(["slice", "substring", "substr"]);
25
+
26
+ /**
27
+ * Returns true iff `node` is a `.slice(...)` / `.substring(...)` /
28
+ * `.substr(...)` call with a numeric-literal start index. Single-arg
29
+ * `.slice(N)` (skip-prefix) is excluded — only multi-arg or zero-start
30
+ * forms count as truncation candidates.
31
+ */
32
+ function isTruncatingSliceCall(node) {
33
+ if (!node || node.type !== "CallExpression") return false;
34
+ if (node.callee.type !== "MemberExpression") return false;
35
+ if (node.callee.computed) return false;
36
+ if (node.callee.property.type !== "Identifier") return false;
37
+ if (!TRUNCATING_METHODS.has(node.callee.property.name)) return false;
38
+ if (node.arguments.length === 0) return false;
39
+
40
+ const firstArg = node.arguments[0];
41
+ if (
42
+ firstArg.type === "Literal" &&
43
+ typeof firstArg.value === "number" &&
44
+ firstArg.value === 0 &&
45
+ node.arguments.length >= 2
46
+ ) {
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Returns true iff `node` is a string literal containing a truncation
54
+ * suffix marker (`...`, `…`, `[truncated]`).
55
+ */
56
+ function isTruncationSuffixLiteral(node) {
57
+ return (
58
+ node &&
59
+ node.type === "Literal" &&
60
+ typeof node.value === "string" &&
61
+ TRUNCATION_SUFFIX_RE.test(node.value)
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Walk the argument tree of a `maskSensitiveOutput(...)` call and find
67
+ * the first `<slice-call> + <truncation-suffix>` BinaryExpression. Recurses
68
+ * into common nesting nodes (ternaries, logical chains, template literal
69
+ * expressions) but stops at function-call boundaries (don't peek inside
70
+ * helper calls — those are someone else's discipline).
71
+ */
72
+ function findTruncationPattern(node, visited = new Set()) {
73
+ if (!node) return null;
74
+ if (visited.has(node)) return null;
75
+ visited.add(node);
76
+
77
+ if (node.type === "BinaryExpression" && node.operator === "+") {
78
+ const left = node.left;
79
+ const right = node.right;
80
+ if (
81
+ (isTruncatingSliceCall(left) && isTruncationSuffixLiteral(right)) ||
82
+ (isTruncatingSliceCall(right) && isTruncationSuffixLiteral(left))
83
+ ) {
84
+ return node;
85
+ }
86
+ return (
87
+ findTruncationPattern(left, visited) ||
88
+ findTruncationPattern(right, visited)
89
+ );
90
+ }
91
+
92
+ if (node.type === "ConditionalExpression") {
93
+ return (
94
+ findTruncationPattern(node.consequent, visited) ||
95
+ findTruncationPattern(node.alternate, visited)
96
+ );
97
+ }
98
+
99
+ if (node.type === "LogicalExpression") {
100
+ return (
101
+ findTruncationPattern(node.left, visited) ||
102
+ findTruncationPattern(node.right, visited)
103
+ );
104
+ }
105
+
106
+ if (node.type === "TemplateLiteral") {
107
+ for (const expr of node.expressions) {
108
+ const found = findTruncationPattern(expr, visited);
109
+ if (found) return found;
110
+ }
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /** @type {import('eslint').Rule.RuleModule} */
117
+ export default {
118
+ meta: {
119
+ type: "problem",
120
+ docs: {
121
+ description:
122
+ "Disallow truncation INSIDE maskSensitiveOutput(...) calls — mask first, then slice the masked output.",
123
+ category: "Possible Errors",
124
+ recommended: true
125
+ },
126
+ messages: {
127
+ truncateBeforeMask:
128
+ 'Truncation inside maskSensitiveOutput(...) — slicing before masking can cut a credential mid-token and break the masking regex. Mask first, then slice: `const masked = maskSensitiveOutput(raw); const bounded = masked.length > N ? masked.slice(0, N) + "…" : masked;`'
129
+ },
130
+ schema: []
131
+ },
132
+
133
+ create(context) {
134
+ return {
135
+ CallExpression(node) {
136
+ if (
137
+ node.callee.type !== "Identifier" ||
138
+ node.callee.name !== "maskSensitiveOutput"
139
+ ) {
140
+ return;
141
+ }
142
+ if (node.arguments.length !== 1) return;
143
+
144
+ const truncation = findTruncationPattern(node.arguments[0]);
145
+ if (truncation) {
146
+ context.report({
147
+ node: truncation,
148
+ messageId: "truncateBeforeMask"
149
+ });
150
+ }
151
+ }
152
+ };
153
+ }
154
+ };