@noctcore/eslint-plugin-observability 0.1.0 → 0.3.0
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 +2 -1
- package/dist/index.cjs +324 -58
- package/dist/index.d.cts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +324 -58
- package/docs/rules/audit-pii-declared.md +143 -0
- package/docs/rules/no-sensitive-fields-in-logs.md +6 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -40,5 +40,6 @@ export default [
|
|
|
40
40
|
| Rule | Description | Recommended |
|
|
41
41
|
| --- | --- | --- |
|
|
42
42
|
| [`structured-log-arguments`](./docs/rules/structured-log-arguments.md) | Pass dynamic values in a structured context object, not interpolated into the message string. | `error` |
|
|
43
|
-
| [`no-sensitive-fields-in-logs`](./docs/rules/no-sensitive-fields-in-logs.md) | Name-heuristic guard against writing credentials/secrets into log sinks. | `
|
|
43
|
+
| [`no-sensitive-fields-in-logs`](./docs/rules/no-sensitive-fields-in-logs.md) | Name-heuristic guard against writing credentials/secrets into log sinks. | `error` |
|
|
44
44
|
| [`no-error-detail-loss`](./docs/rules/no-error-detail-loss.md) | A catch block that reports failure must log the error itself, not only `e.message`. | `error` |
|
|
45
|
+
| [`audit-pii-declared`](./docs/rules/audit-pii-declared.md) | A PII-shaped key written into an audit payload must be declared: registered for scrubbing on purge, or declared non-PII. Enforces declaration, not deletion. Inert until `auditCallees` is set. | `error` |
|
package/dist/index.cjs
CHANGED
|
@@ -29,11 +29,13 @@ module.exports = __toCommonJS(index_exports);
|
|
|
29
29
|
// src/configs/recommended.ts
|
|
30
30
|
var recommended = {
|
|
31
31
|
"noctcore-observability/structured-log-arguments": "error",
|
|
32
|
-
"noctcore-observability/no-sensitive-fields-in-logs": "
|
|
33
|
-
"noctcore-observability/no-error-detail-loss": "error"
|
|
32
|
+
"noctcore-observability/no-sensitive-fields-in-logs": "error",
|
|
33
|
+
"noctcore-observability/no-error-detail-loss": "error",
|
|
34
|
+
// Inert until you set `auditCallees`, so it ships enabled but checks nothing by default.
|
|
35
|
+
"noctcore-observability/audit-pii-declared": "error"
|
|
34
36
|
};
|
|
35
37
|
|
|
36
|
-
// src/rules/
|
|
38
|
+
// src/rules/audit-pii-declared.ts
|
|
37
39
|
var import_utils2 = require("@typescript-eslint/utils");
|
|
38
40
|
|
|
39
41
|
// src/createRule.ts
|
|
@@ -43,12 +45,7 @@ var createRule = (0, import_eslint_utils.makeCreateRule)("observability");
|
|
|
43
45
|
// src/utils.ts
|
|
44
46
|
var import_utils = require("@typescript-eslint/utils");
|
|
45
47
|
var DEFAULT_LOGGERS = ["console", "logger", "log"];
|
|
46
|
-
var LOG_METHODS = /* @__PURE__ */ new Set([
|
|
47
|
-
"info",
|
|
48
|
-
"warn",
|
|
49
|
-
"error",
|
|
50
|
-
"debug"
|
|
51
|
-
]);
|
|
48
|
+
var LOG_METHODS = /* @__PURE__ */ new Set(["info", "warn", "error", "debug"]);
|
|
52
49
|
function loggerObjectName(object) {
|
|
53
50
|
if (object.type === import_utils.AST_NODE_TYPES.Identifier) {
|
|
54
51
|
return object.name;
|
|
@@ -73,16 +70,310 @@ function loggerCallMethod(node, loggers) {
|
|
|
73
70
|
}
|
|
74
71
|
return method;
|
|
75
72
|
}
|
|
73
|
+
function nameSegments(name) {
|
|
74
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^a-zA-Z0-9]+/).join(" ").toLowerCase().split(/\s+/).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
function compactName(name) {
|
|
77
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
78
|
+
}
|
|
79
|
+
function makeNameMatcher(patterns) {
|
|
80
|
+
const single = [];
|
|
81
|
+
const multi = [];
|
|
82
|
+
for (const pattern of patterns) {
|
|
83
|
+
const segs = nameSegments(pattern);
|
|
84
|
+
if (segs.length <= 1) {
|
|
85
|
+
single.push(compactName(pattern));
|
|
86
|
+
} else {
|
|
87
|
+
multi.push(compactName(pattern));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return (name) => {
|
|
91
|
+
const segs = new Set(nameSegments(name));
|
|
92
|
+
if (single.some((p) => segs.has(p))) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const flat = compactName(name);
|
|
96
|
+
return multi.some((p) => flat.includes(p));
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/rules/audit-pii-declared.ts
|
|
101
|
+
var RULE_NAME = "audit-pii-declared";
|
|
102
|
+
var DEFAULT_PAYLOAD_KEYS = ["metadata", "before", "after"];
|
|
103
|
+
var DEFAULT_PII_FIELDS = [
|
|
104
|
+
"email",
|
|
105
|
+
"phone",
|
|
106
|
+
"mobile",
|
|
107
|
+
"address",
|
|
108
|
+
"firstName",
|
|
109
|
+
"lastName",
|
|
110
|
+
"fullName",
|
|
111
|
+
"displayName",
|
|
112
|
+
"surname",
|
|
113
|
+
"birthDate",
|
|
114
|
+
"dateOfBirth"
|
|
115
|
+
];
|
|
116
|
+
var DEFAULT_NON_PII_PREFIXES = ["is", "has", "was", "should", "can"];
|
|
117
|
+
var DEFAULT_NON_PII_SUFFIXES = [
|
|
118
|
+
"sent",
|
|
119
|
+
"verified",
|
|
120
|
+
"confirmed",
|
|
121
|
+
"enabled",
|
|
122
|
+
"disabled",
|
|
123
|
+
"changed",
|
|
124
|
+
"required",
|
|
125
|
+
"count",
|
|
126
|
+
"type",
|
|
127
|
+
"kind",
|
|
128
|
+
"status",
|
|
129
|
+
"id",
|
|
130
|
+
"ids"
|
|
131
|
+
];
|
|
132
|
+
var stringList = { type: "array", items: { type: "string" }, uniqueItems: true };
|
|
133
|
+
var optionSchema = {
|
|
134
|
+
type: "object",
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
properties: {
|
|
137
|
+
auditCallees: { ...stringList, default: [] },
|
|
138
|
+
payloadKeys: { ...stringList, default: [...DEFAULT_PAYLOAD_KEYS] },
|
|
139
|
+
piiFields: { ...stringList, default: [...DEFAULT_PII_FIELDS] },
|
|
140
|
+
registeredFields: { ...stringList, default: [] },
|
|
141
|
+
nonPiiFields: { ...stringList, default: [] },
|
|
142
|
+
nonPiiPrefixes: { ...stringList, default: [...DEFAULT_NON_PII_PREFIXES] },
|
|
143
|
+
nonPiiSuffixes: { ...stringList, default: [...DEFAULT_NON_PII_SUFFIXES] },
|
|
144
|
+
reportOpaque: { type: "boolean", default: true }
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
function calleeText(node) {
|
|
148
|
+
if (node.type === import_utils2.AST_NODE_TYPES.Identifier) {
|
|
149
|
+
return node.name;
|
|
150
|
+
}
|
|
151
|
+
if (node.type === import_utils2.AST_NODE_TYPES.ThisExpression) {
|
|
152
|
+
return "this";
|
|
153
|
+
}
|
|
154
|
+
if (node.type === import_utils2.AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === import_utils2.AST_NODE_TYPES.Identifier) {
|
|
155
|
+
const object = calleeText(node.object);
|
|
156
|
+
return object === null ? null : `${object}.${node.property.name}`;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function staticKeyName(property) {
|
|
161
|
+
if (property.computed) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const key = property.key;
|
|
165
|
+
if (key.type === import_utils2.AST_NODE_TYPES.Identifier) {
|
|
166
|
+
return key.name;
|
|
167
|
+
}
|
|
168
|
+
if (key.type === import_utils2.AST_NODE_TYPES.Literal && typeof key.value === "string") {
|
|
169
|
+
return key.value;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
function valueName(node) {
|
|
174
|
+
if (node.type === import_utils2.AST_NODE_TYPES.Identifier) {
|
|
175
|
+
return node.name;
|
|
176
|
+
}
|
|
177
|
+
if (node.type === import_utils2.AST_NODE_TYPES.MemberExpression && !node.computed && node.property.type === import_utils2.AST_NODE_TYPES.Identifier) {
|
|
178
|
+
return node.property.name;
|
|
179
|
+
}
|
|
180
|
+
if (node.type === import_utils2.AST_NODE_TYPES.ChainExpression) {
|
|
181
|
+
return valueName(node.expression);
|
|
182
|
+
}
|
|
183
|
+
if (node.type === import_utils2.AST_NODE_TYPES.TSNonNullExpression || node.type === import_utils2.AST_NODE_TYPES.TSAsExpression) {
|
|
184
|
+
return valueName(node.expression);
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
function isEmptyValue(node) {
|
|
189
|
+
return node.type === import_utils2.AST_NODE_TYPES.Literal && node.value === null || node.type === import_utils2.AST_NODE_TYPES.Identifier && node.name === "undefined";
|
|
190
|
+
}
|
|
191
|
+
function isScalarFlag(node) {
|
|
192
|
+
return node.type === import_utils2.AST_NODE_TYPES.Literal && (typeof node.value === "boolean" || typeof node.value === "number" || node.value === null);
|
|
193
|
+
}
|
|
194
|
+
function spreadSources(node) {
|
|
195
|
+
if (node.type === import_utils2.AST_NODE_TYPES.ObjectExpression) {
|
|
196
|
+
return [node];
|
|
197
|
+
}
|
|
198
|
+
if (isEmptyValue(node)) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
if (node.type === import_utils2.AST_NODE_TYPES.ConditionalExpression) {
|
|
202
|
+
const consequent = spreadSources(node.consequent);
|
|
203
|
+
const alternate = spreadSources(node.alternate);
|
|
204
|
+
return consequent === null || alternate === null ? null : [...consequent, ...alternate];
|
|
205
|
+
}
|
|
206
|
+
if (node.type === import_utils2.AST_NODE_TYPES.LogicalExpression && node.operator === "&&") {
|
|
207
|
+
return spreadSources(node.right);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
var auditPiiDeclaredRule = createRule({
|
|
212
|
+
name: RULE_NAME,
|
|
213
|
+
meta: {
|
|
214
|
+
type: "problem",
|
|
215
|
+
docs: {
|
|
216
|
+
description: "A PII-shaped key written into an audit payload must be declared, either registered for scrubbing on purge or declared non-PII."
|
|
217
|
+
},
|
|
218
|
+
schema: [optionSchema],
|
|
219
|
+
messages: {
|
|
220
|
+
undeclaredPiiKey: "`{{key}}` looks like personal data in audit `{{payload}}`. Add it to `registeredFields` (the keys your purger scrubs) or, if it is not personal data, to `nonPiiFields`.",
|
|
221
|
+
undeclaredPiiValue: "`{{key}}` in audit `{{payload}}` holds `{{value}}`, which looks like personal data. Add `{{key}}` to `registeredFields` (the keys your purger scrubs) or, if it is not personal data, to `nonPiiFields`.",
|
|
222
|
+
opaquePayload: "Audit `{{payload}}` cannot be read statically ({{why}}), so its keys cannot be checked against the PII registry. Write it as an object literal."
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
defaultOptions: [
|
|
226
|
+
{
|
|
227
|
+
auditCallees: [],
|
|
228
|
+
payloadKeys: DEFAULT_PAYLOAD_KEYS,
|
|
229
|
+
piiFields: DEFAULT_PII_FIELDS,
|
|
230
|
+
registeredFields: [],
|
|
231
|
+
nonPiiFields: [],
|
|
232
|
+
nonPiiPrefixes: DEFAULT_NON_PII_PREFIXES,
|
|
233
|
+
nonPiiSuffixes: DEFAULT_NON_PII_SUFFIXES,
|
|
234
|
+
reportOpaque: true
|
|
235
|
+
}
|
|
236
|
+
],
|
|
237
|
+
create(context, [options]) {
|
|
238
|
+
const auditCallees = options.auditCallees ?? [];
|
|
239
|
+
if (auditCallees.length === 0) {
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
const payloadKeys = new Set(options.payloadKeys ?? DEFAULT_PAYLOAD_KEYS);
|
|
243
|
+
const looksLikePii = makeNameMatcher(options.piiFields ?? DEFAULT_PII_FIELDS);
|
|
244
|
+
const declared = /* @__PURE__ */ new Set([
|
|
245
|
+
...options.registeredFields ?? [],
|
|
246
|
+
...options.nonPiiFields ?? []
|
|
247
|
+
]);
|
|
248
|
+
const reportOpaque = options.reportOpaque ?? true;
|
|
249
|
+
const nonPiiPrefixes = new Set(options.nonPiiPrefixes ?? DEFAULT_NON_PII_PREFIXES);
|
|
250
|
+
const nonPiiSuffixes = new Set(options.nonPiiSuffixes ?? DEFAULT_NON_PII_SUFFIXES);
|
|
251
|
+
const exactPii = new Set(
|
|
252
|
+
(options.piiFields ?? DEFAULT_PII_FIELDS).map((field) => compactName(field))
|
|
253
|
+
);
|
|
254
|
+
const isPiiName = (name) => {
|
|
255
|
+
if (!looksLikePii(name)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (exactPii.has(compactName(name))) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
const segments = nameSegments(name);
|
|
262
|
+
const first = segments[0];
|
|
263
|
+
const last = segments[segments.length - 1];
|
|
264
|
+
const isAbout = segments.length > 1 && (first !== void 0 && nonPiiPrefixes.has(first) || last !== void 0 && nonPiiSuffixes.has(last));
|
|
265
|
+
return !isAbout;
|
|
266
|
+
};
|
|
267
|
+
const isAuditCall = (callee) => {
|
|
268
|
+
const text = calleeText(callee);
|
|
269
|
+
return text !== null && auditCallees.some((entry) => text === entry || text.endsWith(`.${entry}`));
|
|
270
|
+
};
|
|
271
|
+
const opaque = (node, payload, why) => {
|
|
272
|
+
if (reportOpaque) {
|
|
273
|
+
context.report({ node, messageId: "opaquePayload", data: { payload, why } });
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const checkObject = (object, payload) => {
|
|
277
|
+
for (const property of object.properties) {
|
|
278
|
+
if (property.type === import_utils2.AST_NODE_TYPES.SpreadElement) {
|
|
279
|
+
const sources = spreadSources(property.argument);
|
|
280
|
+
if (sources === null) {
|
|
281
|
+
opaque(property, payload, "it spreads a value that is not an object literal");
|
|
282
|
+
} else {
|
|
283
|
+
sources.forEach((source) => checkObject(source, payload));
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const key = staticKeyName(property);
|
|
288
|
+
if (key === null) {
|
|
289
|
+
opaque(property.key, payload, "it has a computed key");
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const value = property.value;
|
|
293
|
+
if (!declared.has(key) && !isScalarFlag(value)) {
|
|
294
|
+
if (isPiiName(key)) {
|
|
295
|
+
context.report({
|
|
296
|
+
node: property.key,
|
|
297
|
+
messageId: "undeclaredPiiKey",
|
|
298
|
+
data: { key, payload }
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
const name = valueName(value);
|
|
302
|
+
if (name !== null && isPiiName(name)) {
|
|
303
|
+
context.report({
|
|
304
|
+
node: property.key,
|
|
305
|
+
messageId: "undeclaredPiiValue",
|
|
306
|
+
data: { key, payload, value: context.sourceCode.getText(value) }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
checkNested(value, payload);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const checkNested = (node, payload) => {
|
|
315
|
+
if (node.type === import_utils2.AST_NODE_TYPES.ObjectExpression) {
|
|
316
|
+
checkObject(node, payload);
|
|
317
|
+
} else if (node.type === import_utils2.AST_NODE_TYPES.ArrayExpression) {
|
|
318
|
+
for (const element of node.elements) {
|
|
319
|
+
if (element !== null && element.type !== import_utils2.AST_NODE_TYPES.SpreadElement) {
|
|
320
|
+
checkNested(element, payload);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const checkParams = (params) => {
|
|
326
|
+
for (const property of params.properties) {
|
|
327
|
+
if (property.type === import_utils2.AST_NODE_TYPES.SpreadElement) {
|
|
328
|
+
const sources = spreadSources(property.argument);
|
|
329
|
+
if (sources === null) {
|
|
330
|
+
opaque(property, "params", "it spreads a value that is not an object literal");
|
|
331
|
+
} else {
|
|
332
|
+
sources.forEach(checkParams);
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const key = staticKeyName(property);
|
|
337
|
+
if (key === null || !payloadKeys.has(key)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const value = property.value;
|
|
341
|
+
if (value.type === import_utils2.AST_NODE_TYPES.ObjectExpression) {
|
|
342
|
+
checkObject(value, key);
|
|
343
|
+
} else if (!isEmptyValue(value)) {
|
|
344
|
+
opaque(value, key, "it is not an object literal");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
CallExpression(node) {
|
|
350
|
+
if (!isAuditCall(node.callee)) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const [params] = node.arguments;
|
|
354
|
+
if (params === void 0) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (params.type !== import_utils2.AST_NODE_TYPES.ObjectExpression) {
|
|
358
|
+
opaque(params, "params", "the audit call is not given an object literal");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
checkParams(params);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
});
|
|
76
366
|
|
|
77
367
|
// src/rules/no-error-detail-loss.ts
|
|
78
|
-
var
|
|
368
|
+
var import_utils4 = require("@typescript-eslint/utils");
|
|
369
|
+
var RULE_NAME2 = "no-error-detail-loss";
|
|
79
370
|
function containsLoggerCall(block, loggers) {
|
|
80
371
|
let found = false;
|
|
81
372
|
function walk(node) {
|
|
82
373
|
if (found) {
|
|
83
374
|
return;
|
|
84
375
|
}
|
|
85
|
-
if (node.type ===
|
|
376
|
+
if (node.type === import_utils4.AST_NODE_TYPES.CallExpression && loggerCallMethod(node, loggers) !== null) {
|
|
86
377
|
found = true;
|
|
87
378
|
return;
|
|
88
379
|
}
|
|
@@ -107,19 +398,19 @@ function containsLoggerCall(block, loggers) {
|
|
|
107
398
|
}
|
|
108
399
|
function isLossyReference(id) {
|
|
109
400
|
const parent = id.parent;
|
|
110
|
-
if (parent.type ===
|
|
401
|
+
if (parent.type === import_utils4.AST_NODE_TYPES.MemberExpression && parent.object === id && !parent.computed && parent.property.type === import_utils4.AST_NODE_TYPES.Identifier && parent.property.name === "message") {
|
|
111
402
|
return true;
|
|
112
403
|
}
|
|
113
|
-
if (parent.type ===
|
|
404
|
+
if (parent.type === import_utils4.AST_NODE_TYPES.CallExpression && parent.callee.type === import_utils4.AST_NODE_TYPES.Identifier && parent.callee.name === "String" && parent.arguments.includes(id)) {
|
|
114
405
|
return true;
|
|
115
406
|
}
|
|
116
|
-
if (parent.type ===
|
|
407
|
+
if (parent.type === import_utils4.AST_NODE_TYPES.TemplateLiteral) {
|
|
117
408
|
return true;
|
|
118
409
|
}
|
|
119
410
|
return false;
|
|
120
411
|
}
|
|
121
412
|
var noErrorDetailLossRule = createRule({
|
|
122
|
-
name:
|
|
413
|
+
name: RULE_NAME2,
|
|
123
414
|
meta: {
|
|
124
415
|
type: "suggestion",
|
|
125
416
|
docs: {
|
|
@@ -135,7 +426,7 @@ var noErrorDetailLossRule = createRule({
|
|
|
135
426
|
const loggers = new Set(DEFAULT_LOGGERS);
|
|
136
427
|
return {
|
|
137
428
|
CatchClause(node) {
|
|
138
|
-
if (node.param === null || node.param.type !==
|
|
429
|
+
if (node.param === null || node.param.type !== import_utils4.AST_NODE_TYPES.Identifier) {
|
|
139
430
|
return;
|
|
140
431
|
}
|
|
141
432
|
if (!containsLoggerCall(node.body, loggers)) {
|
|
@@ -166,8 +457,8 @@ var noErrorDetailLossRule = createRule({
|
|
|
166
457
|
});
|
|
167
458
|
|
|
168
459
|
// src/rules/no-sensitive-fields-in-logs.ts
|
|
169
|
-
var
|
|
170
|
-
var
|
|
460
|
+
var import_utils6 = require("@typescript-eslint/utils");
|
|
461
|
+
var RULE_NAME3 = "no-sensitive-fields-in-logs";
|
|
171
462
|
var DEFAULT_DENY_NAMES = [
|
|
172
463
|
"password",
|
|
173
464
|
"token",
|
|
@@ -177,7 +468,7 @@ var DEFAULT_DENY_NAMES = [
|
|
|
177
468
|
"apiKey",
|
|
178
469
|
"ssn"
|
|
179
470
|
];
|
|
180
|
-
var
|
|
471
|
+
var optionSchema2 = {
|
|
181
472
|
type: "object",
|
|
182
473
|
additionalProperties: false,
|
|
183
474
|
properties: {
|
|
@@ -187,47 +478,21 @@ var optionSchema = {
|
|
|
187
478
|
}
|
|
188
479
|
}
|
|
189
480
|
};
|
|
190
|
-
function nameSegments(name) {
|
|
191
|
-
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^a-zA-Z0-9]+/).join(" ").toLowerCase().split(/\s+/).filter(Boolean);
|
|
192
|
-
}
|
|
193
|
-
function compact(name) {
|
|
194
|
-
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
195
|
-
}
|
|
196
|
-
function makeMatcher(denyNames) {
|
|
197
|
-
const single = [];
|
|
198
|
-
const multi = [];
|
|
199
|
-
for (const deny of denyNames) {
|
|
200
|
-
const segs = nameSegments(deny);
|
|
201
|
-
if (segs.length <= 1) {
|
|
202
|
-
single.push(compact(deny));
|
|
203
|
-
} else {
|
|
204
|
-
multi.push(compact(deny));
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return (name) => {
|
|
208
|
-
const segs = new Set(nameSegments(name));
|
|
209
|
-
if (single.some((deny) => segs.has(deny))) {
|
|
210
|
-
return true;
|
|
211
|
-
}
|
|
212
|
-
const flat = compact(name);
|
|
213
|
-
return multi.some((deny) => flat.includes(deny));
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
481
|
var noSensitiveFieldsInLogsRule = createRule({
|
|
217
|
-
name:
|
|
482
|
+
name: RULE_NAME3,
|
|
218
483
|
meta: {
|
|
219
484
|
type: "suggestion",
|
|
220
485
|
docs: {
|
|
221
486
|
description: "A logger call must not reference an identifier, property, or object key whose name matches a sensitive-field denylist (password, token, secret, ...). Redact it before logging."
|
|
222
487
|
},
|
|
223
|
-
schema: [
|
|
488
|
+
schema: [optionSchema2],
|
|
224
489
|
messages: {
|
|
225
490
|
sensitiveField: "`{{name}}` looks like a sensitive field being written to a log sink. Redact it before logging (e.g. `redact({{name}})`) or omit it \u2014 logs are long-lived and widely readable."
|
|
226
491
|
}
|
|
227
492
|
},
|
|
228
493
|
defaultOptions: [{ denyNames: DEFAULT_DENY_NAMES }],
|
|
229
494
|
create(context, [options]) {
|
|
230
|
-
const matches =
|
|
495
|
+
const matches = makeNameMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
|
|
231
496
|
function walk(node, visit) {
|
|
232
497
|
visit(node);
|
|
233
498
|
for (const key of Object.keys(node)) {
|
|
@@ -266,9 +531,9 @@ var noSensitiveFieldsInLogsRule = createRule({
|
|
|
266
531
|
};
|
|
267
532
|
for (const arg of node.arguments) {
|
|
268
533
|
walk(arg, (n) => {
|
|
269
|
-
if (n.type ===
|
|
534
|
+
if (n.type === import_utils6.AST_NODE_TYPES.Identifier && matches(n.name)) {
|
|
270
535
|
report(n.name, n);
|
|
271
|
-
} else if (n.type ===
|
|
536
|
+
} else if (n.type === import_utils6.AST_NODE_TYPES.Property && n.key.type === import_utils6.AST_NODE_TYPES.Literal && typeof n.key.value === "string" && matches(n.key.value)) {
|
|
272
537
|
report(n.key.value, n.key);
|
|
273
538
|
}
|
|
274
539
|
});
|
|
@@ -279,9 +544,9 @@ var noSensitiveFieldsInLogsRule = createRule({
|
|
|
279
544
|
});
|
|
280
545
|
|
|
281
546
|
// src/rules/structured-log-arguments.ts
|
|
282
|
-
var
|
|
283
|
-
var
|
|
284
|
-
var
|
|
547
|
+
var import_utils8 = require("@typescript-eslint/utils");
|
|
548
|
+
var RULE_NAME4 = "structured-log-arguments";
|
|
549
|
+
var optionSchema3 = {
|
|
285
550
|
type: "object",
|
|
286
551
|
additionalProperties: false,
|
|
287
552
|
properties: {
|
|
@@ -292,13 +557,13 @@ var optionSchema2 = {
|
|
|
292
557
|
}
|
|
293
558
|
};
|
|
294
559
|
var structuredLogArgumentsRule = createRule({
|
|
295
|
-
name:
|
|
560
|
+
name: RULE_NAME4,
|
|
296
561
|
meta: {
|
|
297
562
|
type: "suggestion",
|
|
298
563
|
docs: {
|
|
299
564
|
description: "A logger call must not interpolate dynamic values into the message string (a template literal with expressions). Pass a static message and a structured context object instead."
|
|
300
565
|
},
|
|
301
|
-
schema: [
|
|
566
|
+
schema: [optionSchema3],
|
|
302
567
|
messages: {
|
|
303
568
|
interpolatedMessage: "This `.{{method}}(...)` call interpolates dynamic values into the message string \u2014 they become unqueryable free text. Pass a static message plus a structured context object (e.g. `log.{{method}}('processing task', { taskId })`) so each field stays indexable."
|
|
304
569
|
}
|
|
@@ -313,7 +578,7 @@ var structuredLogArgumentsRule = createRule({
|
|
|
313
578
|
return;
|
|
314
579
|
}
|
|
315
580
|
for (const arg of node.arguments) {
|
|
316
|
-
if (arg.type ===
|
|
581
|
+
if (arg.type === import_utils8.AST_NODE_TYPES.TemplateLiteral && arg.expressions.length > 0) {
|
|
317
582
|
context.report({
|
|
318
583
|
node: arg,
|
|
319
584
|
messageId: "interpolatedMessage",
|
|
@@ -330,12 +595,13 @@ var structuredLogArgumentsRule = createRule({
|
|
|
330
595
|
var rules = {
|
|
331
596
|
"structured-log-arguments": structuredLogArgumentsRule,
|
|
332
597
|
"no-sensitive-fields-in-logs": noSensitiveFieldsInLogsRule,
|
|
333
|
-
"no-error-detail-loss": noErrorDetailLossRule
|
|
598
|
+
"no-error-detail-loss": noErrorDetailLossRule,
|
|
599
|
+
"audit-pii-declared": auditPiiDeclaredRule
|
|
334
600
|
};
|
|
335
601
|
|
|
336
602
|
// src/index.ts
|
|
337
603
|
var NAMESPACE = "noctcore-observability";
|
|
338
|
-
var VERSION = "0.
|
|
604
|
+
var VERSION = "0.2.0";
|
|
339
605
|
var plugin = {
|
|
340
606
|
meta: { name: "@noctcore/eslint-plugin-observability", version: VERSION },
|
|
341
607
|
rules,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
2
|
|
|
3
|
+
interface AuditPiiDeclaredOptions {
|
|
4
|
+
readonly auditCallees?: readonly string[];
|
|
5
|
+
readonly payloadKeys?: readonly string[];
|
|
6
|
+
readonly piiFields?: readonly string[];
|
|
7
|
+
readonly registeredFields?: readonly string[];
|
|
8
|
+
readonly nonPiiFields?: readonly string[];
|
|
9
|
+
readonly nonPiiPrefixes?: readonly string[];
|
|
10
|
+
readonly nonPiiSuffixes?: readonly string[];
|
|
11
|
+
readonly reportOpaque?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
interface NoSensitiveFieldsInLogsOptions {
|
|
4
15
|
readonly denyNames?: readonly string[];
|
|
5
16
|
}
|
|
@@ -19,6 +30,9 @@ declare const rules: {
|
|
|
19
30
|
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
20
31
|
name: string;
|
|
21
32
|
};
|
|
33
|
+
'audit-pii-declared': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredPiiKey" | "undeclaredPiiValue" | "opaquePayload", [AuditPiiDeclaredOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
22
36
|
};
|
|
23
37
|
|
|
24
38
|
declare const plugin: {
|
|
@@ -36,6 +50,9 @@ declare const plugin: {
|
|
|
36
50
|
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
37
51
|
name: string;
|
|
38
52
|
};
|
|
53
|
+
'audit-pii-declared': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredPiiKey" | "undeclaredPiiValue" | "opaquePayload", [AuditPiiDeclaredOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
54
|
+
name: string;
|
|
55
|
+
};
|
|
39
56
|
};
|
|
40
57
|
configs: Record<string, unknown>;
|
|
41
58
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
2
|
|
|
3
|
+
interface AuditPiiDeclaredOptions {
|
|
4
|
+
readonly auditCallees?: readonly string[];
|
|
5
|
+
readonly payloadKeys?: readonly string[];
|
|
6
|
+
readonly piiFields?: readonly string[];
|
|
7
|
+
readonly registeredFields?: readonly string[];
|
|
8
|
+
readonly nonPiiFields?: readonly string[];
|
|
9
|
+
readonly nonPiiPrefixes?: readonly string[];
|
|
10
|
+
readonly nonPiiSuffixes?: readonly string[];
|
|
11
|
+
readonly reportOpaque?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
3
14
|
interface NoSensitiveFieldsInLogsOptions {
|
|
4
15
|
readonly denyNames?: readonly string[];
|
|
5
16
|
}
|
|
@@ -19,6 +30,9 @@ declare const rules: {
|
|
|
19
30
|
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
20
31
|
name: string;
|
|
21
32
|
};
|
|
33
|
+
'audit-pii-declared': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredPiiKey" | "undeclaredPiiValue" | "opaquePayload", [AuditPiiDeclaredOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
22
36
|
};
|
|
23
37
|
|
|
24
38
|
declare const plugin: {
|
|
@@ -36,6 +50,9 @@ declare const plugin: {
|
|
|
36
50
|
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
37
51
|
name: string;
|
|
38
52
|
};
|
|
53
|
+
'audit-pii-declared': _typescript_eslint_utils_ts_eslint.RuleModule<"undeclaredPiiKey" | "undeclaredPiiValue" | "opaquePayload", [AuditPiiDeclaredOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
54
|
+
name: string;
|
|
55
|
+
};
|
|
39
56
|
};
|
|
40
57
|
configs: Record<string, unknown>;
|
|
41
58
|
};
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// src/configs/recommended.ts
|
|
2
2
|
var recommended = {
|
|
3
3
|
"noctcore-observability/structured-log-arguments": "error",
|
|
4
|
-
"noctcore-observability/no-sensitive-fields-in-logs": "
|
|
5
|
-
"noctcore-observability/no-error-detail-loss": "error"
|
|
4
|
+
"noctcore-observability/no-sensitive-fields-in-logs": "error",
|
|
5
|
+
"noctcore-observability/no-error-detail-loss": "error",
|
|
6
|
+
// Inert until you set `auditCallees`, so it ships enabled but checks nothing by default.
|
|
7
|
+
"noctcore-observability/audit-pii-declared": "error"
|
|
6
8
|
};
|
|
7
9
|
|
|
8
|
-
// src/rules/
|
|
10
|
+
// src/rules/audit-pii-declared.ts
|
|
9
11
|
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
10
12
|
|
|
11
13
|
// src/createRule.ts
|
|
@@ -15,12 +17,7 @@ var createRule = makeCreateRule("observability");
|
|
|
15
17
|
// src/utils.ts
|
|
16
18
|
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
17
19
|
var DEFAULT_LOGGERS = ["console", "logger", "log"];
|
|
18
|
-
var LOG_METHODS = /* @__PURE__ */ new Set([
|
|
19
|
-
"info",
|
|
20
|
-
"warn",
|
|
21
|
-
"error",
|
|
22
|
-
"debug"
|
|
23
|
-
]);
|
|
20
|
+
var LOG_METHODS = /* @__PURE__ */ new Set(["info", "warn", "error", "debug"]);
|
|
24
21
|
function loggerObjectName(object) {
|
|
25
22
|
if (object.type === AST_NODE_TYPES.Identifier) {
|
|
26
23
|
return object.name;
|
|
@@ -45,16 +42,310 @@ function loggerCallMethod(node, loggers) {
|
|
|
45
42
|
}
|
|
46
43
|
return method;
|
|
47
44
|
}
|
|
45
|
+
function nameSegments(name) {
|
|
46
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^a-zA-Z0-9]+/).join(" ").toLowerCase().split(/\s+/).filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
function compactName(name) {
|
|
49
|
+
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
50
|
+
}
|
|
51
|
+
function makeNameMatcher(patterns) {
|
|
52
|
+
const single = [];
|
|
53
|
+
const multi = [];
|
|
54
|
+
for (const pattern of patterns) {
|
|
55
|
+
const segs = nameSegments(pattern);
|
|
56
|
+
if (segs.length <= 1) {
|
|
57
|
+
single.push(compactName(pattern));
|
|
58
|
+
} else {
|
|
59
|
+
multi.push(compactName(pattern));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return (name) => {
|
|
63
|
+
const segs = new Set(nameSegments(name));
|
|
64
|
+
if (single.some((p) => segs.has(p))) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
const flat = compactName(name);
|
|
68
|
+
return multi.some((p) => flat.includes(p));
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/rules/audit-pii-declared.ts
|
|
73
|
+
var RULE_NAME = "audit-pii-declared";
|
|
74
|
+
var DEFAULT_PAYLOAD_KEYS = ["metadata", "before", "after"];
|
|
75
|
+
var DEFAULT_PII_FIELDS = [
|
|
76
|
+
"email",
|
|
77
|
+
"phone",
|
|
78
|
+
"mobile",
|
|
79
|
+
"address",
|
|
80
|
+
"firstName",
|
|
81
|
+
"lastName",
|
|
82
|
+
"fullName",
|
|
83
|
+
"displayName",
|
|
84
|
+
"surname",
|
|
85
|
+
"birthDate",
|
|
86
|
+
"dateOfBirth"
|
|
87
|
+
];
|
|
88
|
+
var DEFAULT_NON_PII_PREFIXES = ["is", "has", "was", "should", "can"];
|
|
89
|
+
var DEFAULT_NON_PII_SUFFIXES = [
|
|
90
|
+
"sent",
|
|
91
|
+
"verified",
|
|
92
|
+
"confirmed",
|
|
93
|
+
"enabled",
|
|
94
|
+
"disabled",
|
|
95
|
+
"changed",
|
|
96
|
+
"required",
|
|
97
|
+
"count",
|
|
98
|
+
"type",
|
|
99
|
+
"kind",
|
|
100
|
+
"status",
|
|
101
|
+
"id",
|
|
102
|
+
"ids"
|
|
103
|
+
];
|
|
104
|
+
var stringList = { type: "array", items: { type: "string" }, uniqueItems: true };
|
|
105
|
+
var optionSchema = {
|
|
106
|
+
type: "object",
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
properties: {
|
|
109
|
+
auditCallees: { ...stringList, default: [] },
|
|
110
|
+
payloadKeys: { ...stringList, default: [...DEFAULT_PAYLOAD_KEYS] },
|
|
111
|
+
piiFields: { ...stringList, default: [...DEFAULT_PII_FIELDS] },
|
|
112
|
+
registeredFields: { ...stringList, default: [] },
|
|
113
|
+
nonPiiFields: { ...stringList, default: [] },
|
|
114
|
+
nonPiiPrefixes: { ...stringList, default: [...DEFAULT_NON_PII_PREFIXES] },
|
|
115
|
+
nonPiiSuffixes: { ...stringList, default: [...DEFAULT_NON_PII_SUFFIXES] },
|
|
116
|
+
reportOpaque: { type: "boolean", default: true }
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
function calleeText(node) {
|
|
120
|
+
if (node.type === AST_NODE_TYPES2.Identifier) {
|
|
121
|
+
return node.name;
|
|
122
|
+
}
|
|
123
|
+
if (node.type === AST_NODE_TYPES2.ThisExpression) {
|
|
124
|
+
return "this";
|
|
125
|
+
}
|
|
126
|
+
if (node.type === AST_NODE_TYPES2.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES2.Identifier) {
|
|
127
|
+
const object = calleeText(node.object);
|
|
128
|
+
return object === null ? null : `${object}.${node.property.name}`;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function staticKeyName(property) {
|
|
133
|
+
if (property.computed) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const key = property.key;
|
|
137
|
+
if (key.type === AST_NODE_TYPES2.Identifier) {
|
|
138
|
+
return key.name;
|
|
139
|
+
}
|
|
140
|
+
if (key.type === AST_NODE_TYPES2.Literal && typeof key.value === "string") {
|
|
141
|
+
return key.value;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
function valueName(node) {
|
|
146
|
+
if (node.type === AST_NODE_TYPES2.Identifier) {
|
|
147
|
+
return node.name;
|
|
148
|
+
}
|
|
149
|
+
if (node.type === AST_NODE_TYPES2.MemberExpression && !node.computed && node.property.type === AST_NODE_TYPES2.Identifier) {
|
|
150
|
+
return node.property.name;
|
|
151
|
+
}
|
|
152
|
+
if (node.type === AST_NODE_TYPES2.ChainExpression) {
|
|
153
|
+
return valueName(node.expression);
|
|
154
|
+
}
|
|
155
|
+
if (node.type === AST_NODE_TYPES2.TSNonNullExpression || node.type === AST_NODE_TYPES2.TSAsExpression) {
|
|
156
|
+
return valueName(node.expression);
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function isEmptyValue(node) {
|
|
161
|
+
return node.type === AST_NODE_TYPES2.Literal && node.value === null || node.type === AST_NODE_TYPES2.Identifier && node.name === "undefined";
|
|
162
|
+
}
|
|
163
|
+
function isScalarFlag(node) {
|
|
164
|
+
return node.type === AST_NODE_TYPES2.Literal && (typeof node.value === "boolean" || typeof node.value === "number" || node.value === null);
|
|
165
|
+
}
|
|
166
|
+
function spreadSources(node) {
|
|
167
|
+
if (node.type === AST_NODE_TYPES2.ObjectExpression) {
|
|
168
|
+
return [node];
|
|
169
|
+
}
|
|
170
|
+
if (isEmptyValue(node)) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
if (node.type === AST_NODE_TYPES2.ConditionalExpression) {
|
|
174
|
+
const consequent = spreadSources(node.consequent);
|
|
175
|
+
const alternate = spreadSources(node.alternate);
|
|
176
|
+
return consequent === null || alternate === null ? null : [...consequent, ...alternate];
|
|
177
|
+
}
|
|
178
|
+
if (node.type === AST_NODE_TYPES2.LogicalExpression && node.operator === "&&") {
|
|
179
|
+
return spreadSources(node.right);
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
var auditPiiDeclaredRule = createRule({
|
|
184
|
+
name: RULE_NAME,
|
|
185
|
+
meta: {
|
|
186
|
+
type: "problem",
|
|
187
|
+
docs: {
|
|
188
|
+
description: "A PII-shaped key written into an audit payload must be declared, either registered for scrubbing on purge or declared non-PII."
|
|
189
|
+
},
|
|
190
|
+
schema: [optionSchema],
|
|
191
|
+
messages: {
|
|
192
|
+
undeclaredPiiKey: "`{{key}}` looks like personal data in audit `{{payload}}`. Add it to `registeredFields` (the keys your purger scrubs) or, if it is not personal data, to `nonPiiFields`.",
|
|
193
|
+
undeclaredPiiValue: "`{{key}}` in audit `{{payload}}` holds `{{value}}`, which looks like personal data. Add `{{key}}` to `registeredFields` (the keys your purger scrubs) or, if it is not personal data, to `nonPiiFields`.",
|
|
194
|
+
opaquePayload: "Audit `{{payload}}` cannot be read statically ({{why}}), so its keys cannot be checked against the PII registry. Write it as an object literal."
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
defaultOptions: [
|
|
198
|
+
{
|
|
199
|
+
auditCallees: [],
|
|
200
|
+
payloadKeys: DEFAULT_PAYLOAD_KEYS,
|
|
201
|
+
piiFields: DEFAULT_PII_FIELDS,
|
|
202
|
+
registeredFields: [],
|
|
203
|
+
nonPiiFields: [],
|
|
204
|
+
nonPiiPrefixes: DEFAULT_NON_PII_PREFIXES,
|
|
205
|
+
nonPiiSuffixes: DEFAULT_NON_PII_SUFFIXES,
|
|
206
|
+
reportOpaque: true
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
create(context, [options]) {
|
|
210
|
+
const auditCallees = options.auditCallees ?? [];
|
|
211
|
+
if (auditCallees.length === 0) {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
const payloadKeys = new Set(options.payloadKeys ?? DEFAULT_PAYLOAD_KEYS);
|
|
215
|
+
const looksLikePii = makeNameMatcher(options.piiFields ?? DEFAULT_PII_FIELDS);
|
|
216
|
+
const declared = /* @__PURE__ */ new Set([
|
|
217
|
+
...options.registeredFields ?? [],
|
|
218
|
+
...options.nonPiiFields ?? []
|
|
219
|
+
]);
|
|
220
|
+
const reportOpaque = options.reportOpaque ?? true;
|
|
221
|
+
const nonPiiPrefixes = new Set(options.nonPiiPrefixes ?? DEFAULT_NON_PII_PREFIXES);
|
|
222
|
+
const nonPiiSuffixes = new Set(options.nonPiiSuffixes ?? DEFAULT_NON_PII_SUFFIXES);
|
|
223
|
+
const exactPii = new Set(
|
|
224
|
+
(options.piiFields ?? DEFAULT_PII_FIELDS).map((field) => compactName(field))
|
|
225
|
+
);
|
|
226
|
+
const isPiiName = (name) => {
|
|
227
|
+
if (!looksLikePii(name)) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
if (exactPii.has(compactName(name))) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
const segments = nameSegments(name);
|
|
234
|
+
const first = segments[0];
|
|
235
|
+
const last = segments[segments.length - 1];
|
|
236
|
+
const isAbout = segments.length > 1 && (first !== void 0 && nonPiiPrefixes.has(first) || last !== void 0 && nonPiiSuffixes.has(last));
|
|
237
|
+
return !isAbout;
|
|
238
|
+
};
|
|
239
|
+
const isAuditCall = (callee) => {
|
|
240
|
+
const text = calleeText(callee);
|
|
241
|
+
return text !== null && auditCallees.some((entry) => text === entry || text.endsWith(`.${entry}`));
|
|
242
|
+
};
|
|
243
|
+
const opaque = (node, payload, why) => {
|
|
244
|
+
if (reportOpaque) {
|
|
245
|
+
context.report({ node, messageId: "opaquePayload", data: { payload, why } });
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const checkObject = (object, payload) => {
|
|
249
|
+
for (const property of object.properties) {
|
|
250
|
+
if (property.type === AST_NODE_TYPES2.SpreadElement) {
|
|
251
|
+
const sources = spreadSources(property.argument);
|
|
252
|
+
if (sources === null) {
|
|
253
|
+
opaque(property, payload, "it spreads a value that is not an object literal");
|
|
254
|
+
} else {
|
|
255
|
+
sources.forEach((source) => checkObject(source, payload));
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const key = staticKeyName(property);
|
|
260
|
+
if (key === null) {
|
|
261
|
+
opaque(property.key, payload, "it has a computed key");
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const value = property.value;
|
|
265
|
+
if (!declared.has(key) && !isScalarFlag(value)) {
|
|
266
|
+
if (isPiiName(key)) {
|
|
267
|
+
context.report({
|
|
268
|
+
node: property.key,
|
|
269
|
+
messageId: "undeclaredPiiKey",
|
|
270
|
+
data: { key, payload }
|
|
271
|
+
});
|
|
272
|
+
} else {
|
|
273
|
+
const name = valueName(value);
|
|
274
|
+
if (name !== null && isPiiName(name)) {
|
|
275
|
+
context.report({
|
|
276
|
+
node: property.key,
|
|
277
|
+
messageId: "undeclaredPiiValue",
|
|
278
|
+
data: { key, payload, value: context.sourceCode.getText(value) }
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
checkNested(value, payload);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const checkNested = (node, payload) => {
|
|
287
|
+
if (node.type === AST_NODE_TYPES2.ObjectExpression) {
|
|
288
|
+
checkObject(node, payload);
|
|
289
|
+
} else if (node.type === AST_NODE_TYPES2.ArrayExpression) {
|
|
290
|
+
for (const element of node.elements) {
|
|
291
|
+
if (element !== null && element.type !== AST_NODE_TYPES2.SpreadElement) {
|
|
292
|
+
checkNested(element, payload);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const checkParams = (params) => {
|
|
298
|
+
for (const property of params.properties) {
|
|
299
|
+
if (property.type === AST_NODE_TYPES2.SpreadElement) {
|
|
300
|
+
const sources = spreadSources(property.argument);
|
|
301
|
+
if (sources === null) {
|
|
302
|
+
opaque(property, "params", "it spreads a value that is not an object literal");
|
|
303
|
+
} else {
|
|
304
|
+
sources.forEach(checkParams);
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const key = staticKeyName(property);
|
|
309
|
+
if (key === null || !payloadKeys.has(key)) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const value = property.value;
|
|
313
|
+
if (value.type === AST_NODE_TYPES2.ObjectExpression) {
|
|
314
|
+
checkObject(value, key);
|
|
315
|
+
} else if (!isEmptyValue(value)) {
|
|
316
|
+
opaque(value, key, "it is not an object literal");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
return {
|
|
321
|
+
CallExpression(node) {
|
|
322
|
+
if (!isAuditCall(node.callee)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const [params] = node.arguments;
|
|
326
|
+
if (params === void 0) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (params.type !== AST_NODE_TYPES2.ObjectExpression) {
|
|
330
|
+
opaque(params, "params", "the audit call is not given an object literal");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
checkParams(params);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
});
|
|
48
338
|
|
|
49
339
|
// src/rules/no-error-detail-loss.ts
|
|
50
|
-
|
|
340
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES3 } from "@typescript-eslint/utils";
|
|
341
|
+
var RULE_NAME2 = "no-error-detail-loss";
|
|
51
342
|
function containsLoggerCall(block, loggers) {
|
|
52
343
|
let found = false;
|
|
53
344
|
function walk(node) {
|
|
54
345
|
if (found) {
|
|
55
346
|
return;
|
|
56
347
|
}
|
|
57
|
-
if (node.type ===
|
|
348
|
+
if (node.type === AST_NODE_TYPES3.CallExpression && loggerCallMethod(node, loggers) !== null) {
|
|
58
349
|
found = true;
|
|
59
350
|
return;
|
|
60
351
|
}
|
|
@@ -79,19 +370,19 @@ function containsLoggerCall(block, loggers) {
|
|
|
79
370
|
}
|
|
80
371
|
function isLossyReference(id) {
|
|
81
372
|
const parent = id.parent;
|
|
82
|
-
if (parent.type ===
|
|
373
|
+
if (parent.type === AST_NODE_TYPES3.MemberExpression && parent.object === id && !parent.computed && parent.property.type === AST_NODE_TYPES3.Identifier && parent.property.name === "message") {
|
|
83
374
|
return true;
|
|
84
375
|
}
|
|
85
|
-
if (parent.type ===
|
|
376
|
+
if (parent.type === AST_NODE_TYPES3.CallExpression && parent.callee.type === AST_NODE_TYPES3.Identifier && parent.callee.name === "String" && parent.arguments.includes(id)) {
|
|
86
377
|
return true;
|
|
87
378
|
}
|
|
88
|
-
if (parent.type ===
|
|
379
|
+
if (parent.type === AST_NODE_TYPES3.TemplateLiteral) {
|
|
89
380
|
return true;
|
|
90
381
|
}
|
|
91
382
|
return false;
|
|
92
383
|
}
|
|
93
384
|
var noErrorDetailLossRule = createRule({
|
|
94
|
-
name:
|
|
385
|
+
name: RULE_NAME2,
|
|
95
386
|
meta: {
|
|
96
387
|
type: "suggestion",
|
|
97
388
|
docs: {
|
|
@@ -107,7 +398,7 @@ var noErrorDetailLossRule = createRule({
|
|
|
107
398
|
const loggers = new Set(DEFAULT_LOGGERS);
|
|
108
399
|
return {
|
|
109
400
|
CatchClause(node) {
|
|
110
|
-
if (node.param === null || node.param.type !==
|
|
401
|
+
if (node.param === null || node.param.type !== AST_NODE_TYPES3.Identifier) {
|
|
111
402
|
return;
|
|
112
403
|
}
|
|
113
404
|
if (!containsLoggerCall(node.body, loggers)) {
|
|
@@ -138,8 +429,8 @@ var noErrorDetailLossRule = createRule({
|
|
|
138
429
|
});
|
|
139
430
|
|
|
140
431
|
// src/rules/no-sensitive-fields-in-logs.ts
|
|
141
|
-
import { AST_NODE_TYPES as
|
|
142
|
-
var
|
|
432
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES4 } from "@typescript-eslint/utils";
|
|
433
|
+
var RULE_NAME3 = "no-sensitive-fields-in-logs";
|
|
143
434
|
var DEFAULT_DENY_NAMES = [
|
|
144
435
|
"password",
|
|
145
436
|
"token",
|
|
@@ -149,7 +440,7 @@ var DEFAULT_DENY_NAMES = [
|
|
|
149
440
|
"apiKey",
|
|
150
441
|
"ssn"
|
|
151
442
|
];
|
|
152
|
-
var
|
|
443
|
+
var optionSchema2 = {
|
|
153
444
|
type: "object",
|
|
154
445
|
additionalProperties: false,
|
|
155
446
|
properties: {
|
|
@@ -159,47 +450,21 @@ var optionSchema = {
|
|
|
159
450
|
}
|
|
160
451
|
}
|
|
161
452
|
};
|
|
162
|
-
function nameSegments(name) {
|
|
163
|
-
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^a-zA-Z0-9]+/).join(" ").toLowerCase().split(/\s+/).filter(Boolean);
|
|
164
|
-
}
|
|
165
|
-
function compact(name) {
|
|
166
|
-
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
167
|
-
}
|
|
168
|
-
function makeMatcher(denyNames) {
|
|
169
|
-
const single = [];
|
|
170
|
-
const multi = [];
|
|
171
|
-
for (const deny of denyNames) {
|
|
172
|
-
const segs = nameSegments(deny);
|
|
173
|
-
if (segs.length <= 1) {
|
|
174
|
-
single.push(compact(deny));
|
|
175
|
-
} else {
|
|
176
|
-
multi.push(compact(deny));
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return (name) => {
|
|
180
|
-
const segs = new Set(nameSegments(name));
|
|
181
|
-
if (single.some((deny) => segs.has(deny))) {
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
184
|
-
const flat = compact(name);
|
|
185
|
-
return multi.some((deny) => flat.includes(deny));
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
453
|
var noSensitiveFieldsInLogsRule = createRule({
|
|
189
|
-
name:
|
|
454
|
+
name: RULE_NAME3,
|
|
190
455
|
meta: {
|
|
191
456
|
type: "suggestion",
|
|
192
457
|
docs: {
|
|
193
458
|
description: "A logger call must not reference an identifier, property, or object key whose name matches a sensitive-field denylist (password, token, secret, ...). Redact it before logging."
|
|
194
459
|
},
|
|
195
|
-
schema: [
|
|
460
|
+
schema: [optionSchema2],
|
|
196
461
|
messages: {
|
|
197
462
|
sensitiveField: "`{{name}}` looks like a sensitive field being written to a log sink. Redact it before logging (e.g. `redact({{name}})`) or omit it \u2014 logs are long-lived and widely readable."
|
|
198
463
|
}
|
|
199
464
|
},
|
|
200
465
|
defaultOptions: [{ denyNames: DEFAULT_DENY_NAMES }],
|
|
201
466
|
create(context, [options]) {
|
|
202
|
-
const matches =
|
|
467
|
+
const matches = makeNameMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
|
|
203
468
|
function walk(node, visit) {
|
|
204
469
|
visit(node);
|
|
205
470
|
for (const key of Object.keys(node)) {
|
|
@@ -238,9 +503,9 @@ var noSensitiveFieldsInLogsRule = createRule({
|
|
|
238
503
|
};
|
|
239
504
|
for (const arg of node.arguments) {
|
|
240
505
|
walk(arg, (n) => {
|
|
241
|
-
if (n.type ===
|
|
506
|
+
if (n.type === AST_NODE_TYPES4.Identifier && matches(n.name)) {
|
|
242
507
|
report(n.name, n);
|
|
243
|
-
} else if (n.type ===
|
|
508
|
+
} else if (n.type === AST_NODE_TYPES4.Property && n.key.type === AST_NODE_TYPES4.Literal && typeof n.key.value === "string" && matches(n.key.value)) {
|
|
244
509
|
report(n.key.value, n.key);
|
|
245
510
|
}
|
|
246
511
|
});
|
|
@@ -251,9 +516,9 @@ var noSensitiveFieldsInLogsRule = createRule({
|
|
|
251
516
|
});
|
|
252
517
|
|
|
253
518
|
// src/rules/structured-log-arguments.ts
|
|
254
|
-
import { AST_NODE_TYPES as
|
|
255
|
-
var
|
|
256
|
-
var
|
|
519
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES5 } from "@typescript-eslint/utils";
|
|
520
|
+
var RULE_NAME4 = "structured-log-arguments";
|
|
521
|
+
var optionSchema3 = {
|
|
257
522
|
type: "object",
|
|
258
523
|
additionalProperties: false,
|
|
259
524
|
properties: {
|
|
@@ -264,13 +529,13 @@ var optionSchema2 = {
|
|
|
264
529
|
}
|
|
265
530
|
};
|
|
266
531
|
var structuredLogArgumentsRule = createRule({
|
|
267
|
-
name:
|
|
532
|
+
name: RULE_NAME4,
|
|
268
533
|
meta: {
|
|
269
534
|
type: "suggestion",
|
|
270
535
|
docs: {
|
|
271
536
|
description: "A logger call must not interpolate dynamic values into the message string (a template literal with expressions). Pass a static message and a structured context object instead."
|
|
272
537
|
},
|
|
273
|
-
schema: [
|
|
538
|
+
schema: [optionSchema3],
|
|
274
539
|
messages: {
|
|
275
540
|
interpolatedMessage: "This `.{{method}}(...)` call interpolates dynamic values into the message string \u2014 they become unqueryable free text. Pass a static message plus a structured context object (e.g. `log.{{method}}('processing task', { taskId })`) so each field stays indexable."
|
|
276
541
|
}
|
|
@@ -285,7 +550,7 @@ var structuredLogArgumentsRule = createRule({
|
|
|
285
550
|
return;
|
|
286
551
|
}
|
|
287
552
|
for (const arg of node.arguments) {
|
|
288
|
-
if (arg.type ===
|
|
553
|
+
if (arg.type === AST_NODE_TYPES5.TemplateLiteral && arg.expressions.length > 0) {
|
|
289
554
|
context.report({
|
|
290
555
|
node: arg,
|
|
291
556
|
messageId: "interpolatedMessage",
|
|
@@ -302,12 +567,13 @@ var structuredLogArgumentsRule = createRule({
|
|
|
302
567
|
var rules = {
|
|
303
568
|
"structured-log-arguments": structuredLogArgumentsRule,
|
|
304
569
|
"no-sensitive-fields-in-logs": noSensitiveFieldsInLogsRule,
|
|
305
|
-
"no-error-detail-loss": noErrorDetailLossRule
|
|
570
|
+
"no-error-detail-loss": noErrorDetailLossRule,
|
|
571
|
+
"audit-pii-declared": auditPiiDeclaredRule
|
|
306
572
|
};
|
|
307
573
|
|
|
308
574
|
// src/index.ts
|
|
309
575
|
var NAMESPACE = "noctcore-observability";
|
|
310
|
-
var VERSION = "0.
|
|
576
|
+
var VERSION = "0.2.0";
|
|
311
577
|
var plugin = {
|
|
312
578
|
meta: { name: "@noctcore/eslint-plugin-observability", version: VERSION },
|
|
313
579
|
rules,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# `noctcore-observability/audit-pii-declared`
|
|
2
|
+
|
|
3
|
+
> A PII-shaped key written into an audit payload must be declared: registered for scrubbing when its
|
|
4
|
+
> subject is purged, or declared not to be personal data.
|
|
5
|
+
|
|
6
|
+
## What this rule does and does not prove
|
|
7
|
+
|
|
8
|
+
Read this section before enabling the rule.
|
|
9
|
+
|
|
10
|
+
It **enforces declaration, not deletion.** It proves that every PII-shaped key an audit write puts
|
|
11
|
+
into a payload it can read appears in a list you maintain. It does **not** prove:
|
|
12
|
+
|
|
13
|
+
- that anything reads that list, or that your purge job runs, succeeds, or scrubs those keys;
|
|
14
|
+
- that a registered value is actually personal data, or that a `nonPiiFields` entry is not;
|
|
15
|
+
- anything about payloads it cannot read (see `reportOpaque`) or keys its name heuristic does not
|
|
16
|
+
recognise as PII (a `note` holding an email address passes);
|
|
17
|
+
- anything about audit columns outside the payload keys (`ipAddress`, `userAgent`), or about audit
|
|
18
|
+
rows written by code that does not go through `auditCallees`.
|
|
19
|
+
|
|
20
|
+
The rule is one half of a contract. The other half is a purge job that anonymizes the registered keys
|
|
21
|
+
in the audit rows of a user it deletes, and a test of that job. Without that half, this rule is a
|
|
22
|
+
list with no reader.
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
Audit rows outlive the request that wrote them, and usually outlive the user. A policy of "raw email
|
|
27
|
+
stays in audit metadata while the user exists and is scrubbed when the user is purged" keeps the trail
|
|
28
|
+
useful for who-did-what while the data does not outlive the person. The purge job can only scrub keys
|
|
29
|
+
it knows about. This rule keeps that list complete as new audit writes are added: a new
|
|
30
|
+
`metadata: { newEmail }` fails lint until someone decides whether `newEmail` goes in the registry.
|
|
31
|
+
|
|
32
|
+
## What it flags
|
|
33
|
+
|
|
34
|
+
An audit write is a call whose callee text is an `auditCallees` entry, or ends with `.<entry>`
|
|
35
|
+
(`this.auditService.log` matches `auditService.log`). The rule reads the first argument's
|
|
36
|
+
`payloadKeys` properties (`metadata`, `before`, `after` by default). In a payload object literal, at
|
|
37
|
+
any depth, including nested objects, array elements and literal spreads, it reports:
|
|
38
|
+
|
|
39
|
+
- **`undeclaredPiiKey`**: a key matching `piiFields` that is in neither `registeredFields` nor
|
|
40
|
+
`nonPiiFields`;
|
|
41
|
+
- **`undeclaredPiiValue`**: an undeclared, neutral key whose value is an identifier or member access
|
|
42
|
+
named like PII (`target: user.email`). The key is what the purger would have to scrub, so the key
|
|
43
|
+
is what must be declared;
|
|
44
|
+
- **`opaquePayload`**: a payload the rule cannot read: a payload or params that is not an object
|
|
45
|
+
literal, a spread of a non-literal (`...subject.fence`, `...(extra ?? {})`), or a computed key. A
|
|
46
|
+
bag the rule cannot see is a bag it cannot vouch for. Turn this off with `reportOpaque: false` if
|
|
47
|
+
you accept that gap.
|
|
48
|
+
|
|
49
|
+
A spread the rule *can* read is not opaque: `...{ a }`, both branches of `...(ok ? { a } : {})`, the
|
|
50
|
+
right side of `...(ok && { a })`, and `null` / `undefined`.
|
|
51
|
+
|
|
52
|
+
### Name matching
|
|
53
|
+
|
|
54
|
+
`piiFields` entries match like `no-sensitive-fields-in-logs`: a single-word entry (`email`) matches a
|
|
55
|
+
camelCase or snake_case segment (`newEmail`, `previous_email`) but not a word that merely contains it;
|
|
56
|
+
a multi-word entry (`firstName`) matches the compacted name (`contactFirstName`).
|
|
57
|
+
|
|
58
|
+
Some PII-shaped names describe the data rather than hold it. A name whose first segment is in
|
|
59
|
+
`nonPiiPrefixes` (`isEmailPublic`) or whose last segment is in `nonPiiSuffixes` (`emailSent`,
|
|
60
|
+
`phoneVerified`, `addressId`) is not reported. A key whose value is a boolean, number or `null`
|
|
61
|
+
literal is not reported either. A name equal to a `piiFields` entry (`nationalId`) is always PII,
|
|
62
|
+
whatever its suffix.
|
|
63
|
+
|
|
64
|
+
Bare `name` is not in the default list, because it would flag `fileName` and `templateName`. Add it
|
|
65
|
+
if your audit payloads carry person names under that key.
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
| Option | Type | Default | Meaning |
|
|
70
|
+
| --- | --- | --- | --- |
|
|
71
|
+
| `auditCallees` | `string[]` | `[]` | Callee texts that are audit writes. Empty means the rule is inert. |
|
|
72
|
+
| `payloadKeys` | `string[]` | `['metadata', 'before', 'after']` | Properties of the audit call's params that hold free-form payload. |
|
|
73
|
+
| `piiFields` | `string[]` | `email`, `phone`, `mobile`, `address`, `firstName`, `lastName`, `fullName`, `displayName`, `surname`, `birthDate`, `dateOfBirth` | Name patterns that are PII-shaped. |
|
|
74
|
+
| `registeredFields` | `string[]` | `[]` | Exact keys the purger scrubs. Declared PII. |
|
|
75
|
+
| `nonPiiFields` | `string[]` | `[]` | Exact keys declared not to be personal data. |
|
|
76
|
+
| `nonPiiPrefixes` | `string[]` | `is`, `has`, `was`, `should`, `can` | A first name segment that marks a flag. |
|
|
77
|
+
| `nonPiiSuffixes` | `string[]` | `sent`, `verified`, `confirmed`, `enabled`, `disabled`, `changed`, `required`, `count`, `type`, `kind`, `status`, `id`, `ids` | A last name segment that marks a flag, count or reference. |
|
|
78
|
+
| `reportOpaque` | `boolean` | `true` | Report payloads the rule cannot read. |
|
|
79
|
+
|
|
80
|
+
## Wiring: one registry, not two
|
|
81
|
+
|
|
82
|
+
The purge job needs the registry at run time and this rule needs it at lint time. Keep **one** copy,
|
|
83
|
+
in code both can import, and pass it into the ESLint config. Never retype it into the config: two
|
|
84
|
+
hand-kept copies of one list drift.
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// packages/shared/src/audit/pii-registry.ts
|
|
88
|
+
/** Audit payload keys holding personal data. The user purge anonymizes these. */
|
|
89
|
+
export const AUDIT_PII_FIELDS = ['newEmail', 'email'] as const;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
// eslint.config.mjs
|
|
94
|
+
import { AUDIT_PII_FIELDS } from '@acme/shared/audit/pii-registry';
|
|
95
|
+
|
|
96
|
+
export default [
|
|
97
|
+
{
|
|
98
|
+
rules: {
|
|
99
|
+
'noctcore-observability/audit-pii-declared': [
|
|
100
|
+
'error',
|
|
101
|
+
{
|
|
102
|
+
auditCallees: ['auditService.log', 'auditService.logOrThrow'],
|
|
103
|
+
registeredFields: [...AUDIT_PII_FIELDS],
|
|
104
|
+
nonPiiFields: ['emailTemplate'],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Examples
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// Bad: raw email in the payload, and no declaration that the purger must scrub it
|
|
116
|
+
await this.auditService.log({
|
|
117
|
+
action: 'auth.email_change.requested',
|
|
118
|
+
userId,
|
|
119
|
+
metadata: { newEmail: normalizedEmail },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Bad: before/after snapshots of a PII column
|
|
123
|
+
await this.auditService.log({
|
|
124
|
+
action: 'auth.email_changed',
|
|
125
|
+
before: { email: previousEmail },
|
|
126
|
+
after: { email: pending.newEmail },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Bad: the rule cannot see what this bag carries
|
|
130
|
+
await this.auditService.log({ action, metadata: { ...grant.auditMetadata } });
|
|
131
|
+
|
|
132
|
+
// Good, with registeredFields: ['newEmail', 'email']
|
|
133
|
+
await this.auditService.log({ action, userId, metadata: { newEmail: normalizedEmail } });
|
|
134
|
+
|
|
135
|
+
// Good: nothing PII-shaped; `emailSent` is a flag about the data
|
|
136
|
+
await this.auditService.log({ action, metadata: { role, outcome, emailSent } });
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## When not to use it
|
|
140
|
+
|
|
141
|
+
If your audit payloads never carry personal data by policy, `no-sensitive-fields-in-logs`-style
|
|
142
|
+
denial (fail on any PII-shaped key) is simpler: set `registeredFields` to `[]` and treat every report
|
|
143
|
+
as a key to remove. If you have no purge job, do not enable this rule as if it gave you one.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `noctcore-observability/no-sensitive-fields-in-logs`
|
|
2
2
|
|
|
3
|
-
> A name-heuristic guard against writing credentials and secrets into log sinks. Ships at `
|
|
3
|
+
> A name-heuristic guard against writing credentials and secrets into log sinks. Ships at `error`.
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
@@ -9,8 +9,10 @@ Logs are long-lived, widely readable, and shipped to third-party aggregators. A
|
|
|
9
9
|
request by months. This rule catches the most common shape — a variable, property, or object key
|
|
10
10
|
whose **name** matches a sensitive-field denylist appearing inside a logger call.
|
|
11
11
|
|
|
12
|
-
It reads **names, never values**, so it is a heuristic
|
|
13
|
-
|
|
12
|
+
It reads **names, never values**, so it is a heuristic. It still ships at `error`: a miss puts a
|
|
13
|
+
credential in a long-lived log sink, while a false positive costs a rename or an explicit `redact()`.
|
|
14
|
+
Tests that log a secret on purpose (to prove a redaction boundary works) should turn the rule off for
|
|
15
|
+
those files in config.
|
|
14
16
|
|
|
15
17
|
## What it flags
|
|
16
18
|
|
|
@@ -55,4 +57,4 @@ type Options = {
|
|
|
55
57
|
## When not to use it
|
|
56
58
|
|
|
57
59
|
If your logging pipeline already redacts sensitive fields centrally (a serializer denylist), this
|
|
58
|
-
rule is redundant. Otherwise keep it on at `
|
|
60
|
+
rule is redundant. Otherwise keep it on at `error` as a second line of defence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-observability",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Structured-logging discipline ESLint rules — context objects over interpolated messages, no sensitive fields in logs, no error-detail loss.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -60,6 +60,6 @@
|
|
|
60
60
|
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
61
61
|
"tsup": "^8.5.1",
|
|
62
62
|
"typescript": "^5.6.0",
|
|
63
|
-
"vitest": "^
|
|
63
|
+
"vitest": "^4"
|
|
64
64
|
}
|
|
65
65
|
}
|