@pikku/inspector 0.12.66 → 0.12.67

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/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## 0.12.67
2
+
3
+ ### Patch Changes
4
+
5
+ - af2b764: **Breaking:** extend the classification sink scan to personal data, as PKU954.
6
+ A project that passes `pikku all --security --fail-on-error` today can fail
7
+ after upgrading, without a line of its own code changing.
8
+
9
+ PKU953 already followed a revealed vault secret into a logger, a queue, an
10
+ email, an audit or a webhook. A PII column reaches those same sinks with no
11
+ `.reveal()` to mark the moment — `Pii<T>` is simply the brand the generated row
12
+ types carry — and nothing flagged it.
13
+
14
+ It is not the same rule, because PII is not a secret: the question is not
15
+ whether the value is written down, but whether writing it there hands it to
16
+ somebody else. A log is shipped to a third-party aggregator and retained long
17
+ past any consent, and a webhook posts to another server, so both are reported.
18
+ An email is addressed to the data subject, an audit is a row in the operator's
19
+ own database, and a queue payload is consumed by the operator's own worker, so
20
+ none of those are. `private` is never reported at all — it says who may read
21
+ the row, not that recording it is a disclosure.
22
+
23
+ The scan is still opt-in behind `pikku all --security` and still reports at
24
+ `error` severity, so the dev server keeps starting and `--fail-on-error` is
25
+ what blocks a build. Where a finding is wrong, an explicit type annotation
26
+ erases the brand (`const email: string = shopper.email`) — but the better fix
27
+ is usually to correct the column's classification in `db/annotations.ts`.
28
+
29
+ Also fixes a false positive in the existing PKU953 scan: property-access
30
+ receivers were typed on the way down, and a row type is branded wherever any of
31
+ its columns are, so `user.userId` reported the row's `email`.
32
+
33
+ - Updated dependencies [8154b1c]
34
+ - Updated dependencies [6d9c09c]
35
+ - Updated dependencies [239332b]
36
+ - @pikku/core@0.12.97
37
+
1
38
  ## 0.12.66
2
39
 
3
40
  ### Patch Changes
@@ -29,7 +29,7 @@ import { resolvePermissions } from '../utils/permissions.js';
29
29
  import { extractWireNames } from '../utils/post-process.js';
30
30
  import { ErrorCode } from '../error-codes.js';
31
31
  import { findPiiPaths } from '../utils/check-pii-output.js';
32
- import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js';
32
+ import { findClassifiedSinks } from '../utils/check-classified-sinks.js';
33
33
  import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
