@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 +47 -0
- package/dist/add/add-functions.js +18 -9
- package/dist/error-codes.d.ts +3 -1
- package/dist/error-codes.js +3 -0
- package/dist/utils/check-classified-sinks.d.ts +40 -0
- package/dist/utils/check-classified-sinks.js +170 -0
- package/dist/utils/find-zod-transform.d.ts +21 -0
- package/dist/utils/find-zod-transform.js +93 -0
- package/dist/utils/schema-generator.js +11 -0
- package/package.json +3 -3
- package/src/add/add-functions.ts +19 -9
- package/src/add/{secret-sink-check.test.ts → classified-sink-check.test.ts} +135 -2
- package/src/error-codes.ts +4 -0
- package/src/utils/check-classified-sinks.ts +215 -0
- package/src/utils/find-zod-transform.test.ts +70 -0
- package/src/utils/find-zod-transform.ts +103 -0
- package/src/utils/schema-generator.test.ts +47 -0
- package/src/utils/schema-generator.ts +14 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/utils/check-secret-sinks.ts +0 -148
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 {
|
|
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);
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -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;
|
package/dist/error-codes.js
CHANGED
|
@@ -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.
|
|
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.
|
|
48
|
+
"@pikku/core": "^0.12.99"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.99",
|
|
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
|
|