@pikku/inspector 0.12.12 → 0.12.14
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 +45 -0
- package/dist/add/add-auth.d.ts +2 -0
- package/dist/add/add-auth.js +34 -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/inspector.js +4 -0
- package/dist/types.d.ts +4 -0
- package/dist/utils/check-pii-output.d.ts +14 -0
- package/dist/utils/check-pii-output.js +63 -0
- package/dist/utils/serialize-inspector-state.d.ts +4 -0
- package/dist/utils/serialize-inspector-state.js +8 -0
- package/dist/visit.js +2 -0
- package/package.json +2 -2
- package/src/add/add-auth.test.ts +175 -0
- package/src/add/add-auth.ts +49 -0
- package/src/add/add-functions.ts +24 -0
- package/src/add/pii-check.test.ts +202 -0
- package/src/error-codes.ts +3 -0
- package/src/inspector.ts +4 -0
- package/src/types.ts +4 -0
- package/src/utils/check-pii-output.ts +82 -0
- package/src/utils/serialize-inspector-state.ts +12 -0
- package/src/visit.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
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>/Pii<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 __classification__: 'private' }
|
|
32
|
+
type Pii<T> = T & { readonly __classification__: 'pii' }
|
|
33
|
+
type Secret<T> = T & { readonly __classification__: 'secret' }
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
async function runInspect(sourceCode: string) {
|
|
37
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-pii-test-'))
|
|
38
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
39
|
+
await writeFile(file, sourceCode)
|
|
40
|
+
const { logger, criticals } = makeLogger()
|
|
41
|
+
try {
|
|
42
|
+
await inspect(logger, [file], { rootDir: tmpDir })
|
|
43
|
+
} finally {
|
|
44
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
45
|
+
}
|
|
46
|
+
return criticals
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── findPiiPaths unit tests via full inspect() round-trip ────────────────────
|
|
50
|
+
|
|
51
|
+
describe('PII output check — PKU910', () => {
|
|
52
|
+
test('flags a top-level Private<string> field', async () => {
|
|
53
|
+
const criticals = await runInspect(`
|
|
54
|
+
${BRAND_TYPES}
|
|
55
|
+
import { pikkuFunc } from '@pikku/core'
|
|
56
|
+
export const getUser = pikkuFunc({
|
|
57
|
+
func: async () => {
|
|
58
|
+
const email = 'test@example.com' as Private<string>
|
|
59
|
+
return { id: 1, email }
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
`)
|
|
63
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
64
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
65
|
+
assert.match(hit.message, /email/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('flags a top-level Secret<string> field', async () => {
|
|
69
|
+
const criticals = await runInspect(`
|
|
70
|
+
${BRAND_TYPES}
|
|
71
|
+
import { pikkuFunc } from '@pikku/core'
|
|
72
|
+
export const getToken = pikkuFunc({
|
|
73
|
+
func: async () => {
|
|
74
|
+
const token = 'abc' as Secret<string>
|
|
75
|
+
return { token }
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
`)
|
|
79
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
80
|
+
assert.ok(hit)
|
|
81
|
+
assert.match(hit.message, /token/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('flags a nested Private field', async () => {
|
|
85
|
+
const criticals = await runInspect(`
|
|
86
|
+
${BRAND_TYPES}
|
|
87
|
+
import { pikkuFunc } from '@pikku/core'
|
|
88
|
+
export const getProfile = pikkuFunc({
|
|
89
|
+
func: async () => {
|
|
90
|
+
const email = 'x@y.com' as Private<string>
|
|
91
|
+
return { user: { id: 1, email } }
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
`)
|
|
95
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
96
|
+
assert.ok(hit)
|
|
97
|
+
assert.match(hit.message, /user\.email/)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('does not flag a plain string return', async () => {
|
|
101
|
+
const criticals = await runInspect(`
|
|
102
|
+
import { pikkuFunc } from '@pikku/core'
|
|
103
|
+
export const getPublicData = pikkuFunc({
|
|
104
|
+
func: async () => ({ id: 1, status: 'active', count: 42 })
|
|
105
|
+
})
|
|
106
|
+
`)
|
|
107
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
108
|
+
assert.equal(
|
|
109
|
+
hit,
|
|
110
|
+
undefined,
|
|
111
|
+
`Expected no PKU910 but got: ${JSON.stringify(hit)}`
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('does not flag a void-returning function', async () => {
|
|
116
|
+
const criticals = await runInspect(`
|
|
117
|
+
import { pikkuFunc } from '@pikku/core'
|
|
118
|
+
export const doWork = pikkuFunc({
|
|
119
|
+
func: async () => { /* no return */ }
|
|
120
|
+
})
|
|
121
|
+
`)
|
|
122
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
123
|
+
assert.equal(hit, undefined)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('flags a function that returns a typed alias with Private field', async () => {
|
|
127
|
+
const criticals = await runInspect(`
|
|
128
|
+
${BRAND_TYPES}
|
|
129
|
+
import { pikkuFunc } from '@pikku/core'
|
|
130
|
+
type UserRow = { id: number; email: Private<string> }
|
|
131
|
+
export const getUser = pikkuFunc({
|
|
132
|
+
func: async (): Promise<UserRow> => {
|
|
133
|
+
return { id: 1, email: 'x' as Private<string> }
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
`)
|
|
137
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
138
|
+
assert.ok(hit)
|
|
139
|
+
assert.match(hit.message, /email/)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('flags across multiple functions in the same file', async () => {
|
|
143
|
+
const criticals = await runInspect(`
|
|
144
|
+
${BRAND_TYPES}
|
|
145
|
+
import { pikkuFunc } from '@pikku/core'
|
|
146
|
+
export const getEmail = pikkuFunc({
|
|
147
|
+
func: async () => ({ email: 'x' as Private<string> })
|
|
148
|
+
})
|
|
149
|
+
export const getPhone = pikkuFunc({
|
|
150
|
+
func: async () => ({ phone: '555' as Private<string> })
|
|
151
|
+
})
|
|
152
|
+
export const getSafe = pikkuFunc({
|
|
153
|
+
func: async () => ({ name: 'Alice' })
|
|
154
|
+
})
|
|
155
|
+
`)
|
|
156
|
+
const hits = criticals.filter((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
157
|
+
assert.equal(hits.length, 2, `Expected 2 PKU910 but got ${hits.length}`)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('flags branded values inside arrays', async () => {
|
|
161
|
+
const criticals = await runInspect(`
|
|
162
|
+
${BRAND_TYPES}
|
|
163
|
+
import { pikkuFunc } from '@pikku/core'
|
|
164
|
+
export const getEmails = pikkuFunc({
|
|
165
|
+
func: async () => ({ emails: ['x@y.com' as Private<string>] })
|
|
166
|
+
})
|
|
167
|
+
`)
|
|
168
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
169
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
170
|
+
assert.match(hit.message, /emails/)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('flags branded values inside string-indexed records', async () => {
|
|
174
|
+
const criticals = await runInspect(`
|
|
175
|
+
${BRAND_TYPES}
|
|
176
|
+
import { pikkuFunc } from '@pikku/core'
|
|
177
|
+
export const getMap = pikkuFunc({
|
|
178
|
+
func: async () => ({ byId: { a: 'x@y.com' as Private<string> } as Record<string, Private<string>> })
|
|
179
|
+
})
|
|
180
|
+
`)
|
|
181
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
182
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
183
|
+
assert.match(hit.message, /byId/)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('does not flag when branded field is stripped before return', async () => {
|
|
187
|
+
const criticals = await runInspect(`
|
|
188
|
+
${BRAND_TYPES}
|
|
189
|
+
import { pikkuFunc } from '@pikku/core'
|
|
190
|
+
export const getUser = pikkuFunc({
|
|
191
|
+
func: async () => {
|
|
192
|
+
const raw: { email: Private<string> } = { email: 'x' as Private<string> }
|
|
193
|
+
const safe: { email: string } = { email: raw.email as string }
|
|
194
|
+
return safe
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
`)
|
|
198
|
+
// The explicit type annotation on 'safe' strips the brand from the inferred return type
|
|
199
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
200
|
+
assert.equal(hit, undefined)
|
|
201
|
+
})
|
|
202
|
+
})
|
package/src/error-codes.ts
CHANGED
package/src/inspector.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively walks a resolved TypeScript type looking for `__classification__` brands —
|
|
5
|
+
* the structural marker emitted by `Private<T>` and `Secret<T>`.
|
|
6
|
+
*
|
|
7
|
+
* `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
|
|
8
|
+
* system as an intersection whose constituents include a type with a `__classification__`
|
|
9
|
+
* property. We detect that by checking whether any constituent of an
|
|
10
|
+
* intersection exposes a property named `__classification__`.
|
|
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 __classification__: 'private' } → isIntersection()
|
|
27
|
+
// where one constituent has a `__classification__` property.
|
|
28
|
+
if (type.isIntersection()) {
|
|
29
|
+
const branded = type.types.some((t) =>
|
|
30
|
+
t.getProperties().some((p) => p.name === '__classification__')
|
|
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(
|
|
58
|
+
...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen)
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String)
|
|
62
|
+
if (stringIndex) {
|
|
63
|
+
const idxPath = path ? `${path}[*]` : '[*]'
|
|
64
|
+
violations.push(
|
|
65
|
+
...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen)
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const prop of type.getProperties()) {
|
|
70
|
+
if (prop.name.startsWith('__')) continue
|
|
71
|
+
const decl = prop.valueDeclaration ?? prop.declarations?.[0]
|
|
72
|
+
if (!decl) continue
|
|
73
|
+
const propType = checker.getTypeOfSymbolAtLocation(prop, decl)
|
|
74
|
+
const subPath = path ? `${path}.${prop.name}` : prop.name
|
|
75
|
+
violations.push(
|
|
76
|
+
...findPiiPaths(checker, propType, subPath, depth + 1, seen)
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return violations
|
|
82
|
+
}
|
|
@@ -181,6 +181,10 @@ export interface SerializableInspectorState {
|
|
|
181
181
|
meta: InspectorState['nodes']['meta']
|
|
182
182
|
files: string[]
|
|
183
183
|
}
|
|
184
|
+
auth: {
|
|
185
|
+
providers: string[]
|
|
186
|
+
files: string[]
|
|
187
|
+
}
|
|
184
188
|
secrets: {
|
|
185
189
|
definitions: InspectorState['secrets']['definitions']
|
|
186
190
|
files: string[]
|
|
@@ -383,6 +387,10 @@ export function serializeInspectorState(
|
|
|
383
387
|
meta: state.nodes.meta,
|
|
384
388
|
files: Array.from(state.nodes.files),
|
|
385
389
|
},
|
|
390
|
+
auth: {
|
|
391
|
+
providers: state.auth.providers,
|
|
392
|
+
files: Array.from(state.auth.files),
|
|
393
|
+
},
|
|
386
394
|
secrets: {
|
|
387
395
|
definitions: state.secrets.definitions,
|
|
388
396
|
files: Array.from(state.secrets.files),
|
|
@@ -556,6 +564,10 @@ export function deserializeInspectorState(
|
|
|
556
564
|
meta: data.nodes?.meta || {},
|
|
557
565
|
files: new Set(data.nodes?.files || []),
|
|
558
566
|
},
|
|
567
|
+
auth: {
|
|
568
|
+
providers: data.auth?.providers || [],
|
|
569
|
+
files: new Set(data.auth?.files || []),
|
|
570
|
+
},
|
|
559
571
|
secrets: {
|
|
560
572
|
definitions: data.secrets?.definitions || [],
|
|
561
573
|
files: new Set(data.secrets?.files || []),
|
package/src/visit.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { addWireAddon } from './add/add-wire-addon.js'
|
|
|
22
22
|
import { addMiddleware } from './add/add-middleware.js'
|
|
23
23
|
import { addPermission } from './add/add-permission.js'
|
|
24
24
|
import { addCLI, addCLIRenderers } from './add/add-cli.js'
|
|
25
|
+
import { addAuth } from './add/add-auth.js'
|
|
25
26
|
import { addSecret } from './add/add-secret.js'
|
|
26
27
|
import { addCredential } from './add/add-credential.js'
|
|
27
28
|
import { addVariable } from './add/add-variable.js'
|
|
@@ -106,6 +107,7 @@ export const visitRoutes = (
|
|
|
106
107
|
options: InspectorOptions
|
|
107
108
|
) => {
|
|
108
109
|
addFunctions(logger, node, checker, state, options)
|
|
110
|
+
addAuth(logger, node, checker, state, options)
|
|
109
111
|
addSecret(logger, node, checker, state, options)
|
|
110
112
|
addCredential(logger, node, checker, state, options)
|
|
111
113
|
addVariable(logger, node, checker, state, options)
|