34
34
  const isValidVariableName = (name) => {
35
35
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
@@ -884,16 +884,25 @@ export const addFunctions = (logger, node, checker, state, options) => {
884
884
  // it runs ONLY when explicitly requested (`pikku all --security`) — see the
885
885
  // classificationCheck option. Default codegen skips it entirely.
886
886
  if (options.classificationCheck) {
887
- // A revealed vault secret is invisible to the return-type scan below when it
888
- // is written out mid-body rather than returned, so walk the sinks too.
889
- for (const violation of findRevealedSecretSinks(checker, handler)) {
887
+ // A revealed vault secret — or a PII column — is invisible to the return-type
888
+ // scan below when it is written out mid-body rather than returned, so walk
889
+ // the sinks too.
890
+ for (const violation of findClassifiedSinks(checker, handler)) {
890
891
  logger.diagnostic({
891
892
  severity: 'error',
892
- code: ErrorCode.SECRET_REVEALED_INTO_SINK,
893
- message: `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
894
- `at line ${violation.line} (path '${violation.path}').\n ` +
895
- `A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
896
- `Pass the value only to the client that needs it, and reveal it there.`,
893
+ code: violation.classification === 'secret'
894
+ ? ErrorCode.SECRET_REVEALED_INTO_SINK
895
+ : ErrorCode.PII_INTO_SINK,
896
+ message: violation.classification === 'secret'
897
+ ? `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
898
+ `at line ${violation.line} (path '${violation.path}').\n ` +
899
+ `A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
900
+ `Pass the value only to the client that needs it, and reveal it there.`
901
+ : `Function '${name}' passes personal data to '${violation.sink}' ` +
902
+ `at line ${violation.line} (path '${violation.path}').\n ` +
903
+ `A log, the console and a webhook all leave your control — they are shipped to third parties and retained ` +
904
+ `long past the request. Log an id instead, or narrow the value to a non-identifying field. ` +
905
+ `An email to the data subject, an audit in your own database and a queue on your own infrastructure are fine.`,
897
906
  });
898
907
  }
899
908
  const sig = checker.getSignatureFromDeclaration(handler);
@@ -79,7 +79,8 @@ export declare enum ErrorCode {
79
79
  SECRET_SERVICE_ALIASED = "PKU950",
80
80
  SECRET_NOT_DECLARED = "PKU951",
81
81
  SECRET_KEY_NOT_STATIC = "PKU952",
82
- SECRET_REVEALED_INTO_SINK = "PKU953"
82
+ SECRET_REVEALED_INTO_SINK = "PKU953",
83
+ PII_INTO_SINK = "PKU954"
83
84
  }
84
85
  /**
85
86
  * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
@@ -104,4 +104,5 @@ export var ErrorCode;
104
104
  ErrorCode["SECRET_NOT_DECLARED"] = "PKU951";
105
105
  ErrorCode["SECRET_KEY_NOT_STATIC"] = "PKU952";
106
106
  ErrorCode["SECRET_REVEALED_INTO_SINK"] = "PKU953";
107
+ ErrorCode["PII_INTO_SINK"] = "PKU954";
107
108
  })(ErrorCode || (ErrorCode = {}));
@@ -0,0 +1,40 @@
1
+ import * as ts from 'typescript';
2
+ export type SinkClassification = 'secret' | 'pii';
3
+ export type SinkViolation = {
4
+ /** How the value reaches the outside world, e.g. `logger.info`. */
5
+ sink: string;
6
+ /** Dotted path to the secret inside the argument, or `<argument>` when bare. */
7
+ path: string;
8
+ /** Position of the offending argument, for the diagnostic message. */
9
+ line: number;
10
+ /** Which brand was found, so the caller can pick the diagnostic. */
11
+ classification: SinkClassification;
12
+ };
13
+ /**
14
+ * Finds revealed vault secrets — and, for the sinks that reject them, PII
15
+ * columns — that flow into a sink.
16
+ *
17
+ * `SecretValue` is nominally typed, so the type system alone stops it reaching
18
+ * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
19
+ * `Secret<T>` — the erasable classification brand. That brand is what this scan
20
+ * follows, which means it catches the revealed value both inline
21
+ * (`logger.info(s.reveal())`) and through a local binding, because the local's
22
+ * inferred type carries the brand too.
23
+ *
24
+ * PII arrives by the same route without a `.reveal()`: `Pii<T>` is the brand the
25
+ * generated row types carry, so a column read straight out of the database keeps
26
+ * it through a local binding and into the call.
27
+ *
28
+ * The brand is optional by design, so an explicit annotation (`const t: string =
29
+ * s.reveal()`, or `const email: string = user.email`) erases it and this scan
30
+ * goes quiet. That is the documented limit
31
+ * of a structural marker: it catches the mistake, not someone determined to
32
+ * silence it.
33
+ *
34
+ * Sinks are matched on the receiver's resolved core interface, which means the
35
+ * scan needs the services parameter to be typed. Real projects get that from
36
+ * the generated `#pikku` factory; a hand-rolled file that calls the raw core
37
+ * factory without annotating its services leaves the parameter `any`, and an
38
+ * `any` receiver matches nothing.
39
+ */
40
+ export declare function findClassifiedSinks(checker: ts.TypeChecker, handler: ts.Node): SinkViolation[];
@@ -0,0 +1,170 @@
1
+ import * as ts from 'typescript';
2
+ import { findPiiPaths } from './check-pii-output.js';
3
+ import { doesTypeExtendsCore } from './does-type-extend-core-type.js';
4
+ /**
5
+ * A method that writes its arguments somewhere a classified value must never
6
+ * land, keyed by the core interface that owns it. `'*'` means every method on
7
+ * the type is a sink, which is true of a logger.
8
+ *
9
+ * `rejects` differs per sink because the two brands leak differently. A vault
10
+ * secret is wrong everywhere: no sink has a legitimate reason to receive one.
11
+ * PII is wrong only where it leaves the operator's control, or lands somewhere
12
+ * never designed to hold it:
13
+ *
14
+ * - a log is read far more widely than the record it came from, is usually
15
+ * shipped to a third-party aggregator, and is retained long past the
16
+ * lifetime of any consent — so PII does not belong in one;
17
+ * - a webhook posts to somebody else's server, which is the same disclosure
18
+ * with a different transport;
19
+ * - an email is addressed to the data subject, and their own name in it is
20
+ * the point;
21
+ * - an audit is a record in the operator's own database, and naming who did
22
+ * what is usually the legal requirement rather than a breach of it;
23
+ * - a queue payload stays on the operator's own infrastructure and is
24
+ * consumed by their own worker.
25
+ *
26
+ * `private` is deliberately absent everywhere: it marks a value that is not
27
+ * public, which is a statement about who may read the row — not a claim that
28
+ * writing it down is a disclosure.
29
+ */
30
+ const SINKS = [
31
+ { coreType: 'Logger', methods: '*', rejects: ['secret', 'pii'] },
32
+ { coreType: 'WebhookService', methods: ['send'], rejects: ['secret', 'pii'] },
33
+ { coreType: 'QueueService', methods: ['add'], rejects: ['secret'] },
34
+ { coreType: 'EmailService', methods: ['send'], rejects: ['secret'] },
35
+ {
36
+ coreType: 'AuditService',
37
+ methods: ['audit', 'write'],
38
+ rejects: ['secret'],
39
+ },
40
+ { coreType: 'AuditLog', methods: ['write'], rejects: ['secret'] },
41
+ ];
42
+ /** `console` has no Pikku type, and is read as widely as any logger. */
43
+ const CONSOLE_REJECTS = ['secret', 'pii'];
44
+ /**
45
+ * Finds revealed vault secrets — and, for the sinks that reject them, PII
46
+ * columns — that flow into a sink.
47
+ *
48
+ * `SecretValue` is nominally typed, so the type system alone stops it reaching
49
+ * any of these. `.reveal()` is the deliberate escape hatch, and it hands back
50
+ * `Secret<T>` — the erasable classification brand. That brand is what this scan
51
+ * follows, which means it catches the revealed value both inline
52
+ * (`logger.info(s.reveal())`) and through a local binding, because the local's
53
+ * inferred type carries the brand too.
54
+ *
55
+ * PII arrives by the same route without a `.reveal()`: `Pii<T>` is the brand the
56
+ * generated row types carry, so a column read straight out of the database keeps
57
+ * it through a local binding and into the call.
58
+ *
59
+ * The brand is optional by design, so an explicit annotation (`const t: string =
60
+ * s.reveal()`, or `const email: string = user.email`) erases it and this scan
61
+ * goes quiet. That is the documented limit
62
+ * of a structural marker: it catches the mistake, not someone determined to
63
+ * silence it.
64
+ *
65
+ * Sinks are matched on the receiver's resolved core interface, which means the
66
+ * scan needs the services parameter to be typed. Real projects get that from
67
+ * the generated `#pikku` factory; a hand-rolled file that calls the raw core
68
+ * factory without annotating its services leaves the parameter `any`, and an
69
+ * `any` receiver matches nothing.
70
+ */
71
+ export function findClassifiedSinks(checker, handler) {
72
+ const violations = [];
73
+ const visit = (node) => {
74
+ if (ts.isCallExpression(node) &&
75
+ ts.isPropertyAccessExpression(node.expression)) {
76
+ const sink = describeSink(checker, node.expression);
77
+ if (sink) {
78
+ for (const arg of node.arguments) {
79
+ const found = findClassifiedExpression(checker, arg, sink.rejects);
80
+ if (found) {
81
+ violations.push({
82
+ sink: sink.name,
83
+ path: found.node.getText(),
84
+ line: found.node
85
+ .getSourceFile()
86
+ .getLineAndCharacterOfPosition(found.node.getStart()).line +
87
+ 1,
88
+ classification: found.classification,
89
+ });
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ts.forEachChild(node, visit);
95
+ };
96
+ visit(handler);
97
+ return violations;
98
+ }
99
+ /**
100
+ * Finds the first expression inside a sink argument whose own type carries one
101
+ * of the brands this sink rejects, and returns it so the diagnostic can quote
102
+ * it.
103
+ *
104
+ * This walks the argument rather than typing it whole, because an object
105
+ * literal in an argument position is contextually typed by the parameter — for
106
+ * a guarded sink that parameter is `Safe<M>`, and resolving the literal against
107
+ * it drops the optional brand before it can be read. Each inner expression
108
+ * (`token.reveal()`, or a reference to a local holding it) carries its own type
109
+ * regardless of the surrounding context, so reading those is what actually
110
+ * sees the secret.
111
+ */
112
+ function findClassifiedExpression(checker, node, rejects,
113
+ /**
114
+ * The node is only being read to reach a property of it, so its own type says
115
+ * nothing about what the call receives. Without this, `user.userId` reports
116
+ * the row's `email` column: the walk types the `user` receiver on its way
117
+ * down, and a row type is branded wherever any of its columns are.
118
+ */
119
+ isReceiver = false) {
120
+ // Descend first, so an object literal wrapping the secret reports the
121
+ // expression that actually holds it (`token.reveal()`) rather than the whole
122
+ // literal. Nothing inside a `SecretValue` receiver is itself branded, so a
123
+ // bare `token.reveal()` still falls through to the self-check below.
124
+ for (const child of node.getChildren()) {
125
+ // A property name is not a value, and typing it re-resolves the property.
126
+ if (ts.isPropertyAssignment(node) && child === node.name)
127
+ continue;
128
+ const childIsReceiver = (ts.isPropertyAccessExpression(node) ||
129
+ ts.isElementAccessExpression(node)) &&
130
+ child === node.expression;
131
+ const found = findClassifiedExpression(checker, child, rejects, childIsReceiver);
132
+ if (found)
133
+ return found;
134
+ }
135
+ if (!isReceiver && ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
136
+ const type = checker.getTypeAtLocation(node);
137
+ /* `secret` outranks `pii` when a value somehow carries both, so the caller
138
+ reports the more serious of the two rather than whichever came first. */
139
+ for (const classification of ['secret', 'pii']) {
140
+ if (!rejects.includes(classification))
141
+ continue;
142
+ const hit = findPiiPaths(checker, type).some((f) => f.classification === classification);
143
+ if (hit)
144
+ return { node, classification };
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+ /**
150
+ * Names the sink a call writes to, or undefined when the call is not one.
151
+ * `console` is matched by identifier because it is a global with no Pikku type;
152
+ * everything else is matched on its resolved core interface so a renamed
153
+ * service or a decorator still counts.
154
+ */
155
+ function describeSink(checker, callee) {
156
+ const method = callee.name.text;
157
+ const receiver = callee.expression;
158
+ if (ts.isIdentifier(receiver) && receiver.text === 'console') {
159
+ return { name: `console.${method}`, rejects: CONSOLE_REJECTS };
160
+ }
161
+ const receiverType = checker.getTypeAtLocation(receiver);
162
+ for (const { coreType, methods, rejects } of SINKS) {
163
+ if (methods !== '*' && !methods.includes(method))
164
+ continue;
165
+ if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
166
+ return { name: `${receiver.getText()}.${method}`, rejects };
167
+ }
168
+ }
169
+ return undefined;
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.66",
3
+ "version": "0.12.67",
4
4
  "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "BUSL-1.1",
@@ -45,10 +45,10 @@
45
45
  "zod-to-ts": "^2.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@pikku/core": "^0.12.96"
48
+ "@pikku/core": "^0.12.97"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.96",
51
+ "@pikku/core": "^0.12.97",
52
52
  "@types/node": "^24.13.3"
53
53
  },
54
54
  "engines": {
@@ -43,7 +43,7 @@ import { resolvePermissions } from '../utils/permissions.js'
43
43
  import { extractWireNames } from '../utils/post-process.js'
44
44
  import { ErrorCode } from '../error-codes.js'
45
45
  import { findPiiPaths } from '../utils/check-pii-output.js'
46
- import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js'
46
+ import { findClassifiedSinks } from '../utils/check-classified-sinks.js'
47
47
  import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js'
48
48
  import type { NodeType } from '@pikku/core/node'
49
49
 
@@ -1230,17 +1230,27 @@ export const addFunctions: AddWiring = (
1230
1230
  // it runs ONLY when explicitly requested (`pikku all --security`) — see the
1231
1231
  // classificationCheck option. Default codegen skips it entirely.
1232
1232
  if (options.classificationCheck) {
1233
- // A revealed vault secret is invisible to the return-type scan below when it
1234
- // is written out mid-body rather than returned, so walk the sinks too.
1235
- for (const violation of findRevealedSecretSinks(checker, handler)) {
1233
+ // A revealed vault secret — or a PII column — is invisible to the return-type
1234
+ // scan below when it is written out mid-body rather than returned, so walk
1235
+ // the sinks too.
1236
+ for (const violation of findClassifiedSinks(checker, handler)) {
1236
1237
  logger.diagnostic({
1237
1238
  severity: 'error',
1238
- code: ErrorCode.SECRET_REVEALED_INTO_SINK,
1239
+ code:
1240
+ violation.classification === 'secret'
1241
+ ? ErrorCode.SECRET_REVEALED_INTO_SINK
1242
+ : ErrorCode.PII_INTO_SINK,
1239
1243
  message:
1240
- `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
1241
- `at line ${violation.line} (path '${violation.path}').\n ` +
1242
- `A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
1243
- `Pass the value only to the client that needs it, and reveal it there.`,
1244
+ violation.classification === 'secret'
1245
+ ? `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
1246
+ `at line ${violation.line} (path '${violation.path}').\n ` +
1247
+ `A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
1248
+ `Pass the value only to the client that needs it, and reveal it there.`
1249
+ : `Function '${name}' passes personal data to '${violation.sink}' ` +
1250
+ `at line ${violation.line} (path '${violation.path}').\n ` +
1251
+ `A log, the console and a webhook all leave your control — they are shipped to third parties and retained ` +
1252
+ `long past the request. Log an id instead, or narrow the value to a non-identifying field. ` +
1253
+ `An email to the data subject, an audit in your own database and a queue on your own infrastructure are fine.`,
1244
1254
  })
1245
1255
  }
1246
1256
 
@@ -23,11 +23,22 @@ function makeLogger() {
23
23
 
24
24
  /**
25
25
  * Stands in for @pikku/core, which is not importable from a tmp dir. The shapes
26
- * are what matters: `Secret<T>` is the optional brand `.reveal()` returns, and
27
- * the service interfaces are matched by name, so these must keep the real names.
26
+ * are what matters: `Secret<T>` is the optional brand `.reveal()` returns,
27
+ * `Pii<T>` and `Private<T>` are the brands a generated row type carries, and the
28
+ * service interfaces are matched by name, so these must keep the real names.
28
29
  */
29
30
  const PRELUDE = `
30
31
  type Secret<T> = T & { readonly __classification__?: 'secret' }
32
+ type Pii<T> = T & { readonly __classification__?: 'pii' }
33
+ type Private<T> = T & { readonly __classification__?: 'private' }
34
+
35
+ /** What \`db.selectFrom('user')\` hands back once the schema is generated. */
36
+ interface UserRow {
37
+ userId: string
38
+ email: Pii<string>
39
+ internalNote: Private<string>
40
+ }
41
+ declare function getUser(): Promise<UserRow>
31
42
 
32
43
  declare class SecretValue<T = string> {
33
44
  private brand: T
@@ -88,6 +99,9 @@ async function runInspect(source: string) {
88
99
  const sinkErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
89
100
  diagnostics.filter((d) => d.code === ErrorCode.SECRET_REVEALED_INTO_SINK)
90
101
 
102
+ const piiErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
103
+ diagnostics.filter((d) => d.code === ErrorCode.PII_INTO_SINK)
104
+
91
105
  describe('PKU953 — a revealed secret reaching a sink', () => {
92
106
  test('flags a secret logged inline', async () => {
93
107
  const diagnostics = await runInspect(`
@@ -212,3 +226,122 @@ describe('PKU953 — a revealed secret reaching a sink', () => {
212
226
  assert.equal(sinkErrors(diagnostics).length, 0)
213
227
  })
214
228
  })
229
+
230
+ /**
231
+ * PII is not a secret, so the policy is not "never write it down" — it is "not
232
+ * where it leaves your control". A log ships to an aggregator and a webhook
233
+ * posts to somebody else's server; an email is addressed to the data subject,
234
+ * and a queue and an audit stay on the operator's own infrastructure.
235
+ */
236
+ describe('PKU954 — personal data reaching a sink', () => {
237
+ test('flags PII logged inline and through a local binding', async () => {
238
+ const diagnostics = await runInspect(`
239
+ export const leak = pikkuFunc<void, void>({
240
+ func: async ({ logger }) => {
241
+ const user = await getUser()
242
+ logger.info(user.email)
243
+ const email = user.email
244
+ logger.error('send failed', { email })
245
+ },
246
+ })
247
+ `)
248
+ const errors = piiErrors(diagnostics)
249
+ assert.equal(errors.length, 2)
250
+ assert.match(errors[0]!.message, /logger\.info/)
251
+ assert.match(errors[1]!.message, /logger\.error/)
252
+ assert.equal(sinkErrors(diagnostics).length, 0)
253
+ })
254
+
255
+ test('flags a whole row logged, since the PII rides along inside it', async () => {
256
+ const diagnostics = await runInspect(`
257
+ export const leak = pikkuFunc<void, void>({
258
+ func: async ({ logger }) => {
259
+ const user = await getUser()
260
+ logger.info('loaded', user)
261
+ },
262
+ })
263
+ `)
264
+ assert.equal(piiErrors(diagnostics).length, 1)
265
+ })
266
+
267
+ test('flags PII on console and in a webhook', async () => {
268
+ const diagnostics = await runInspect(`
269
+ export const leak = pikkuFunc<void, void>({
270
+ func: async ({ webhooks }) => {
271
+ const user = await getUser()
272
+ console.log(user.email)
273
+ await webhooks.send({ url: 'https://x', data: { email: user.email } })
274
+ },
275
+ })
276
+ `)
277
+ const errors = piiErrors(diagnostics)
278
+ assert.equal(errors.length, 2)
279
+ assert.match(errors[0]!.message, /console\.log/)
280
+ assert.match(errors[1]!.message, /webhooks\.send/)
281
+ })
282
+
283
+ test('stays quiet for an email to the data subject, an audit and a queue', async () => {
284
+ const diagnostics = await runInspect(`
285
+ export const fine = pikkuFunc<void, void>({
286
+ func: async ({ email, audit, auditLog, queueService }) => {
287
+ const user = await getUser()
288
+ await email.send({ to: user.email, text: 'Hi ' + user.email })
289
+ await audit?.audit({ type: 'viewed', input: { email: user.email } })
290
+ await auditLog?.write({ type: 'viewed', input: user.email })
291
+ await queueService.add('welcome', { email: user.email })
292
+ },
293
+ })
294
+ `)
295
+ assert.equal(piiErrors(diagnostics).length, 0)
296
+ assert.equal(sinkErrors(diagnostics).length, 0)
297
+ })
298
+
299
+ test('stays quiet for private, which is about who may read the row', async () => {
300
+ const diagnostics = await runInspect(`
301
+ export const fine = pikkuFunc<void, void>({
302
+ func: async ({ logger }) => {
303
+ const user = await getUser()
304
+ logger.info('note', { note: user.internalNote, id: user.userId })
305
+ },
306
+ })
307
+ `)
308
+ assert.equal(piiErrors(diagnostics).length, 0)
309
+ })
310
+
311
+ test('stays quiet when an explicit annotation erases the brand', async () => {
312
+ const diagnostics = await runInspect(`
313
+ export const documentedLimit = pikkuFunc<void, void>({
314
+ func: async ({ logger }) => {
315
+ const user = await getUser()
316
+ const email: string = user.email
317
+ logger.info(email)
318
+ },
319
+ })
320
+ `)
321
+ assert.equal(piiErrors(diagnostics).length, 0)
322
+ })
323
+
324
+ test('does not run without the classification flag', async () => {
325
+ const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-pii-sink-off-'))
326
+ const file = join(tmpDir, 'funcs.ts')
327
+ await writeFile(
328
+ file,
329
+ PRELUDE +
330
+ `
331
+ export const leak = pikkuFunc<void, void>({
332
+ func: async ({ logger }) => {
333
+ const user = await getUser()
334
+ logger.info(user.email)
335
+ },
336
+ })
337
+ `
338
+ )
339
+ const { logger, diagnostics } = makeLogger()
340
+ try {
341
+ await inspect(logger, [file], { rootDir: tmpDir })
342
+ } finally {
343
+ await rm(tmpDir, { recursive: true, force: true })
344
+ }
345
+ assert.equal(piiErrors(diagnostics).length, 0)
346
+ })
347
+ })
@@ -123,6 +123,7 @@ export enum ErrorCode {
123
123
  SECRET_NOT_DECLARED = 'PKU951',
124
124
  SECRET_KEY_NOT_STATIC = 'PKU952',
125
125
  SECRET_REVEALED_INTO_SINK = 'PKU953',
126
+ PII_INTO_SINK = 'PKU954',
126
127
  }
127
128
 
128
129
  /**