@pikku/inspector 0.12.65 → 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,76 @@
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
+
38
+ ## 0.12.66
39
+
40
+ ### Patch Changes
41
+
42
+ - 2f94a0a: Stop `pikku all` failing on a diagnostic its own codegen went on to fix.
43
+
44
+ The run inspects several times, because generating the scaffold, the leaf indexes and the type files each changes the source graph the next inspection reads. Every full inspection re-runs every validator, so the newest pass is the complete one — but the logger accumulated diagnostics across all of them, and the build gate failed the run if any pass had ever recorded a critical.
45
+
46
+ A project whose system roles grant a scaffold-declared scope (`virtualUser:*`) therefore could not build from clean: the pass taken before the scaffold existed raised PKU124, the pass taken after was clean, and the run failed anyway. Same for PKU951 on a scaffold-declared secret.
47
+
48
+ Validation diagnostics are now scoped to the pass that recorded them, so a later pass supersedes an earlier one. Diagnostics reported outside a validation pass — by a generator or a command — are untouched, and a fault every pass reports still fails the run.
49
+
50
+ - 7eeff81: Record `rpc!.invoke(...)` as an invocation
51
+
52
+ `rpc` is optional on the wire, so a caller that knows it is there writes
53
+ `rpc!.invoke('someFunction', …)`. The inspector matched the receiver as a bare
54
+ identifier, but a non-null assertion is a node of its own wrapping that
55
+ identifier, so those call sites were never recorded.
56
+
57
+ That is silent rather than loud. An invocation is what puts a function that is
58
+ not `expose: true` into the runtime registry, so the target was left
59
+ unregistered and the dispatch failed with `Function not found` — at run time,
60
+ on a call site that type-checked.
61
+
62
+ The generated virtual-user scaffold dispatches exactly this way, which meant
63
+ `executeVirtualUserRun` was never registered: every virtual-user run sat at
64
+ `running` forever with nothing logged, because the scaffold's own
65
+ `.catch(() => {})` swallowed the rejection.
66
+
67
+ The receiver is now unwrapped through non-null assertions and parentheses
68
+ before it is matched.
69
+
70
+ - Updated dependencies [88629af]
71
+ - Updated dependencies [f1ccfe3]
72
+ - @pikku/core@0.12.96
73
+
1
74
  ## 0.12.65
2
75
 
3
76
  ### 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);
@@ -13,6 +13,25 @@ function outerParent(node) {
13
13
  function findCastArg(args) {
14
14
  return args.find(hasTypeCast);
15
15
  }
16
+ /**
17
+ * The `rpc` in `rpc.invoke(...)`, seen through whatever the author wrote to
18
+ * satisfy the type checker.
19
+ *
20
+ * `rpc` is optional on the wire, so a caller that knows it is there reaches for
21
+ * `rpc!.invoke(...)` — and a non-null assertion is a node of its own, not the
22
+ * identifier underneath it. Matching only the bare identifier meant those calls
23
+ * were never recorded as invocations, which is silent rather than loud: the
24
+ * target is not `expose: true`, so nothing else puts it in the registry, and
25
+ * the dispatch fails with "Function not found" at run time on a call site that
26
+ * type-checked. Parentheses are unwrapped for the same reason.
27
+ */
28
+ function invocationReceiver(node) {
29
+ let inner = node;
30
+ while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
31
+ inner = inner.expression;
32
+ }
33
+ return inner;
34
+ }
16
35
  /**
17
36
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
18
37
  */
@@ -62,10 +81,14 @@ export function addRPCInvocations(node, state, logger) {
62
81
  }
63
82
  }
64
83
  // Check for rpc.invoke('...') calls
84
+ const invokeReceiver = ts.isPropertyAccessExpression(expression)
85
+ ? invocationReceiver(expression.expression)
86
+ : undefined;
65
87
  if (ts.isPropertyAccessExpression(expression) &&
66
88
  expression.name.text === 'invoke' &&
67
- ts.isIdentifier(expression.expression) &&
68
- expression.expression.text === 'rpc') {
89
+ invokeReceiver &&
90
+ ts.isIdentifier(invokeReceiver) &&
91
+ invokeReceiver.text === 'rpc') {
69
92
  // Skip PKU940 for generated files — they may contain intentional casts
70
93
  // (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
71
94
  const sourceFileName = node.getSourceFile().fileName;
@@ -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 = {}));
package/dist/inspector.js CHANGED
@@ -371,6 +371,10 @@ export const inspect = async (logger, routeFiles, options = {}) => {
371
371
  state.filesAndMethodsErrors = errors;
372
372
  logger.debug(`Get files and methods completed in ${(performance.now() - startFilesAndMethods).toFixed(2)}ms`);
373
373
  if (!options.setupOnly) {
374
+ // Everything below re-derives from the state this pass just built, so it
375
+ // supersedes whatever an earlier pass concluded about a source graph the
376
+ // run has since finished generating into.
377
+ logger.beginValidationPass?.();
374
378
  const startAggregate = performance.now();
375
379
  // Apply the inspected auth handler service set before aggregation so it
376
380
  // flows into requiredServices (the generated handler's own func is opaque).
@@ -408,6 +412,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
408
412
  validateScopeReferences(logger, state);
409
413
  validateSystemRoleScopes(logger, state);
410
414
  validatePersonaRoles(logger, state);
415
+ logger.endValidationPass?.();
411
416
  }
412
417
  state.program = program;
413
418
  return state;
package/dist/types.d.ts CHANGED
@@ -300,6 +300,24 @@ export interface InspectorLogger {
300
300
  /** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
301
301
  critical: (code: ErrorCode, message: string) => void;
302
302
  hasCriticalErrors: () => boolean;
303
+ /**
304
+ * Opens a validation pass, discarding whatever a previous one recorded.
305
+ *
306
+ * A single `pikku all` inspects several times, because generating the
307
+ * scaffold, the leaf indexes and the type files each changes the source graph
308
+ * the next inspection reads. The validators are pure functions of that graph
309
+ * and every full inspection re-runs all of them, so the newest pass is always
310
+ * the complete one and the ones before it are strictly superseded.
311
+ *
312
+ * Without this the diagnostics accumulated instead, and a run failed on a
313
+ * complaint about its own half-built input: a project whose roles reference
314
+ * scaffold-declared scopes was reported as failing even though the pass that
315
+ * ran once the scaffold existed was clean. Optional so a host supplying its
316
+ * own logger keeps working — one that omits it simply never discards.
317
+ */
318
+ beginValidationPass?: () => void;
319
+ /** Closes the pass opened by `beginValidationPass`. */
320
+ endValidationPass?: () => void;
303
321
  }
304
322
  export type AddWiring = (logger: InspectorLogger, node: ts.Node, checker: ts.TypeChecker, state: InspectorState, options: InspectorOptions) => void;
305
323
  export interface InspectorFilesAndMethods {
@@ -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.65",
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.95"
48
+ "@pikku/core": "^0.12.97"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.95",
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
 
@@ -110,4 +110,54 @@ export const routes = { stream: { func: ref('console:streamWorkflowRun') } }
110
110
  await rm(dir, { recursive: true, force: true })
111
111
  }
112
112
  })
