@pikku/inspector 0.12.12 → 0.12.13
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 +19 -0
- package/dist/add/add-functions.js +20 -0
- package/dist/error-codes.d.ts +2 -1
- package/dist/error-codes.js +2 -0
- package/dist/utils/check-pii-output.d.ts +14 -0
- package/dist/utils/check-pii-output.js +63 -0
- package/package.json +2 -2
- package/src/add/add-functions.ts +24 -0
- package/src/add/pii-check.test.ts +197 -0
- package/src/error-codes.ts +3 -0
- package/src/utils/check-pii-output.ts +76 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## 0.12.13
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 665bdb0: Add end-to-end data classification for SQLite and Postgres projects.
|
|
6
|
+
|
|
7
|
+
**Core (`@pikku/core`):** New `Private<T>` and `Secret<T>` intersection brands, `ClassificationManifest`, `ColumnClassification`, and `AnonymizeStrategy` types exported from `data-classification.ts`.
|
|
8
|
+
|
|
9
|
+
**CLI (`@pikku/cli`):**
|
|
10
|
+
- SQL comment annotations: `-- @public`, `-- @private[:strategy]`, `-- @secret[:strategy]` on `CREATE TABLE` columns and `ALTER TABLE ... ADD COLUMN` statements. Unannotated columns default to `private`.
|
|
11
|
+
- `pikku db migrate` now emits a `classification.gen.ts` manifest alongside `schema.d.ts`.
|
|
12
|
+
- New `pikku db audit` command — prints a per-column classification summary and warns on `private`/`secret` columns with no anonymize strategy.
|
|
13
|
+
- Postgres dialect support in `resolveDb`, `PostgresMigrationExecutor`, and `PostgresIntrospector`.
|
|
14
|
+
|
|
15
|
+
**Inspector (`@pikku/inspector`):** New PKU910 check — `findPiiPaths()` walks inferred function return types looking for `__pii__` brands (including inside `Array<T>`, `Record<K,V>`, and index signatures) and fails the build if a function exposes branded fields in its output.
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [665bdb0]
|
|
18
|
+
- @pikku/core@0.12.25
|
|
19
|
+
|
|
1
20
|
## 0.12.12
|
|
2
21
|
|
|
3
22
|
### Patch Changes
|
|
@@ -10,6 +10,7 @@ import { resolveMiddleware } from '../utils/middleware.js';
|
|
|
10
10
|
import { resolvePermissions } from '../utils/permissions.js';
|
|
11
11
|
import { extractWireNames } from '../utils/post-process.js';
|
|
12
12
|
import { ErrorCode } from '../error-codes.js';
|
|
13
|
+
import { findPiiPaths } from '../utils/check-pii-output.js';
|
|
13
14
|
const isValidVariableName = (name) => {
|
|
14
15
|
const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
15
16
|
return regex.test(name);
|
|
@@ -626,6 +627,25 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
626
627
|
}
|
|
627
628
|
}
|
|
628
629
|
}
|
|
630
|
+
// ── PII brand check ───────────────────────────────────────────────────────
|
|
631
|
+
// Walk the function body's ACTUAL inferred return type looking for Private<T>
|
|
632
|
+
// / Secret<T> brands (__pii__ property). This runs for every function,
|
|
633
|
+
// including those with a Zod output schema, because the TS return type
|
|
634
|
+
// reflects what the body actually returns before any Zod coercion.
|
|
635
|
+
{
|
|
636
|
+
const sig = checker.getSignatureFromDeclaration(handler);
|
|
637
|
+
if (sig) {
|
|
638
|
+
const rawRet = checker.getReturnTypeOfSignature(sig);
|
|
639
|
+
const unwrapped = unwrapPromise(checker, rawRet);
|
|
640
|
+
const piiPaths = findPiiPaths(checker, unwrapped);
|
|
641
|
+
if (piiPaths.length > 0) {
|
|
642
|
+
logger.critical(ErrorCode.PII_IN_OUTPUT, `Function '${name}' exposes PII-classified field(s) in its return type: ` +
|
|
643
|
+
piiPaths.map((p) => `'${p}'`).join(', ') +
|
|
644
|
+
`.\n Either strip these fields before returning or mark the column ` +
|
|
645
|
+
`@public in the migration if it is safe to expose.`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
629
649
|
// --- resolve middleware ---
|
|
630
650
|
let middleware = objectNode
|
|
631
651
|
? resolveMiddleware(state, objectNode, tags, checker)
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -54,5 +54,6 @@ export declare enum ErrorCode {
|
|
|
54
54
|
SCHEMA_AND_WIRING_COLOCATED = "PKU490",
|
|
55
55
|
SERVICES_NOT_DESTRUCTURED = "PKU410",
|
|
56
56
|
WIRES_NOT_DESTRUCTURED = "PKU411",
|
|
57
|
-
WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901"
|
|
57
|
+
WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
|
|
58
|
+
PII_IN_OUTPUT = "PKU910"
|
|
58
59
|
}
|
package/dist/error-codes.js
CHANGED
|
@@ -67,4 +67,6 @@ export var ErrorCode;
|
|
|
67
67
|
ErrorCode["WIRES_NOT_DESTRUCTURED"] = "PKU411";
|
|
68
68
|
// Feature Flag
|
|
69
69
|
ErrorCode["WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED"] = "PKU901";
|
|
70
|
+
// Data classification errors
|
|
71
|
+
ErrorCode["PII_IN_OUTPUT"] = "PKU910";
|
|
70
72
|
})(ErrorCode || (ErrorCode = {}));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
/**
|
|
3
|
+
* Recursively walks a resolved TypeScript type looking for `__pii__` brands —
|
|
4
|
+
* the structural marker emitted by `Private<T>` and `Secret<T>`.
|
|
5
|
+
*
|
|
6
|
+
* `Private<T> = T & { readonly __pii__: 'private' }` shows up in the TS type
|
|
7
|
+
* system as an intersection whose constituents include a type with a `__pii__`
|
|
8
|
+
* property. We detect that by checking whether any constituent of an
|
|
9
|
+
* intersection exposes a property named `__pii__`.
|
|
10
|
+
*
|
|
11
|
+
* Returns the list of dotted field paths where a brand was found
|
|
12
|
+
* (e.g. `['email', 'address.phone']`). An empty array means clean.
|
|
13
|
+
*/
|
|
14
|
+
export declare function findPiiPaths(checker: ts.TypeChecker, type: ts.Type, path?: string, depth?: number, seen?: Set<ts.Type>): string[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
/**
|
|
3
|
+
* Recursively walks a resolved TypeScript type looking for `__pii__` brands —
|
|
4
|
+
* the structural marker emitted by `Private<T>` and `Secret<T>`.
|
|
5
|
+
*
|
|
6
|
+
* `Private<T> = T & { readonly __pii__: 'private' }` shows up in the TS type
|
|
7
|
+
* system as an intersection whose constituents include a type with a `__pii__`
|
|
8
|
+
* property. We detect that by checking whether any constituent of an
|
|
9
|
+
* intersection exposes a property named `__pii__`.
|
|
10
|
+
*
|
|
11
|
+
* Returns the list of dotted field paths where a brand was found
|
|
12
|
+
* (e.g. `['email', 'address.phone']`). An empty array means clean.
|
|
13
|
+
*/
|
|
14
|
+
export function findPiiPaths(checker, type, path = '', depth = 0, seen = new Set()) {
|
|
15
|
+
if (depth > 8 || seen.has(type))
|
|
16
|
+
return [];
|
|
17
|
+
seen.add(type);
|
|
18
|
+
// ── Is this type itself branded? ─────────────────────────────────────────
|
|
19
|
+
// Private<T> = T & { readonly __pii__: 'private' } → isIntersection()
|
|
20
|
+
// where one constituent has a `__pii__` property.
|
|
21
|
+
if (type.isIntersection()) {
|
|
22
|
+
const branded = type.types.some((t) => t.getProperties().some((p) => p.name === '__pii__'));
|
|
23
|
+
if (branded) {
|
|
24
|
+
return [path || '<return value>'];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const violations = [];
|
|
28
|
+
// ── Union: check every branch ─────────────────────────────────────────────
|
|
29
|
+
if (type.isUnion()) {
|
|
30
|
+
for (const branch of type.types) {
|
|
31
|
+
violations.push(...findPiiPaths(checker, branch, path, depth, seen));
|
|
32
|
+
}
|
|
33
|
+
return violations;
|
|
34
|
+
}
|
|
35
|
+
// ── Object: recurse into named properties ─────────────────────────────────
|
|
36
|
+
if (type.flags & ts.TypeFlags.Object) {
|
|
37
|
+
const ref = type;
|
|
38
|
+
for (const arg of ref.typeArguments ?? []) {
|
|
39
|
+
violations.push(...findPiiPaths(checker, arg, path, depth + 1, seen));
|
|
40
|
+
}
|
|
41
|
+
const numberIndex = checker.getIndexTypeOfType(type, ts.IndexKind.Number);
|
|
42
|
+
if (numberIndex) {
|
|
43
|
+
const idxPath = path ? `${path}[]` : '[]';
|
|
44
|
+
violations.push(...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen));
|
|
45
|
+
}
|
|
46
|
+
const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String);
|
|
47
|
+
if (stringIndex) {
|
|
48
|
+
const idxPath = path ? `${path}[*]` : '[*]';
|
|
49
|
+
violations.push(...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen));
|
|
50
|
+
}
|
|
51
|
+
for (const prop of type.getProperties()) {
|
|
52
|
+
if (prop.name.startsWith('__'))
|
|
53
|
+
continue;
|
|
54
|
+
const decl = prop.valueDeclaration ?? prop.declarations?.[0];
|
|
55
|
+
if (!decl)
|
|
56
|
+
continue;
|
|
57
|
+
const propType = checker.getTypeOfSymbolAtLocation(prop, decl);
|
|
58
|
+
const subPath = path ? `${path}.${prop.name}` : prop.name;
|
|
59
|
+
violations.push(...findPiiPaths(checker, propType, subPath, depth + 1, seen));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return violations;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.13",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
|
|
38
|
-
"@pikku/core": "^0.12.
|
|
38
|
+
"@pikku/core": "^0.12.25",
|
|
39
39
|
"path-to-regexp": "^8.3.0",
|
|
40
40
|
"ts-json-schema-generator": "^2.5.0",
|
|
41
41
|
"tsx": "^4.21.0",
|
package/src/add/add-functions.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { resolveMiddleware } from '../utils/middleware.js'
|
|
|
19
19
|
import { resolvePermissions } from '../utils/permissions.js'
|
|
20
20
|
import { extractWireNames } from '../utils/post-process.js'
|
|
21
21
|
import { ErrorCode } from '../error-codes.js'
|
|
22
|
+
import { findPiiPaths } from '../utils/check-pii-output.js'
|
|
22
23
|
import type { NodeType } from '@pikku/core/node'
|
|
23
24
|
|
|
24
25
|
const isValidVariableName = (name: string) => {
|
|
@@ -882,6 +883,29 @@ export const addFunctions: AddWiring = (
|
|
|
882
883
|
}
|
|
883
884
|
}
|
|
884
885
|
|
|
886
|
+
// ── PII brand check ───────────────────────────────────────────────────────
|
|
887
|
+
// Walk the function body's ACTUAL inferred return type looking for Private<T>
|
|
888
|
+
// / Secret<T> brands (__pii__ property). This runs for every function,
|
|
889
|
+
// including those with a Zod output schema, because the TS return type
|
|
890
|
+
// reflects what the body actually returns before any Zod coercion.
|
|
891
|
+
{
|
|
892
|
+
const sig = checker.getSignatureFromDeclaration(handler)
|
|
893
|
+
if (sig) {
|
|
894
|
+
const rawRet = checker.getReturnTypeOfSignature(sig)
|
|
895
|
+
const unwrapped = unwrapPromise(checker, rawRet)
|
|
896
|
+
const piiPaths = findPiiPaths(checker, unwrapped)
|
|
897
|
+
if (piiPaths.length > 0) {
|
|
898
|
+
logger.critical(
|
|
899
|
+
ErrorCode.PII_IN_OUTPUT,
|
|
900
|
+
`Function '${name}' exposes PII-classified field(s) in its return type: ` +
|
|
901
|
+
piiPaths.map((p) => `'${p}'`).join(', ') +
|
|
902
|
+
`.\n Either strip these fields before returning or mark the column ` +
|
|
903
|
+
`@public in the migration if it is safe to expose.`
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
885
909
|
// --- resolve middleware ---
|
|
886
910
|
let middleware = objectNode
|
|
887
911
|
? resolveMiddleware(state, objectNode, tags, checker)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeLogger() {
|
|
13
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
14
|
+
const logger: InspectorLogger = {
|
|
15
|
+
debug: () => {},
|
|
16
|
+
info: () => {},
|
|
17
|
+
warn: () => {},
|
|
18
|
+
error: () => {},
|
|
19
|
+
critical: (code, message) => criticals.push({ code, message }),
|
|
20
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
21
|
+
}
|
|
22
|
+
return { logger, criticals }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Inline Private<T>/Secret<T> definitions that the test source files use.
|
|
27
|
+
* Mirrors what schema.d.ts emits so the TypeScript program sees the correct
|
|
28
|
+
* structural brand type even without @pikku/core being importable from /tmp.
|
|
29
|
+
*/
|
|
30
|
+
const BRAND_TYPES = `
|
|
31
|
+
type Private<T> = T & { readonly __pii__: 'private' }
|
|
32
|
+
type Secret<T> = T & { readonly __pii__: 'secret' }
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
async function runInspect(sourceCode: string) {
|
|
36
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-pii-test-'))
|
|
37
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
38
|
+
await writeFile(file, sourceCode)
|
|
39
|
+
const { logger, criticals } = makeLogger()
|
|
40
|
+
try {
|
|
41
|
+
await inspect(logger, [file], { rootDir: tmpDir })
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
return criticals
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── findPiiPaths unit tests via full inspect() round-trip ────────────────────
|
|
49
|
+
|
|
50
|
+
describe('PII output check — PKU910', () => {
|
|
51
|
+
test('flags a top-level Private<string> field', async () => {
|
|
52
|
+
const criticals = await runInspect(`
|
|
53
|
+
${BRAND_TYPES}
|
|
54
|
+
import { pikkuFunc } from '@pikku/core'
|
|
55
|
+
export const getUser = pikkuFunc({
|
|
56
|
+
func: async () => {
|
|
57
|
+
const email = 'test@example.com' as Private<string>
|
|
58
|
+
return { id: 1, email }
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
`)
|
|
62
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
63
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
64
|
+
assert.match(hit.message, /email/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('flags a top-level Secret<string> field', async () => {
|
|
68
|
+
const criticals = await runInspect(`
|
|
69
|
+
${BRAND_TYPES}
|
|
70
|
+
import { pikkuFunc } from '@pikku/core'
|
|
71
|
+
export const getToken = pikkuFunc({
|
|
72
|
+
func: async () => {
|
|
73
|
+
const token = 'abc' as Secret<string>
|
|
74
|
+
return { token }
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
`)
|
|
78
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
79
|
+
assert.ok(hit)
|
|
80
|
+
assert.match(hit.message, /token/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('flags a nested Private field', async () => {
|
|
84
|
+
const criticals = await runInspect(`
|
|
85
|
+
${BRAND_TYPES}
|
|
86
|
+
import { pikkuFunc } from '@pikku/core'
|
|
87
|
+
export const getProfile = pikkuFunc({
|
|
88
|
+
func: async () => {
|
|
89
|
+
const email = 'x@y.com' as Private<string>
|
|
90
|
+
return { user: { id: 1, email } }
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
`)
|
|
94
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
95
|
+
assert.ok(hit)
|
|
96
|
+
assert.match(hit.message, /user\.email/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('does not flag a plain string return', async () => {
|
|
100
|
+
const criticals = await runInspect(`
|
|
101
|
+
import { pikkuFunc } from '@pikku/core'
|
|
102
|
+
export const getPublicData = pikkuFunc({
|
|
103
|
+
func: async () => ({ id: 1, status: 'active', count: 42 })
|
|
104
|
+
})
|
|
105
|
+
`)
|
|
106
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
107
|
+
assert.equal(hit, undefined, `Expected no PKU910 but got: ${JSON.stringify(hit)}`)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('does not flag a void-returning function', async () => {
|
|
111
|
+
const criticals = await runInspect(`
|
|
112
|
+
import { pikkuFunc } from '@pikku/core'
|
|
113
|
+
export const doWork = pikkuFunc({
|
|
114
|
+
func: async () => { /* no return */ }
|
|
115
|
+
})
|
|
116
|
+
`)
|
|
117
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
118
|
+
assert.equal(hit, undefined)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('flags a function that returns a typed alias with Private field', async () => {
|
|
122
|
+
const criticals = await runInspect(`
|
|
123
|
+
${BRAND_TYPES}
|
|
124
|
+
import { pikkuFunc } from '@pikku/core'
|
|
125
|
+
type UserRow = { id: number; email: Private<string> }
|
|
126
|
+
export const getUser = pikkuFunc({
|
|
127
|
+
func: async (): Promise<UserRow> => {
|
|
128
|
+
return { id: 1, email: 'x' as Private<string> }
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
`)
|
|
132
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
133
|
+
assert.ok(hit)
|
|
134
|
+
assert.match(hit.message, /email/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('flags across multiple functions in the same file', async () => {
|
|
138
|
+
const criticals = await runInspect(`
|
|
139
|
+
${BRAND_TYPES}
|
|
140
|
+
import { pikkuFunc } from '@pikku/core'
|
|
141
|
+
export const getEmail = pikkuFunc({
|
|
142
|
+
func: async () => ({ email: 'x' as Private<string> })
|
|
143
|
+
})
|
|
144
|
+
export const getPhone = pikkuFunc({
|
|
145
|
+
func: async () => ({ phone: '555' as Private<string> })
|
|
146
|
+
})
|
|
147
|
+
export const getSafe = pikkuFunc({
|
|
148
|
+
func: async () => ({ name: 'Alice' })
|
|
149
|
+
})
|
|
150
|
+
`)
|
|
151
|
+
const hits = criticals.filter((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
152
|
+
assert.equal(hits.length, 2, `Expected 2 PKU910 but got ${hits.length}`)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('flags branded values inside arrays', async () => {
|
|
156
|
+
const criticals = await runInspect(`
|
|
157
|
+
${BRAND_TYPES}
|
|
158
|
+
import { pikkuFunc } from '@pikku/core'
|
|
159
|
+
export const getEmails = pikkuFunc({
|
|
160
|
+
func: async () => ({ emails: ['x@y.com' as Private<string>] })
|
|
161
|
+
})
|
|
162
|
+
`)
|
|
163
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
164
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
165
|
+
assert.match(hit.message, /emails/)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('flags branded values inside string-indexed records', async () => {
|
|
169
|
+
const criticals = await runInspect(`
|
|
170
|
+
${BRAND_TYPES}
|
|
171
|
+
import { pikkuFunc } from '@pikku/core'
|
|
172
|
+
export const getMap = pikkuFunc({
|
|
173
|
+
func: async () => ({ byId: { a: 'x@y.com' as Private<string> } as Record<string, Private<string>> })
|
|
174
|
+
})
|
|
175
|
+
`)
|
|
176
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
177
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
178
|
+
assert.match(hit.message, /byId/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('does not flag when branded field is stripped before return', async () => {
|
|
182
|
+
const criticals = await runInspect(`
|
|
183
|
+
${BRAND_TYPES}
|
|
184
|
+
import { pikkuFunc } from '@pikku/core'
|
|
185
|
+
export const getUser = pikkuFunc({
|
|
186
|
+
func: async () => {
|
|
187
|
+
const raw: { email: Private<string> } = { email: 'x' as Private<string> }
|
|
188
|
+
const safe: { email: string } = { email: raw.email as string }
|
|
189
|
+
return safe
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
`)
|
|
193
|
+
// The explicit type annotation on 'safe' strips the brand from the inferred return type
|
|
194
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
195
|
+
assert.equal(hit, undefined)
|
|
196
|
+
})
|
|
197
|
+
})
|
package/src/error-codes.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively walks a resolved TypeScript type looking for `__pii__` brands —
|
|
5
|
+
* the structural marker emitted by `Private<T>` and `Secret<T>`.
|
|
6
|
+
*
|
|
7
|
+
* `Private<T> = T & { readonly __pii__: 'private' }` shows up in the TS type
|
|
8
|
+
* system as an intersection whose constituents include a type with a `__pii__`
|
|
9
|
+
* property. We detect that by checking whether any constituent of an
|
|
10
|
+
* intersection exposes a property named `__pii__`.
|
|
11
|
+
*
|
|
12
|
+
* Returns the list of dotted field paths where a brand was found
|
|
13
|
+
* (e.g. `['email', 'address.phone']`). An empty array means clean.
|
|
14
|
+
*/
|
|
15
|
+
export function findPiiPaths(
|
|
16
|
+
checker: ts.TypeChecker,
|
|
17
|
+
type: ts.Type,
|
|
18
|
+
path = '',
|
|
19
|
+
depth = 0,
|
|
20
|
+
seen = new Set<ts.Type>()
|
|
21
|
+
): string[] {
|
|
22
|
+
if (depth > 8 || seen.has(type)) return []
|
|
23
|
+
seen.add(type)
|
|
24
|
+
|
|
25
|
+
// ── Is this type itself branded? ─────────────────────────────────────────
|
|
26
|
+
// Private<T> = T & { readonly __pii__: 'private' } → isIntersection()
|
|
27
|
+
// where one constituent has a `__pii__` property.
|
|
28
|
+
if (type.isIntersection()) {
|
|
29
|
+
const branded = type.types.some((t) =>
|
|
30
|
+
t.getProperties().some((p) => p.name === '__pii__')
|
|
31
|
+
)
|
|
32
|
+
if (branded) {
|
|
33
|
+
return [path || '<return value>']
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const violations: string[] = []
|
|
38
|
+
|
|
39
|
+
// ── Union: check every branch ─────────────────────────────────────────────
|
|
40
|
+
if (type.isUnion()) {
|
|
41
|
+
for (const branch of type.types) {
|
|
42
|
+
violations.push(...findPiiPaths(checker, branch, path, depth, seen))
|
|
43
|
+
}
|
|
44
|
+
return violations
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Object: recurse into named properties ─────────────────────────────────
|
|
48
|
+
if (type.flags & ts.TypeFlags.Object) {
|
|
49
|
+
const ref = type as ts.TypeReference
|
|
50
|
+
for (const arg of (ref as any).typeArguments ?? []) {
|
|
51
|
+
violations.push(...findPiiPaths(checker, arg, path, depth + 1, seen))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const numberIndex = checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
|
55
|
+
if (numberIndex) {
|
|
56
|
+
const idxPath = path ? `${path}[]` : '[]'
|
|
57
|
+
violations.push(...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen))
|
|
58
|
+
}
|
|
59
|
+
const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String)
|
|
60
|
+
if (stringIndex) {
|
|
61
|
+
const idxPath = path ? `${path}[*]` : '[*]'
|
|
62
|
+
violations.push(...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const prop of type.getProperties()) {
|
|
66
|
+
if (prop.name.startsWith('__')) continue
|
|
67
|
+
const decl = prop.valueDeclaration ?? prop.declarations?.[0]
|
|
68
|
+
if (!decl) continue
|
|
69
|
+
const propType = checker.getTypeOfSymbolAtLocation(prop, decl)
|
|
70
|
+
const subPath = path ? `${path}.${prop.name}` : prop.name
|
|
71
|
+
violations.push(...findPiiPaths(checker, propType, subPath, depth + 1, seen))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return violations
|
|
76
|
+
}
|