@pikku/inspector 0.12.66 → 0.12.68

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,50 @@
1
+ ## 0.12.68
2
+
3
+ ### Patch Changes
4
+
5
+ - ee9da9e: A `.transform()` in a schema used as a function's `input` now reports PKU491, naming the schema and the path to the transform, instead of surfacing downstream as PKU724 — the bad `#pikku/*` import error.
6
+ - Updated dependencies [ee9da9e]
7
+ - Updated dependencies [7a15c9c]
8
+ - Updated dependencies [ee9da9e]
9
+ - @pikku/core@0.12.99
10
+
11
+ ## 0.12.67
12
+
13
+ ### Patch Changes
14
+
15
+ - af2b764: **Breaking:** extend the classification sink scan to personal data, as PKU954.
16
+ A project that passes `pikku all --security --fail-on-error` today can fail
17
+ after upgrading, without a line of its own code changing.
18
+
19
+ PKU953 already followed a revealed vault secret into a logger, a queue, an
20
+ email, an audit or a webhook. A PII column reaches those same sinks with no
21
+ `.reveal()` to mark the moment — `Pii<T>` is simply the brand the generated row
22
+ types carry — and nothing flagged it.
23
+
24
+ It is not the same rule, because PII is not a secret: the question is not
25
+ whether the value is written down, but whether writing it there hands it to
26
+ somebody else. A log is shipped to a third-party aggregator and retained long
27
+ past any consent, and a webhook posts to another server, so both are reported.
28
+ An email is addressed to the data subject, an audit is a row in the operator's
29
+ own database, and a queue payload is consumed by the operator's own worker, so
30
+ none of those are. `private` is never reported at all — it says who may read
31
+ the row, not that recording it is a disclosure.
32
+
33
+ The scan is still opt-in behind `pikku all --security` and still reports at
34
+ `error` severity, so the dev server keeps starting and `--fail-on-error` is
35
+ what blocks a build. Where a finding is wrong, an explicit type annotation
36
+ erases the brand (`const email: string = shopper.email`) — but the better fix
37
+ is usually to correct the column's classification in `db/annotations.ts`.
38
+
39
+ Also fixes a false positive in the existing PKU953 scan: property-access
40
+ receivers were typed on the way down, and a row type is branded wherever any of
41
+ its columns are, so `user.userId` reported the row's `email`.
42
+
43
+ - Updated dependencies [8154b1c]
44
+ - Updated dependencies [6d9c09c]
45
+ - Updated dependencies [239332b]
46
+ - @pikku/core@0.12.97
47
+
1
48
  ## 0.12.66
2
49
 
3
50
  ### 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);