113
+
114
+ test('a non-null assertion on the receiver is still an invocation', async () => {
115
+ const { state, dir } = await inspectFiles({
116
+ 'caller.ts': `
117
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
118
+ export async function doWork() {
119
+ return rpc!.invoke('executeVirtualUserRun')
120
+ }
121
+ `,
122
+ })
123
+ try {
124
+ assert.ok(state.rpc.invokedFunctions.has('executeVirtualUserRun'))
125
+ const invoked = [...state.rpc.invokedFunctionsByFile.values()][0]!
126
+ assert.deepStrictEqual([...invoked], ['executeVirtualUserRun'])
127
+ } finally {
128
+ await rm(dir, { recursive: true, force: true })
129
+ }
130
+ })
131
+
132
+ test('a parenthesised receiver is still an invocation', async () => {
133
+ const { state, dir } = await inspectFiles({
134
+ 'caller.ts': `
135
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
136
+ export async function doWork() {
137
+ return (rpc!).invoke('listTasks')
138
+ }
139
+ `,
140
+ })
141
+ try {
142
+ assert.ok(state.rpc.invokedFunctions.has('listTasks'))
143
+ } finally {
144
+ await rm(dir, { recursive: true, force: true })
145
+ }
146
+ })
147
+
148
+ test('invoke on some other receiver is left alone', async () => {
149
+ const { state, dir } = await inspectFiles({
150
+ 'caller.ts': `
151
+ declare const client: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
152
+ export async function doWork() {
153
+ return client!.invoke('notAnRpc')
154
+ }
155
+ `,
156
+ })
157
+ try {
158
+ assert.ok(!state.rpc.invokedFunctions.has('notAnRpc'))
159
+ } finally {
160
+ await rm(dir, { recursive: true, force: true })
161
+ }
162
+ })
113
163
  })
@@ -20,6 +20,26 @@ function findCastArg(
20
20
  return args.find(hasTypeCast)
21
21
  }
22
22
 
23
+ /**
24
+ * The `rpc` in `rpc.invoke(...)`, seen through whatever the author wrote to
25
+ * satisfy the type checker.
26
+ *
27
+ * `rpc` is optional on the wire, so a caller that knows it is there reaches for
28
+ * `rpc!.invoke(...)` — and a non-null assertion is a node of its own, not the
29
+ * identifier underneath it. Matching only the bare identifier meant those calls
30
+ * were never recorded as invocations, which is silent rather than loud: the
31
+ * target is not `expose: true`, so nothing else puts it in the registry, and
32
+ * the dispatch fails with "Function not found" at run time on a call site that
33
+ * type-checked. Parentheses are unwrapped for the same reason.
34
+ */
35
+ function invocationReceiver(node: ts.Expression): ts.Expression {
36
+ let inner = node
37
+ while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
38
+ inner = inner.expression
39
+ }
40
+ return inner
41
+ }
42
+
23
43
  /**
24
44
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
25
45
  */
@@ -80,11 +100,15 @@ export function addRPCInvocations(
80
100
  }
81
101
 
82
102
  // Check for rpc.invoke('...') calls
103
+ const invokeReceiver = ts.isPropertyAccessExpression(expression)
104
+ ? invocationReceiver(expression.expression)
105
+ : undefined
83
106
  if (
84
107
  ts.isPropertyAccessExpression(expression) &&
85
108
  expression.name.text === 'invoke' &&
86
- ts.isIdentifier(expression.expression) &&
87
- expression.expression.text === 'rpc'
109
+ invokeReceiver &&
110
+ ts.isIdentifier(invokeReceiver) &&
111
+ invokeReceiver.text === 'rpc'
88
112
  ) {
89
113
  // Skip PKU940 for generated files — they may contain intentional casts
90
114
  // (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).