@noctcore/eslint-plugin-observability 0.1.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 +44 -0
- package/dist/index.cjs +354 -0
- package/dist/index.d.cts +45 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +326 -0
- package/docs/rules/no-error-detail-loss.md +54 -0
- package/docs/rules/no-sensitive-fields-in-logs.md +58 -0
- package/docs/rules/structured-log-arguments.md +64 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @noctcore/eslint-plugin-observability
|
|
2
|
+
|
|
3
|
+
Structured-logging discipline rules — context objects over interpolated messages, no sensitive fields
|
|
4
|
+
in logs, no error-detail loss. Flat-config only, ESLint 9+.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add -D @noctcore/eslint-plugin-observability # or npm i -D / pnpm add -D
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Use
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
// eslint.config.js
|
|
16
|
+
import observability from '@noctcore/eslint-plugin-observability';
|
|
17
|
+
|
|
18
|
+
export default [
|
|
19
|
+
observability.configs.recommended,
|
|
20
|
+
];
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or wire rules individually:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import observability from '@noctcore/eslint-plugin-observability';
|
|
27
|
+
|
|
28
|
+
export default [
|
|
29
|
+
{
|
|
30
|
+
plugins: { 'noctcore-observability': observability },
|
|
31
|
+
rules: {
|
|
32
|
+
'noctcore-observability/structured-log-arguments': ['error', { loggers: ['log', 'audit'] }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Rules
|
|
39
|
+
|
|
40
|
+
| Rule | Description | Recommended |
|
|
41
|
+
| --- | --- | --- |
|
|
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` |
|
|
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` |
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
configs: () => configs,
|
|
24
|
+
default: () => index_default,
|
|
25
|
+
rules: () => rules
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/configs/recommended.ts
|
|
30
|
+
var recommended = {
|
|
31
|
+
"noctcore-observability/structured-log-arguments": "error",
|
|
32
|
+
"noctcore-observability/no-sensitive-fields-in-logs": "warn",
|
|
33
|
+
"noctcore-observability/no-error-detail-loss": "error"
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/rules/no-error-detail-loss.ts
|
|
37
|
+
var import_utils2 = require("@typescript-eslint/utils");
|
|
38
|
+
|
|
39
|
+
// src/createRule.ts
|
|
40
|
+
var import_eslint_utils = require("@noctcore/eslint-utils");
|
|
41
|
+
var createRule = (0, import_eslint_utils.makeCreateRule)("observability");
|
|
42
|
+
|
|
43
|
+
// src/utils.ts
|
|
44
|
+
var import_utils = require("@typescript-eslint/utils");
|
|
45
|
+
var DEFAULT_LOGGERS = ["console", "logger", "log"];
|
|
46
|
+
var LOG_METHODS = /* @__PURE__ */ new Set([
|
|
47
|
+
"info",
|
|
48
|
+
"warn",
|
|
49
|
+
"error",
|
|
50
|
+
"debug"
|
|
51
|
+
]);
|
|
52
|
+
function loggerObjectName(object) {
|
|
53
|
+
if (object.type === import_utils.AST_NODE_TYPES.Identifier) {
|
|
54
|
+
return object.name;
|
|
55
|
+
}
|
|
56
|
+
if (object.type === import_utils.AST_NODE_TYPES.MemberExpression && !object.computed && object.property.type === import_utils.AST_NODE_TYPES.Identifier) {
|
|
57
|
+
return object.property.name;
|
|
58
|
+
}
|
|
59
|
+
return void 0;
|
|
60
|
+
}
|
|
61
|
+
function loggerCallMethod(node, loggers) {
|
|
62
|
+
const callee = node.callee;
|
|
63
|
+
if (callee.type !== import_utils.AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== import_utils.AST_NODE_TYPES.Identifier) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const method = callee.property.name;
|
|
67
|
+
if (!LOG_METHODS.has(method)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const name = loggerObjectName(callee.object);
|
|
71
|
+
if (name === void 0 || !loggers.has(name)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return method;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/rules/no-error-detail-loss.ts
|
|
78
|
+
var RULE_NAME = "no-error-detail-loss";
|
|
79
|
+
function containsLoggerCall(block, loggers) {
|
|
80
|
+
let found = false;
|
|
81
|
+
function walk(node) {
|
|
82
|
+
if (found) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (node.type === import_utils2.AST_NODE_TYPES.CallExpression && loggerCallMethod(node, loggers) !== null) {
|
|
86
|
+
found = true;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const key of Object.keys(node)) {
|
|
90
|
+
if (key === "parent") {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const value = node[key];
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
for (const child of value) {
|
|
96
|
+
if (child && typeof child.type === "string") {
|
|
97
|
+
walk(child);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else if (value && typeof value.type === "string") {
|
|
101
|
+
walk(value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
walk(block);
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
function isLossyReference(id) {
|
|
109
|
+
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") {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
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)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (parent.type === import_utils2.AST_NODE_TYPES.TemplateLiteral) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
var noErrorDetailLossRule = createRule({
|
|
122
|
+
name: RULE_NAME,
|
|
123
|
+
meta: {
|
|
124
|
+
type: "suggestion",
|
|
125
|
+
docs: {
|
|
126
|
+
description: "A catch block that reports a failure must not reduce the error to only `e.message` / `String(e)` / `${e}` \u2014 log the error itself so its stack and cause survive."
|
|
127
|
+
},
|
|
128
|
+
schema: [],
|
|
129
|
+
messages: {
|
|
130
|
+
detailLoss: "This catch logs only the error message \u2014 its stack and `cause` are lost. Log the error object itself (e.g. `log.error('...', { err: {{name}} })`) so the failure is diagnosable."
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
defaultOptions: [],
|
|
134
|
+
create(context) {
|
|
135
|
+
const loggers = new Set(DEFAULT_LOGGERS);
|
|
136
|
+
return {
|
|
137
|
+
CatchClause(node) {
|
|
138
|
+
if (node.param === null || node.param.type !== import_utils2.AST_NODE_TYPES.Identifier) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!containsLoggerCall(node.body, loggers)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const [variable] = context.sourceCode.getDeclaredVariables(node);
|
|
145
|
+
if (variable === void 0) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const reads = variable.references.filter((ref) => ref.isRead());
|
|
149
|
+
if (reads.length === 0) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const allLossy = reads.every(
|
|
153
|
+
(ref) => isLossyReference(ref.identifier)
|
|
154
|
+
);
|
|
155
|
+
if (!allLossy) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
context.report({
|
|
159
|
+
node: node.param,
|
|
160
|
+
messageId: "detailLoss",
|
|
161
|
+
data: { name: node.param.name }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// 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";
|
|
171
|
+
var DEFAULT_DENY_NAMES = [
|
|
172
|
+
"password",
|
|
173
|
+
"token",
|
|
174
|
+
"secret",
|
|
175
|
+
"authorization",
|
|
176
|
+
"cookie",
|
|
177
|
+
"apiKey",
|
|
178
|
+
"ssn"
|
|
179
|
+
];
|
|
180
|
+
var optionSchema = {
|
|
181
|
+
type: "object",
|
|
182
|
+
additionalProperties: false,
|
|
183
|
+
properties: {
|
|
184
|
+
denyNames: {
|
|
185
|
+
type: "array",
|
|
186
|
+
items: { type: "string" }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
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
|
+
var noSensitiveFieldsInLogsRule = createRule({
|
|
217
|
+
name: RULE_NAME2,
|
|
218
|
+
meta: {
|
|
219
|
+
type: "suggestion",
|
|
220
|
+
docs: {
|
|
221
|
+
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
|
+
},
|
|
223
|
+
schema: [optionSchema],
|
|
224
|
+
messages: {
|
|
225
|
+
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
|
+
}
|
|
227
|
+
},
|
|
228
|
+
defaultOptions: [{ denyNames: DEFAULT_DENY_NAMES }],
|
|
229
|
+
create(context, [options]) {
|
|
230
|
+
const matches = makeMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
|
|
231
|
+
function walk(node, visit) {
|
|
232
|
+
visit(node);
|
|
233
|
+
for (const key of Object.keys(node)) {
|
|
234
|
+
if (key === "parent") {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const value = node[key];
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
for (const child of value) {
|
|
240
|
+
if (child && typeof child.type === "string") {
|
|
241
|
+
walk(child, visit);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} else if (value && typeof value.type === "string") {
|
|
245
|
+
walk(value, visit);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
CallExpression(node) {
|
|
251
|
+
if (loggerCallMethod(node, new Set(DEFAULT_LOGGERS)) === null) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const reported = /* @__PURE__ */ new Set();
|
|
255
|
+
const report = (name, target) => {
|
|
256
|
+
const at = target.range[0];
|
|
257
|
+
if (reported.has(at)) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
reported.add(at);
|
|
261
|
+
context.report({
|
|
262
|
+
node: target,
|
|
263
|
+
messageId: "sensitiveField",
|
|
264
|
+
data: { name }
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
for (const arg of node.arguments) {
|
|
268
|
+
walk(arg, (n) => {
|
|
269
|
+
if (n.type === import_utils4.AST_NODE_TYPES.Identifier && matches(n.name)) {
|
|
270
|
+
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)) {
|
|
272
|
+
report(n.key.value, n.key);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// src/rules/structured-log-arguments.ts
|
|
282
|
+
var import_utils6 = require("@typescript-eslint/utils");
|
|
283
|
+
var RULE_NAME3 = "structured-log-arguments";
|
|
284
|
+
var optionSchema2 = {
|
|
285
|
+
type: "object",
|
|
286
|
+
additionalProperties: false,
|
|
287
|
+
properties: {
|
|
288
|
+
loggers: {
|
|
289
|
+
type: "array",
|
|
290
|
+
items: { type: "string" }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
var structuredLogArgumentsRule = createRule({
|
|
295
|
+
name: RULE_NAME3,
|
|
296
|
+
meta: {
|
|
297
|
+
type: "suggestion",
|
|
298
|
+
docs: {
|
|
299
|
+
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
|
+
},
|
|
301
|
+
schema: [optionSchema2],
|
|
302
|
+
messages: {
|
|
303
|
+
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
|
+
}
|
|
305
|
+
},
|
|
306
|
+
defaultOptions: [{ loggers: DEFAULT_LOGGERS }],
|
|
307
|
+
create(context, [options]) {
|
|
308
|
+
const loggers = new Set(options.loggers ?? DEFAULT_LOGGERS);
|
|
309
|
+
return {
|
|
310
|
+
CallExpression(node) {
|
|
311
|
+
const method = loggerCallMethod(node, loggers);
|
|
312
|
+
if (method === null) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
for (const arg of node.arguments) {
|
|
316
|
+
if (arg.type === import_utils6.AST_NODE_TYPES.TemplateLiteral && arg.expressions.length > 0) {
|
|
317
|
+
context.report({
|
|
318
|
+
node: arg,
|
|
319
|
+
messageId: "interpolatedMessage",
|
|
320
|
+
data: { method }
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// src/rules/index.ts
|
|
330
|
+
var rules = {
|
|
331
|
+
"structured-log-arguments": structuredLogArgumentsRule,
|
|
332
|
+
"no-sensitive-fields-in-logs": noSensitiveFieldsInLogsRule,
|
|
333
|
+
"no-error-detail-loss": noErrorDetailLossRule
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// src/index.ts
|
|
337
|
+
var NAMESPACE = "noctcore-observability";
|
|
338
|
+
var VERSION = "0.1.0";
|
|
339
|
+
var plugin = {
|
|
340
|
+
meta: { name: "@noctcore/eslint-plugin-observability", version: VERSION },
|
|
341
|
+
rules,
|
|
342
|
+
configs: {}
|
|
343
|
+
};
|
|
344
|
+
plugin.configs.recommended = {
|
|
345
|
+
plugins: { [NAMESPACE]: plugin },
|
|
346
|
+
rules: recommended
|
|
347
|
+
};
|
|
348
|
+
var configs = plugin.configs;
|
|
349
|
+
var index_default = plugin;
|
|
350
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
351
|
+
0 && (module.exports = {
|
|
352
|
+
configs,
|
|
353
|
+
rules
|
|
354
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
|
+
|
|
3
|
+
interface NoSensitiveFieldsInLogsOptions {
|
|
4
|
+
readonly denyNames?: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface StructuredLogArgumentsOptions {
|
|
8
|
+
readonly loggers?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
|
|
12
|
+
declare const rules: {
|
|
13
|
+
'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
16
|
+
'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
20
|
+
name: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
declare const plugin: {
|
|
25
|
+
meta: {
|
|
26
|
+
name: string;
|
|
27
|
+
version: string;
|
|
28
|
+
};
|
|
29
|
+
rules: {
|
|
30
|
+
'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
31
|
+
name: string;
|
|
32
|
+
};
|
|
33
|
+
'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
36
|
+
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
37
|
+
name: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
configs: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
declare const configs: Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
export { configs, plugin as default, rules };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as _typescript_eslint_utils_ts_eslint from '@typescript-eslint/utils/ts-eslint';
|
|
2
|
+
|
|
3
|
+
interface NoSensitiveFieldsInLogsOptions {
|
|
4
|
+
readonly denyNames?: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface StructuredLogArgumentsOptions {
|
|
8
|
+
readonly loggers?: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Every rule this plugin exposes, keyed by its (unprefixed) rule id. */
|
|
12
|
+
declare const rules: {
|
|
13
|
+
'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
14
|
+
name: string;
|
|
15
|
+
};
|
|
16
|
+
'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
20
|
+
name: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
declare const plugin: {
|
|
25
|
+
meta: {
|
|
26
|
+
name: string;
|
|
27
|
+
version: string;
|
|
28
|
+
};
|
|
29
|
+
rules: {
|
|
30
|
+
'structured-log-arguments': _typescript_eslint_utils_ts_eslint.RuleModule<"interpolatedMessage", [StructuredLogArgumentsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
31
|
+
name: string;
|
|
32
|
+
};
|
|
33
|
+
'no-sensitive-fields-in-logs': _typescript_eslint_utils_ts_eslint.RuleModule<"sensitiveField", [NoSensitiveFieldsInLogsOptions], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
34
|
+
name: string;
|
|
35
|
+
};
|
|
36
|
+
'no-error-detail-loss': _typescript_eslint_utils_ts_eslint.RuleModule<"detailLoss", [], unknown, _typescript_eslint_utils_ts_eslint.RuleListener> & {
|
|
37
|
+
name: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
configs: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
declare const configs: Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
export { configs, plugin as default, rules };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// src/configs/recommended.ts
|
|
2
|
+
var recommended = {
|
|
3
|
+
"noctcore-observability/structured-log-arguments": "error",
|
|
4
|
+
"noctcore-observability/no-sensitive-fields-in-logs": "warn",
|
|
5
|
+
"noctcore-observability/no-error-detail-loss": "error"
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// src/rules/no-error-detail-loss.ts
|
|
9
|
+
import { AST_NODE_TYPES as AST_NODE_TYPES2 } from "@typescript-eslint/utils";
|
|
10
|
+
|
|
11
|
+
// src/createRule.ts
|
|
12
|
+
import { makeCreateRule } from "@noctcore/eslint-utils";
|
|
13
|
+
var createRule = makeCreateRule("observability");
|
|
14
|
+
|
|
15
|
+
// src/utils.ts
|
|
16
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
17
|
+
var DEFAULT_LOGGERS = ["console", "logger", "log"];
|
|
18
|
+
var LOG_METHODS = /* @__PURE__ */ new Set([
|
|
19
|
+
"info",
|
|
20
|
+
"warn",
|
|
21
|
+
"error",
|
|
22
|
+
"debug"
|
|
23
|
+
]);
|
|
24
|
+
function loggerObjectName(object) {
|
|
25
|
+
if (object.type === AST_NODE_TYPES.Identifier) {
|
|
26
|
+
return object.name;
|
|
27
|
+
}
|
|
28
|
+
if (object.type === AST_NODE_TYPES.MemberExpression && !object.computed && object.property.type === AST_NODE_TYPES.Identifier) {
|
|
29
|
+
return object.property.name;
|
|
30
|
+
}
|
|
31
|
+
return void 0;
|
|
32
|
+
}
|
|
33
|
+
function loggerCallMethod(node, loggers) {
|
|
34
|
+
const callee = node.callee;
|
|
35
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const method = callee.property.name;
|
|
39
|
+
if (!LOG_METHODS.has(method)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const name = loggerObjectName(callee.object);
|
|
43
|
+
if (name === void 0 || !loggers.has(name)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return method;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/rules/no-error-detail-loss.ts
|
|
50
|
+
var RULE_NAME = "no-error-detail-loss";
|
|
51
|
+
function containsLoggerCall(block, loggers) {
|
|
52
|
+
let found = false;
|
|
53
|
+
function walk(node) {
|
|
54
|
+
if (found) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (node.type === AST_NODE_TYPES2.CallExpression && loggerCallMethod(node, loggers) !== null) {
|
|
58
|
+
found = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const key of Object.keys(node)) {
|
|
62
|
+
if (key === "parent") {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const value = node[key];
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
for (const child of value) {
|
|
68
|
+
if (child && typeof child.type === "string") {
|
|
69
|
+
walk(child);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (value && typeof value.type === "string") {
|
|
73
|
+
walk(value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
walk(block);
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
function isLossyReference(id) {
|
|
81
|
+
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") {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (parent.type === AST_NODE_TYPES2.CallExpression && parent.callee.type === AST_NODE_TYPES2.Identifier && parent.callee.name === "String" && parent.arguments.includes(id)) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (parent.type === AST_NODE_TYPES2.TemplateLiteral) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
var noErrorDetailLossRule = createRule({
|
|
94
|
+
name: RULE_NAME,
|
|
95
|
+
meta: {
|
|
96
|
+
type: "suggestion",
|
|
97
|
+
docs: {
|
|
98
|
+
description: "A catch block that reports a failure must not reduce the error to only `e.message` / `String(e)` / `${e}` \u2014 log the error itself so its stack and cause survive."
|
|
99
|
+
},
|
|
100
|
+
schema: [],
|
|
101
|
+
messages: {
|
|
102
|
+
detailLoss: "This catch logs only the error message \u2014 its stack and `cause` are lost. Log the error object itself (e.g. `log.error('...', { err: {{name}} })`) so the failure is diagnosable."
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
defaultOptions: [],
|
|
106
|
+
create(context) {
|
|
107
|
+
const loggers = new Set(DEFAULT_LOGGERS);
|
|
108
|
+
return {
|
|
109
|
+
CatchClause(node) {
|
|
110
|
+
if (node.param === null || node.param.type !== AST_NODE_TYPES2.Identifier) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!containsLoggerCall(node.body, loggers)) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const [variable] = context.sourceCode.getDeclaredVariables(node);
|
|
117
|
+
if (variable === void 0) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const reads = variable.references.filter((ref) => ref.isRead());
|
|
121
|
+
if (reads.length === 0) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const allLossy = reads.every(
|
|
125
|
+
(ref) => isLossyReference(ref.identifier)
|
|
126
|
+
);
|
|
127
|
+
if (!allLossy) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
context.report({
|
|
131
|
+
node: node.param,
|
|
132
|
+
messageId: "detailLoss",
|
|
133
|
+
data: { name: node.param.name }
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// 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";
|
|
143
|
+
var DEFAULT_DENY_NAMES = [
|
|
144
|
+
"password",
|
|
145
|
+
"token",
|
|
146
|
+
"secret",
|
|
147
|
+
"authorization",
|
|
148
|
+
"cookie",
|
|
149
|
+
"apiKey",
|
|
150
|
+
"ssn"
|
|
151
|
+
];
|
|
152
|
+
var optionSchema = {
|
|
153
|
+
type: "object",
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
properties: {
|
|
156
|
+
denyNames: {
|
|
157
|
+
type: "array",
|
|
158
|
+
items: { type: "string" }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
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
|
+
var noSensitiveFieldsInLogsRule = createRule({
|
|
189
|
+
name: RULE_NAME2,
|
|
190
|
+
meta: {
|
|
191
|
+
type: "suggestion",
|
|
192
|
+
docs: {
|
|
193
|
+
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
|
+
},
|
|
195
|
+
schema: [optionSchema],
|
|
196
|
+
messages: {
|
|
197
|
+
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
|
+
}
|
|
199
|
+
},
|
|
200
|
+
defaultOptions: [{ denyNames: DEFAULT_DENY_NAMES }],
|
|
201
|
+
create(context, [options]) {
|
|
202
|
+
const matches = makeMatcher(options.denyNames ?? DEFAULT_DENY_NAMES);
|
|
203
|
+
function walk(node, visit) {
|
|
204
|
+
visit(node);
|
|
205
|
+
for (const key of Object.keys(node)) {
|
|
206
|
+
if (key === "parent") {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const value = node[key];
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
for (const child of value) {
|
|
212
|
+
if (child && typeof child.type === "string") {
|
|
213
|
+
walk(child, visit);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} else if (value && typeof value.type === "string") {
|
|
217
|
+
walk(value, visit);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
CallExpression(node) {
|
|
223
|
+
if (loggerCallMethod(node, new Set(DEFAULT_LOGGERS)) === null) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const reported = /* @__PURE__ */ new Set();
|
|
227
|
+
const report = (name, target) => {
|
|
228
|
+
const at = target.range[0];
|
|
229
|
+
if (reported.has(at)) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
reported.add(at);
|
|
233
|
+
context.report({
|
|
234
|
+
node: target,
|
|
235
|
+
messageId: "sensitiveField",
|
|
236
|
+
data: { name }
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
for (const arg of node.arguments) {
|
|
240
|
+
walk(arg, (n) => {
|
|
241
|
+
if (n.type === AST_NODE_TYPES3.Identifier && matches(n.name)) {
|
|
242
|
+
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)) {
|
|
244
|
+
report(n.key.value, n.key);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// 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 = {
|
|
257
|
+
type: "object",
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
properties: {
|
|
260
|
+
loggers: {
|
|
261
|
+
type: "array",
|
|
262
|
+
items: { type: "string" }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
var structuredLogArgumentsRule = createRule({
|
|
267
|
+
name: RULE_NAME3,
|
|
268
|
+
meta: {
|
|
269
|
+
type: "suggestion",
|
|
270
|
+
docs: {
|
|
271
|
+
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
|
+
},
|
|
273
|
+
schema: [optionSchema2],
|
|
274
|
+
messages: {
|
|
275
|
+
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
|
+
}
|
|
277
|
+
},
|
|
278
|
+
defaultOptions: [{ loggers: DEFAULT_LOGGERS }],
|
|
279
|
+
create(context, [options]) {
|
|
280
|
+
const loggers = new Set(options.loggers ?? DEFAULT_LOGGERS);
|
|
281
|
+
return {
|
|
282
|
+
CallExpression(node) {
|
|
283
|
+
const method = loggerCallMethod(node, loggers);
|
|
284
|
+
if (method === null) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const arg of node.arguments) {
|
|
288
|
+
if (arg.type === AST_NODE_TYPES4.TemplateLiteral && arg.expressions.length > 0) {
|
|
289
|
+
context.report({
|
|
290
|
+
node: arg,
|
|
291
|
+
messageId: "interpolatedMessage",
|
|
292
|
+
data: { method }
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// src/rules/index.ts
|
|
302
|
+
var rules = {
|
|
303
|
+
"structured-log-arguments": structuredLogArgumentsRule,
|
|
304
|
+
"no-sensitive-fields-in-logs": noSensitiveFieldsInLogsRule,
|
|
305
|
+
"no-error-detail-loss": noErrorDetailLossRule
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// src/index.ts
|
|
309
|
+
var NAMESPACE = "noctcore-observability";
|
|
310
|
+
var VERSION = "0.1.0";
|
|
311
|
+
var plugin = {
|
|
312
|
+
meta: { name: "@noctcore/eslint-plugin-observability", version: VERSION },
|
|
313
|
+
rules,
|
|
314
|
+
configs: {}
|
|
315
|
+
};
|
|
316
|
+
plugin.configs.recommended = {
|
|
317
|
+
plugins: { [NAMESPACE]: plugin },
|
|
318
|
+
rules: recommended
|
|
319
|
+
};
|
|
320
|
+
var configs = plugin.configs;
|
|
321
|
+
var index_default = plugin;
|
|
322
|
+
export {
|
|
323
|
+
configs,
|
|
324
|
+
index_default as default,
|
|
325
|
+
rules
|
|
326
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# `noctcore-observability/no-error-detail-loss`
|
|
2
|
+
|
|
3
|
+
> When a catch block reports a failure, log the error itself — not just its message.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
A caught error carries a **stack trace** and often a **`cause`** — the parts you actually need to
|
|
8
|
+
debug a production failure. A catch block that logs only `e.message`, `String(e)`, or `` `${e}` ``
|
|
9
|
+
throws that away: the log records _that_ something failed but not _where_ or _why_.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// ✗ stack and cause are gone
|
|
13
|
+
try { await run(); } catch (e) {
|
|
14
|
+
logger.error(`run failed: ${e.message}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ✓ the whole error survives
|
|
18
|
+
try { await run(); } catch (e) {
|
|
19
|
+
logger.error('run failed', { err: e });
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What it flags
|
|
24
|
+
|
|
25
|
+
A `catch (e)` block where **all** of the following hold:
|
|
26
|
+
|
|
27
|
+
- the catch binding is a plain identifier (`catch (e)`);
|
|
28
|
+
- the block contains a logger call (so a failure is actually being reported);
|
|
29
|
+
- the binding **is** referenced (an unused binding is a different concern); and
|
|
30
|
+
- **every** reference to the binding is a lossy form — `e.message`, `String(e)`, or bare `` `${e}` ``.
|
|
31
|
+
|
|
32
|
+
If the error is passed whole anywhere (`logger.error('x', e)`, `{ err: e }`), or `.stack` / `.cause` /
|
|
33
|
+
any other property is read, or it is re-thrown, its diagnostics survive and the rule stays silent:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// ✓ .stack is read — not a lossy form
|
|
37
|
+
catch (e) { logger.error(`failed: ${e.stack}`); }
|
|
38
|
+
|
|
39
|
+
// ✓ mixed use — the full capture wins
|
|
40
|
+
catch (e) { logger.error(`${e.message}`, { err: e }); }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Distinct from a fully-**unused** catch binding (`catch (e) { cleanup(); }`), which this rule
|
|
44
|
+
deliberately does not touch.
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
This rule has no options.
|
|
49
|
+
|
|
50
|
+
## When not to use it
|
|
51
|
+
|
|
52
|
+
If you deliberately log only messages (e.g. to a user-facing channel that must not include stack
|
|
53
|
+
traces) **and** capture the full error elsewhere, this rule will be noisy — scope it off for those
|
|
54
|
+
files.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# `noctcore-observability/no-sensitive-fields-in-logs`
|
|
2
|
+
|
|
3
|
+
> A name-heuristic guard against writing credentials and secrets into log sinks. Ships at `warn`.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Logs are long-lived, widely readable, and shipped to third-party aggregators. A `password`, `token`,
|
|
8
|
+
`secret`, or `authorization` header written into a log line is a credential leak that outlives the
|
|
9
|
+
request by months. This rule catches the most common shape — a variable, property, or object key
|
|
10
|
+
whose **name** matches a sensitive-field denylist appearing inside a logger call.
|
|
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".
|
|
14
|
+
|
|
15
|
+
## What it flags
|
|
16
|
+
|
|
17
|
+
Inside a logger call (`<logger>.<method>(...)`), any identifier, member-access property, or object key
|
|
18
|
+
whose name matches the denylist:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// ✗ all three flag
|
|
22
|
+
logger.info('login', { password });
|
|
23
|
+
logger.error('auth failed', { userPassword: pw });
|
|
24
|
+
logger.info('session', user.token);
|
|
25
|
+
|
|
26
|
+
// ✓ redact first
|
|
27
|
+
logger.info('login', { password: redact(password) });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Matching is **name-segment aware**. A single-word denyName (`token`) matches a camelCase or
|
|
31
|
+
snake_case segment (`accessToken`, `access_token`) but **not** a longer word that merely contains it
|
|
32
|
+
(`tokenize`, `tokenizer`). A multi-word denyName (`apiKey`) matches the compacted name
|
|
33
|
+
(`myApiKey` → contains `apikey`).
|
|
34
|
+
|
|
35
|
+
String **literals** are never inspected — only names — so a message that mentions a sensitive word is
|
|
36
|
+
fine:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// ✓ a literal, not a value
|
|
40
|
+
logger.info('password reset email sent');
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Options
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
type Options = {
|
|
47
|
+
/**
|
|
48
|
+
* Field names to treat as sensitive (case-insensitive, segment-aware).
|
|
49
|
+
* Default: ['password', 'token', 'secret', 'authorization', 'cookie', 'apiKey', 'ssn'].
|
|
50
|
+
*/
|
|
51
|
+
denyNames?: string[];
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## When not to use it
|
|
56
|
+
|
|
57
|
+
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.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# `noctcore-observability/structured-log-arguments`
|
|
2
|
+
|
|
3
|
+
> Pass dynamic values in a structured context object, not interpolated into the log message string.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Dynamic values baked into a log **message** string are unqueryable. When you write
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
logger.info(`processing task ${taskId} for ${userId}`);
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
a log aggregator stores one opaque line of free text — it cannot index, filter, group, or alert on
|
|
14
|
+
`taskId` or `userId`, because they are fused into the message. The value belongs in a structured
|
|
15
|
+
context object, where each field stays a first-class, queryable attribute:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
logger.info('processing task', { taskId, userId });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What it flags
|
|
22
|
+
|
|
23
|
+
A logger call — `<logger>.<method>(...)` where `<method>` is `info` / `warn` / `error` / `debug` and
|
|
24
|
+
`<logger>` is a configured logger name — that receives a **template literal with expressions** as a
|
|
25
|
+
**direct** positional argument.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// ✗ dynamic values interpolated into the message
|
|
29
|
+
logger.error(`failed: ${err.code}`);
|
|
30
|
+
|
|
31
|
+
// ✓ static message + structured context
|
|
32
|
+
logger.error('request failed', { code: err.code });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Matched purely structurally (no type information). Both `logger.info(...)` and a logger held on a
|
|
36
|
+
namespace or `this` (`this.logger.info(...)`, `app.log.warn(...)`) are recognised.
|
|
37
|
+
|
|
38
|
+
Only **direct** arguments are inspected. A template literal nested inside a context object is building
|
|
39
|
+
a value, not the message, and is never flagged:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// ✓ the template builds a URL field, not the message
|
|
43
|
+
logger.info('fetching', { url: `${base}/tasks` });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A template with **no** expressions carries no dynamic value and is ignored
|
|
47
|
+
(`logger.info(\`ready\`)`).
|
|
48
|
+
|
|
49
|
+
> Note: `console.log(...)` uses the `log` method, which is not one of the tracked log levels
|
|
50
|
+
> (`info` / `warn` / `error` / `debug`), so it is out of scope by design.
|
|
51
|
+
|
|
52
|
+
## Options
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
type Options = {
|
|
56
|
+
/** Logger object names to scan. Default: ['console', 'logger', 'log']. */
|
|
57
|
+
loggers?: string[];
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## When not to use it
|
|
62
|
+
|
|
63
|
+
If your logging layer only accepts a single formatted string (no structured-context argument), this
|
|
64
|
+
rule cannot be satisfied — turn it off for that codebase.
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noctcore/eslint-plugin-observability",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Structured-logging discipline ESLint rules — context objects over interpolated messages, no sensitive fields in logs, no error-detail loss.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"keywords": [
|
|
25
|
+
"eslint",
|
|
26
|
+
"eslintplugin",
|
|
27
|
+
"eslint-plugin",
|
|
28
|
+
"observability",
|
|
29
|
+
"logging",
|
|
30
|
+
"noctcore"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"provenance": true
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/noctcore/eslint-plugins.git",
|
|
39
|
+
"directory": "packages/eslint-plugin-observability"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/eslint-plugin-observability",
|
|
42
|
+
"bugs": "https://github.com/noctcore/eslint-plugins/issues",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@noctcore/eslint-utils": "^0.1.0",
|
|
50
|
+
"@typescript-eslint/utils": "^8.61.1"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"eslint": ">=9.0.0",
|
|
54
|
+
"typescript": ">=5.0.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@noctcore/eslint-test-utils": "workspace:*",
|
|
58
|
+
"@types/node": "^22.0.0",
|
|
59
|
+
"@typescript-eslint/parser": "^8.61.1",
|
|
60
|
+
"@typescript-eslint/rule-tester": "^8.61.1",
|
|
61
|
+
"tsup": "^8.5.1",
|
|
62
|
+
"typescript": "^5.6.0",
|
|
63
|
+
"vitest": "^3"
|
|
64
|
+
}
|
|
65
|
+
}
|