@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.
@@ -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
+ })
@@ -77,4 +77,7 @@ export enum ErrorCode {
77
77
 
78
78
  // Feature Flag
79
79
  WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = 'PKU901',
80
+
81
+ // Data classification errors
82
+ PII_IN_OUTPUT = 'PKU910',
80
83
  }
package/src/inspector.ts CHANGED
@@ -145,6 +145,10 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
145
145
  meta: {},
146
146
  files: new Set(),
147
147
  },
148
+ auth: {
149
+ providers: [],
150
+ files: new Set(),
151
+ },
148
152
  secrets: {
149
153
  definitions: [],
150
154
  files: new Set(),
package/src/types.ts CHANGED
@@ -382,6 +382,10 @@ export interface InspectorState {
382
382
  meta: NodesMeta
383
383
  files: Set<string>
384
384
  }
385
+ auth: {
386
+ providers: string[]
387
+ files: Set<string>
388
+ }
385
389
  secrets: {
386
390
  definitions: SecretDefinitions
387
391
  files: Set<string>
@@ -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)