@@ -65,6 +65,7 @@ export declare enum ErrorCode {
65
65
  AGENT_TOOL_MISSING_DESCRIPTION = "PKU154",
66
66
  AGENT_SCORER_NOT_FOUND = "PKU155",
67
67
  SCHEMA_AND_WIRING_COLOCATED = "PKU490",
68
+ SCHEMA_TRANSFORM_NOT_SUPPORTED = "PKU491",
68
69
  SERVICES_NOT_DESTRUCTURED = "PKU410",
69
70
  WIRES_NOT_DESTRUCTURED = "PKU411",
70
71
  FUNCTION_DYNAMIC_IMPORT = "PKU498",
@@ -79,7 +80,8 @@ export declare enum ErrorCode {
79
80
  SECRET_SERVICE_ALIASED = "PKU950",
80
81
  SECRET_NOT_DECLARED = "PKU951",
81
82
  SECRET_KEY_NOT_STATIC = "PKU952",
82
- SECRET_REVEALED_INTO_SINK = "PKU953"
83
+ SECRET_REVEALED_INTO_SINK = "PKU953",
84
+ PII_INTO_SINK = "PKU954"
83
85
  }
84
86
  /**
85
87
  * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
@@ -84,6 +84,8 @@ export var ErrorCode;
84
84
  ErrorCode["AGENT_SCORER_NOT_FOUND"] = "PKU155";
85
85
  // File structure errors
86
86
  ErrorCode["SCHEMA_AND_WIRING_COLOCATED"] = "PKU490";
87
+ // Schema authoring errors
88
+ ErrorCode["SCHEMA_TRANSFORM_NOT_SUPPORTED"] = "PKU491";
87
89
  // Optimization diagnostics
88
90
  ErrorCode["SERVICES_NOT_DESTRUCTURED"] = "PKU410";
89
91
  ErrorCode["WIRES_NOT_DESTRUCTURED"] = "PKU411";
@@ -104,4 +106,5 @@ export var ErrorCode;
104
106
  ErrorCode["SECRET_NOT_DECLARED"] = "PKU951";
105
107
  ErrorCode["SECRET_KEY_NOT_STATIC"] = "PKU952";
106
108
  ErrorCode["SECRET_REVEALED_INTO_SINK"] = "PKU953";
109
+ ErrorCode["PII_INTO_SINK"] = "PKU954";
107
110
  })(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
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Locate a `.transform()` anywhere inside a Zod schema.
3
+ *
4
+ * A transform is why a schema cannot be a function's `input`: the value the
5
+ * caller sends and the value the function receives are different, and a wire
6
+ * contract has to describe one shape. Zod says so twice, in two different
7
+ * places, and neither says it usefully on its own — `toJSONSchema` with
8
+ * `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
9
+ * everything, and `zodToTs` throws about TypeScript rather than about the
10
+ * wiring. Finding it up front lets the diagnostic name the schema, the path,
11
+ * and the call.
12
+ *
13
+ * Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
14
+ * the walk looks for that node rather than for the method.
15
+ */
16
+ /**
17
+ * The path to the first `.transform()` found — `''` when the transform is the
18
+ * schema itself, `'.user.email'` when it is nested — or `undefined` when there
19
+ * is none.
20
+ */
21
+ export declare function findZodTransform(schema: unknown): string | undefined;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Locate a `.transform()` anywhere inside a Zod schema.
3
+ *
4
+ * A transform is why a schema cannot be a function's `input`: the value the
5
+ * caller sends and the value the function receives are different, and a wire
6
+ * contract has to describe one shape. Zod says so twice, in two different
7
+ * places, and neither says it usefully on its own — `toJSONSchema` with
8
+ * `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
9
+ * everything, and `zodToTs` throws about TypeScript rather than about the
10
+ * wiring. Finding it up front lets the diagnostic name the schema, the path,
11
+ * and the call.
12
+ *
13
+ * Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
14
+ * the walk looks for that node rather than for the method.
15
+ */
16
+ /** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
17
+ const MAX_NODES = 10_000;
18
+ const isNode = (value) => typeof value === 'object' &&
19
+ value !== null &&
20
+ typeof value._zod?.def?.type === 'string';
21
+ /**
22
+ * The path to the first `.transform()` found — `''` when the transform is the
23
+ * schema itself, `'.user.email'` when it is nested — or `undefined` when there
24
+ * is none.
25
+ */
26
+ export function findZodTransform(schema) {
27
+ const seen = new Set();
28
+ let budget = MAX_NODES;
29
+ const walk = (node, path) => {
30
+ if (budget-- <= 0 || !isNode(node) || seen.has(node))
31
+ return undefined;
32
+ seen.add(node);
33
+ const def = node._zod.def;
34
+ if (def.type === 'transform')
35
+ return path;
36
+ const children = [];
37
+ switch (def.type) {
38
+ case 'object':
39
+ case 'interface':
40
+ for (const [key, child] of Object.entries(def.shape ?? {})) {
41
+ children.push([child, `${path}.${key}`]);
42
+ }
43
+ break;
44
+ case 'array':
45
+ case 'set':
46
+ children.push([def.element, `${path}[]`]);
47
+ break;
48
+ case 'tuple':
49
+ for (const [i, child] of (def.items ?? []).entries()) {
50
+ children.push([child, `${path}[${i}]`]);
51
+ }
52
+ if (def.rest)
53
+ children.push([def.rest, `${path}[]`]);
54
+ break;
55
+ case 'union':
56
+ for (const [i, child] of (def.options ?? []).entries()) {
57
+ children.push([child, `${path}|${i}`]);
58
+ }
59
+ break;
60
+ case 'intersection':
61
+ children.push([def.left, path], [def.right, path]);
62
+ break;
63
+ case 'record':
64
+ case 'map':
65
+ children.push([def.keyType, `${path}[key]`], [def.valueType, `${path}[]`]);
66
+ break;
67
+ case 'pipe':
68
+ // `.transform()` itself: report the pipe's own path, not `out`'s.
69
+ children.push([def.in, path], [def.out, path]);
70
+ break;
71
+ default:
72
+ // optional, nullable, default, prefault, nonoptional, readonly, catch,
73
+ // promise, lazy, success — every wrapper Zod spells `innerType`.
74
+ if (def.innerType)
75
+ children.push([def.innerType, path]);
76
+ if (def.getter) {
77
+ try {
78
+ children.push([def.getter(), path]);
79
+ }
80
+ catch {
81
+ // A lazy schema that cannot be forced tells us nothing; skip it.
82
+ }
83
+ }
84
+ }
85
+ for (const [child, childPath] of children) {
86
+ const found = walk(child, childPath);
87
+ if (found !== undefined)
88
+ return found;
89
+ }
90
+ return undefined;
91
+ };
92
+ return walk(schema, '');
93
+ }
@@ -6,6 +6,7 @@ import { createGenerator, RootlessError } from 'ts-json-schema-generator';
6
6
  import { register } from 'tsx/esm/api';
7
7
  import * as z from 'zod';
8
8
  import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts';
9
+ import { findZodTransform } from './find-zod-transform.js';
9
10
  import { ErrorCode } from '../error-codes.js';
10
11
  import { generateCustomTypes } from './custom-types-generator.js';
11
12
  const PRIMITIVE_TYPES = new Set([
@@ -382,6 +383,16 @@ async function importWithRegister(sourceFile) {
382
383
  }
383
384
  }
384
385
  function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger) {
386
+ const transformPath = findZodTransform(zodSchema);
387
+ if (transformPath !== undefined) {
388
+ const where = transformPath === '' ? 'the schema itself' : transformPath;
389
+ throw new Error(`[${ErrorCode.SCHEMA_TRANSFORM_NOT_SUPPORTED}] '${schemaName}' contains a .transform() at ${where}.\n` +
390
+ 'A transform makes the value sent and the value received two different ' +
391
+ 'shapes, and a wire contract describes one: it cannot be expressed in ' +
392
+ 'JSON Schema or in the generated TypeScript.\n' +
393
+ 'Validate with the plain schema and apply the transform inside the ' +
394
+ 'function, or move it to the output type.');
395
+ }
385
396
  const schema = z.toJSONSchema(zodSchema, {
386
397
  unrepresentable: 'any',
387
398
  override: ({ zodSchema, jsonSchema }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.66",
3
+ "version": "0.12.68",
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.99"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.96",
51
+ "@pikku/core": "^0.12.99",
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