@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 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. | `warn` |
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": "warn",
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/no-error-detail-loss.ts
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 RULE_NAME = "no-error-detail-loss";
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 === import_utils2.AST_NODE_TYPES.CallExpression && loggerCallMethod(node, loggers) !== null) {
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 === import_utils2.AST_NODE_TYPES.MemberExpression && parent.object === id && !parent.computed && parent.property.type === import_utils2.AST_NODE_TYPES.Identifier && parent.property.name === "message") {
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 === import_utils2.AST_NODE_TYPES.CallExpression && parent.callee.type === import_utils2.AST_NODE_TYPES.Identifier && parent.callee.name === "String" && parent.arguments.includes(id)) {
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 === import_utils2.AST_NODE_TYPES.TemplateLiteral) {
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: RULE_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 !== import_utils2.AST_NODE_TYPES.Identifier) {
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 import_utils4 = require("@typescript-eslint/utils");
170
- var RULE_NAME2 = "no-sensitive-fields-in-logs";
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 optionSchema = {
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: RULE_NAME2,
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: [optionSchema],
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 = makeMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
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 === import_utils4.AST_NODE_TYPES.Identifier && matches(n.name)) {
534
+ if (n.type === import_utils6.AST_NODE_TYPES.Identifier && matches(n.name)) {
270
535
  report(n.name, n);
271
- } else if (n.type === import_utils4.AST_NODE_TYPES.Property && n.key.type === import_utils4.AST_NODE_TYPES.Literal && typeof n.key.value === "string" && matches(n.key.value)) {
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 import_utils6 = require("@typescript-eslint/utils");
283
- var RULE_NAME3 = "structured-log-arguments";
284
- var optionSchema2 = {
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: RULE_NAME3,
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: [optionSchema2],
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 === import_utils6.AST_NODE_TYPES.TemplateLiteral && arg.expressions.length > 0) {
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.1.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": "warn",
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/no-error-detail-loss.ts
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
- var RULE_NAME = "no-error-detail-loss";
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 === AST_NODE_TYPES2.CallExpression && loggerCallMethod(node, loggers) !== null) {
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 === AST_NODE_TYPES2.MemberExpression && parent.object === id && !parent.computed && parent.property.type === AST_NODE_TYPES2.Identifier && parent.property.name === "message") {
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 === AST_NODE_TYPES2.CallExpression && parent.callee.type === AST_NODE_TYPES2.Identifier && parent.callee.name === "String" && parent.arguments.includes(id)) {
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 === AST_NODE_TYPES2.TemplateLiteral) {
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: RULE_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 !== AST_NODE_TYPES2.Identifier) {
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 AST_NODE_TYPES3 } from "@typescript-eslint/utils";
142
- var RULE_NAME2 = "no-sensitive-fields-in-logs";
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 optionSchema = {
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: RULE_NAME2,
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: [optionSchema],
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 = makeMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
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 === AST_NODE_TYPES3.Identifier && matches(n.name)) {
506
+ if (n.type === AST_NODE_TYPES4.Identifier && matches(n.name)) {
242
507
  report(n.name, n);
243
- } else if (n.type === AST_NODE_TYPES3.Property && n.key.type === AST_NODE_TYPES3.Literal && typeof n.key.value === "string" && matches(n.key.value)) {
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 AST_NODE_TYPES4 } from "@typescript-eslint/utils";
255
- var RULE_NAME3 = "structured-log-arguments";
256
- var optionSchema2 = {
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: RULE_NAME3,
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: [optionSchema2],
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 === AST_NODE_TYPES4.TemplateLiteral && arg.expressions.length > 0) {
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.1.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 `warn`.
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 and ships at `warn`, not `error` treat a hit
13
- as "look here", not "definitely a bug".
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 `warn` as a second line of defence.
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.1.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": "^3"
63
+ "vitest": "^4"
64
64
  }
65
65
  }