@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 +73 -0
- package/dist/add/add-functions.js +18 -9
- package/dist/add/add-rpc-invocations.js +25 -2
- package/dist/error-codes.d.ts +2 -1
- package/dist/error-codes.js +1 -0
- package/dist/inspector.js +5 -0
- package/dist/types.d.ts +18 -0
- package/dist/utils/check-classified-sinks.d.ts +40 -0
- package/dist/utils/check-classified-sinks.js +170 -0
- package/package.json +3 -3
- package/src/add/add-functions.ts +19 -9
- package/src/add/add-rpc-invocations.test.ts +50 -0
- package/src/add/add-rpc-invocations.ts +26 -2
- package/src/add/{secret-sink-check.test.ts → classified-sink-check.test.ts} +135 -2
- package/src/error-codes.ts +1 -0
- package/src/inspector.ts +5 -0
- package/src/types.ts +18 -0
- package/src/utils/check-classified-sinks.ts +215 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/utils/check-secret-sinks.ts +0 -148
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 {
|
|
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
|
|
888
|
-
// is written out mid-body rather than returned, so walk
|
|
889
|
-
|
|
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:
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
`
|
|
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
|
-
|
|
68
|
-
|
|
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;
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -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;
|
package/dist/error-codes.js
CHANGED
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.
|
|
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.
|
|
48
|
+
"@pikku/core": "^0.12.97"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.97",
|
|
52
52
|
"@types/node": "^24.13.3"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-functions.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
1234
|
-
// is written out mid-body rather than returned, so walk
|
|
1235
|
-
|
|
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:
|
|
1239
|
+
code:
|
|
1240
|
+
violation.classification === 'secret'
|
|
1241
|
+
? ErrorCode.SECRET_REVEALED_INTO_SINK
|
|
1242
|
+
: ErrorCode.PII_INTO_SINK,
|
|
1239
1243
|
message:
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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).
|