@noctcore/eslint-plugin-observability 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -83,7 +83,7 @@ The purge job needs the registry at run time and this rule needs it at lint time
|
|
|
83
83
|
in code both can import, and pass it into the ESLint config. Never retype it into the config: two
|
|
84
84
|
hand-kept copies of one list drift.
|
|
85
85
|
|
|
86
|
-
```ts
|
|
86
|
+
```ts prose reason="the registry module the config imports, not a lint example"
|
|
87
87
|
// packages/shared/src/audit/pii-registry.ts
|
|
88
88
|
/** Audit payload keys holding personal data. The user purge anonymizes these. */
|
|
89
89
|
export const AUDIT_PII_FIELDS = ['newEmail', 'email'] as const;
|
|
@@ -111,28 +111,33 @@ export default [
|
|
|
111
111
|
|
|
112
112
|
## Examples
|
|
113
113
|
|
|
114
|
-
```ts
|
|
115
|
-
//
|
|
114
|
+
```ts bad reports=4 options={"auditCallees":["auditService.log","auditService.logOrThrow"]}
|
|
115
|
+
// raw email in the payload, and no declaration that the purger must scrub it
|
|
116
116
|
await this.auditService.log({
|
|
117
117
|
action: 'auth.email_change.requested',
|
|
118
118
|
userId,
|
|
119
119
|
metadata: { newEmail: normalizedEmail },
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
//
|
|
122
|
+
// before/after snapshots of a PII column
|
|
123
123
|
await this.auditService.log({
|
|
124
124
|
action: 'auth.email_changed',
|
|
125
125
|
before: { email: previousEmail },
|
|
126
126
|
after: { email: pending.newEmail },
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
//
|
|
129
|
+
// the rule cannot see what this bag carries
|
|
130
130
|
await this.auditService.log({ action, metadata: { ...grant.auditMetadata } });
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Declaring the keys is the fix, so this example runs with `registeredFields: ['newEmail', 'email']`:
|
|
131
134
|
|
|
132
|
-
|
|
135
|
+
```ts good reconfigured options={"auditCallees":["auditService.log","auditService.logOrThrow"],"registeredFields":["newEmail","email"]}
|
|
133
136
|
await this.auditService.log({ action, userId, metadata: { newEmail: normalizedEmail } });
|
|
137
|
+
```
|
|
134
138
|
|
|
135
|
-
|
|
139
|
+
```ts good options={"auditCallees":["auditService.log","auditService.logOrThrow"]}
|
|
140
|
+
// nothing PII-shaped; `emailSent` is a flag about the data
|
|
136
141
|
await this.auditService.log({ action, metadata: { role, outcome, emailSent } });
|
|
137
142
|
```
|
|
138
143
|
|
|
@@ -8,13 +8,15 @@ A caught error carries a **stack trace** and often a **`cause`** — the parts y
|
|
|
8
8
|
debug a production failure. A catch block that logs only `e.message`, `String(e)`, or `` `${e}` ``
|
|
9
9
|
throws that away: the log records _that_ something failed but not _where_ or _why_.
|
|
10
10
|
|
|
11
|
-
```ts
|
|
12
|
-
//
|
|
11
|
+
```ts bad
|
|
12
|
+
// stack and cause are gone
|
|
13
13
|
try { await run(); } catch (e) {
|
|
14
14
|
logger.error(`run failed: ${e.message}`);
|
|
15
15
|
}
|
|
16
|
+
```
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
```ts good
|
|
19
|
+
// the whole error survives
|
|
18
20
|
try { await run(); } catch (e) {
|
|
19
21
|
logger.error('run failed', { err: e });
|
|
20
22
|
}
|
|
@@ -32,12 +34,12 @@ A `catch (e)` block where **all** of the following hold:
|
|
|
32
34
|
If the error is passed whole anywhere (`logger.error('x', e)`, `{ err: e }`), or `.stack` / `.cause` /
|
|
33
35
|
any other property is read, or it is re-thrown, its diagnostics survive and the rule stays silent:
|
|
34
36
|
|
|
35
|
-
```ts
|
|
36
|
-
//
|
|
37
|
-
catch (e) { logger.error(`failed: ${e.stack}`); }
|
|
37
|
+
```ts good
|
|
38
|
+
// .stack is read: not a lossy form
|
|
39
|
+
try { await run(); } catch (e) { logger.error(`failed: ${e.stack}`); }
|
|
38
40
|
|
|
39
|
-
//
|
|
40
|
-
catch (e) { logger.error(`${e.message}`, { err: e }); }
|
|
41
|
+
// mixed use: the full capture wins
|
|
42
|
+
try { await run(); } catch (e) { logger.error(`${e.message}`, { err: e }); }
|
|
41
43
|
```
|
|
42
44
|
|
|
43
45
|
Distinct from a fully-**unused** catch binding (`catch (e) { cleanup(); }`), which this rule
|
|
@@ -10,7 +10,7 @@ request by months. This rule catches the most common shape — a variable, prope
|
|
|
10
10
|
whose **name** matches a sensitive-field denylist appearing inside a logger call.
|
|
11
11
|
|
|
12
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
|
|
13
|
+
credential in a long-lived log sink, while a false positive costs a rename.
|
|
14
14
|
Tests that log a secret on purpose (to prove a redaction boundary works) should turn the rule off for
|
|
15
15
|
those files in config.
|
|
16
16
|
|
|
@@ -19,16 +19,24 @@ those files in config.
|
|
|
19
19
|
Inside a logger call (`<logger>.<method>(...)`), any identifier, member-access property, or object key
|
|
20
20
|
whose name matches the denylist:
|
|
21
21
|
|
|
22
|
-
```ts
|
|
23
|
-
//
|
|
22
|
+
```ts bad reports=3
|
|
23
|
+
// all three flag
|
|
24
24
|
logger.info('login', { password });
|
|
25
25
|
logger.error('auth failed', { userPassword: pw });
|
|
26
26
|
logger.info('session', user.token);
|
|
27
|
+
```
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
```ts good
|
|
30
|
+
// log who, not the credential
|
|
31
|
+
logger.info('login', { userId });
|
|
32
|
+
logger.error('auth failed', { userId, reason: 'bad-credentials' });
|
|
33
|
+
logger.info('session', { sessionId: user.sessionId });
|
|
30
34
|
```
|
|
31
35
|
|
|
36
|
+
Wrapping the value in a call does not help: `{ password: redact(password) }` is still reported,
|
|
37
|
+
twice, because the key and the argument are both named `password`. The rule has no notion of a
|
|
38
|
+
redaction helper. Omit the field and log an identifier for the subject instead.
|
|
39
|
+
|
|
32
40
|
Matching is **name-segment aware**. A single-word denyName (`token`) matches a camelCase or
|
|
33
41
|
snake_case segment (`accessToken`, `access_token`) but **not** a longer word that merely contains it
|
|
34
42
|
(`tokenize`, `tokenizer`). A multi-word denyName (`apiKey`) matches the compacted name
|
|
@@ -37,14 +45,14 @@ snake_case segment (`accessToken`, `access_token`) but **not** a longer word tha
|
|
|
37
45
|
String **literals** are never inspected — only names — so a message that mentions a sensitive word is
|
|
38
46
|
fine:
|
|
39
47
|
|
|
40
|
-
```ts
|
|
41
|
-
//
|
|
48
|
+
```ts good
|
|
49
|
+
// a literal, not a value
|
|
42
50
|
logger.info('password reset email sent');
|
|
43
51
|
```
|
|
44
52
|
|
|
45
53
|
## Options
|
|
46
54
|
|
|
47
|
-
```ts
|
|
55
|
+
```ts prose reason="the options type, not a lint example"
|
|
48
56
|
type Options = {
|
|
49
57
|
/**
|
|
50
58
|
* Field names to treat as sensitive (case-insensitive, segment-aware).
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Dynamic values baked into a log **message** string are unqueryable. When you write
|
|
8
8
|
|
|
9
|
-
```ts
|
|
9
|
+
```ts bad
|
|
10
10
|
logger.info(`processing task ${taskId} for ${userId}`);
|
|
11
11
|
```
|
|
12
12
|
|
|
@@ -14,7 +14,7 @@ a log aggregator stores one opaque line of free text — it cannot index, filter
|
|
|
14
14
|
`taskId` or `userId`, because they are fused into the message. The value belongs in a structured
|
|
15
15
|
context object, where each field stays a first-class, queryable attribute:
|
|
16
16
|
|
|
17
|
-
```ts
|
|
17
|
+
```ts good
|
|
18
18
|
logger.info('processing task', { taskId, userId });
|
|
19
19
|
```
|
|
20
20
|
|
|
@@ -24,11 +24,13 @@ A logger call — `<logger>.<method>(...)` where `<method>` is `info` / `warn` /
|
|
|
24
24
|
`<logger>` is a configured logger name — that receives a **template literal with expressions** as a
|
|
25
25
|
**direct** positional argument.
|
|
26
26
|
|
|
27
|
-
```ts
|
|
28
|
-
//
|
|
27
|
+
```ts bad
|
|
28
|
+
// dynamic values interpolated into the message
|
|
29
29
|
logger.error(`failed: ${err.code}`);
|
|
30
|
+
```
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
```ts good
|
|
33
|
+
// static message + structured context
|
|
32
34
|
logger.error('request failed', { code: err.code });
|
|
33
35
|
```
|
|
34
36
|
|
|
@@ -38,8 +40,8 @@ namespace or `this` (`this.logger.info(...)`, `app.log.warn(...)`) are recognise
|
|
|
38
40
|
Only **direct** arguments are inspected. A template literal nested inside a context object is building
|
|
39
41
|
a value, not the message, and is never flagged:
|
|
40
42
|
|
|
41
|
-
```ts
|
|
42
|
-
//
|
|
43
|
+
```ts good
|
|
44
|
+
// the template builds a URL field, not the message
|
|
43
45
|
logger.info('fetching', { url: `${base}/tasks` });
|
|
44
46
|
```
|
|
45
47
|
|
|
@@ -51,7 +53,7 @@ A template with **no** expressions carries no dynamic value and is ignored
|
|
|
51
53
|
|
|
52
54
|
## Options
|
|
53
55
|
|
|
54
|
-
```ts
|
|
56
|
+
```ts prose reason="the options type, not a lint example"
|
|
55
57
|
type Options = {
|
|
56
58
|
/** Logger object names to scan. Default: ['console', 'logger', 'log']. */
|
|
57
59
|
loggers?: string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noctcore/eslint-plugin-observability",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|