@pikku/inspector 0.12.13 → 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 +26 -0
- package/dist/add/add-auth.d.ts +2 -0
- package/dist/add/add-auth.js +34 -0
- package/dist/add/add-functions.js +1 -1
- package/dist/inspector.js +4 -0
- package/dist/types.d.ts +4 -0
- package/dist/utils/check-pii-output.d.ts +4 -4
- package/dist/utils/check-pii-output.js +7 -7
- 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 +1 -1
- package/src/add/pii-check.test.ts +9 -4
- package/src/inspector.ts +4 -0
- package/src/types.ts +4 -0
- package/src/utils/check-pii-output.ts +16 -10
- package/src/utils/serialize-inspector-state.ts +12 -0
- package/src/visit.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
## 0.12.14
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 4b5c75b: feat(auth-js): wire OIDC config (issuer/tenantId) as variables, expand provider registry
|
|
6
|
+
- Move `issuer` and `tenantId` out of the secret blob for OIDC providers (auth0, okta, azure-ad, keycloak, cognito, microsoft-entra-id) — they are public config URLs, not secrets. Now registered via `wireVariable` and loaded at runtime via `services.variables.get()`.
|
|
7
|
+
- Expand provider registry from 13 to 31 providers: reddit, notion, instagram, zoom, figma, tiktok, threads, patreon, dropbox, bitbucket, hubspot, salesforce, atlassian, strava, keycloak, cognito, microsoft-entra-id added.
|
|
8
|
+
- `serialize-auth-gen` emits `wireVariable({...})` declarations and `services.variables.get()` calls in the generated factory for OIDC providers.
|
|
9
|
+
- Integration verifier exercises real `/auth/providers` endpoint with `LocalSecretService` + `LocalVariablesService`, including a spy test proving `services.variables.get('AUTH0_ISSUER')` is called at request time.
|
|
10
|
+
|
|
11
|
+
- 4b5c75b: Add end-to-end data classification for SQLite and Postgres projects.
|
|
12
|
+
|
|
13
|
+
**Core (`@pikku/core`):** New `Private<T>` and `Secret<T>` intersection brands, `ClassificationManifest`, `ColumnClassification`, and `AnonymizeStrategy` types exported from `data-classification.ts`.
|
|
14
|
+
|
|
15
|
+
**CLI (`@pikku/cli`):**
|
|
16
|
+
- SQL comment annotations: `-- @public`, `-- @private[:strategy]`, `-- @secret[:strategy]` on `CREATE TABLE` columns and `ALTER TABLE ... ADD COLUMN` statements. Unannotated columns default to `private`.
|
|
17
|
+
- `pikku db migrate` now emits a `classification.gen.ts` manifest alongside `schema.d.ts`.
|
|
18
|
+
- New `pikku db audit` command — prints a per-column classification summary and warns on `private`/`secret` columns with no anonymize strategy.
|
|
19
|
+
- Postgres dialect support in `resolveDb`, `PostgresMigrationExecutor`, and `PostgresIntrospector`.
|
|
20
|
+
|
|
21
|
+
**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.
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [4b5c75b]
|
|
24
|
+
- Updated dependencies [4b5c75b]
|
|
25
|
+
- @pikku/core@0.12.27
|
|
26
|
+
|
|
1
27
|
## 0.12.13
|
|
2
28
|
|
|
3
29
|
### Patch Changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
import { ErrorCode } from '../error-codes.js';
|
|
3
|
+
export const addAuth = (logger, node, _checker, state) => {
|
|
4
|
+
if (!ts.isCallExpression(node))
|
|
5
|
+
return;
|
|
6
|
+
const expression = node.expression;
|
|
7
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'wireAuth')
|
|
8
|
+
return;
|
|
9
|
+
const firstArg = node.arguments[0];
|
|
10
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
|
|
11
|
+
return;
|
|
12
|
+
const providersProp = firstArg.properties.find((p) => ts.isPropertyAssignment(p) &&
|
|
13
|
+
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
|
|
14
|
+
p.name.text === 'providers');
|
|
15
|
+
const sourceFile = node.getSourceFile().fileName;
|
|
16
|
+
state.auth.files.add(sourceFile);
|
|
17
|
+
if (!providersProp) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!ts.isArrayLiteralExpression(providersProp.initializer)) {
|
|
21
|
+
logger.critical(ErrorCode.MISSING_NAME, 'wireAuth: providers must be an array literal of string literals.');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const element of providersProp.initializer
|
|
25
|
+
.elements) {
|
|
26
|
+
if (!ts.isStringLiteral(element)) {
|
|
27
|
+
logger.critical(ErrorCode.NON_LITERAL_WIRE_NAME, `wireAuth: each provider must be a string literal. Found: ${element.getText()}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!state.auth.providers.includes(element.text)) {
|
|
31
|
+
state.auth.providers.push(element.text);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -629,7 +629,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
629
629
|
}
|
|
630
630
|
// ── PII brand check ───────────────────────────────────────────────────────
|
|
631
631
|
// Walk the function body's ACTUAL inferred return type looking for Private<T>
|
|
632
|
-
// / Secret<T> brands (
|
|
632
|
+
// / Pii<T> / Secret<T> brands (__classification__ property). This runs for every function,
|
|
633
633
|
// including those with a Zod output schema, because the TS return type
|
|
634
634
|
// reflects what the body actually returns before any Zod coercion.
|
|
635
635
|
{
|
package/dist/inspector.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
/**
|
|
3
|
-
* Recursively walks a resolved TypeScript type looking for `
|
|
3
|
+
* Recursively walks a resolved TypeScript type looking for `__classification__` brands —
|
|
4
4
|
* the structural marker emitted by `Private<T>` and `Secret<T>`.
|
|
5
5
|
*
|
|
6
|
-
* `Private<T> = T & { readonly
|
|
7
|
-
* system as an intersection whose constituents include a type with a `
|
|
6
|
+
* `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
|
|
7
|
+
* system as an intersection whose constituents include a type with a `__classification__`
|
|
8
8
|
* property. We detect that by checking whether any constituent of an
|
|
9
|
-
* intersection exposes a property named `
|
|
9
|
+
* intersection exposes a property named `__classification__`.
|
|
10
10
|
*
|
|
11
11
|
* Returns the list of dotted field paths where a brand was found
|
|
12
12
|
* (e.g. `['email', 'address.phone']`). An empty array means clean.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
/**
|
|
3
|
-
* Recursively walks a resolved TypeScript type looking for `
|
|
3
|
+
* Recursively walks a resolved TypeScript type looking for `__classification__` brands —
|
|
4
4
|
* the structural marker emitted by `Private<T>` and `Secret<T>`.
|
|
5
5
|
*
|
|
6
|
-
* `Private<T> = T & { readonly
|
|
7
|
-
* system as an intersection whose constituents include a type with a `
|
|
6
|
+
* `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
|
|
7
|
+
* system as an intersection whose constituents include a type with a `__classification__`
|
|
8
8
|
* property. We detect that by checking whether any constituent of an
|
|
9
|
-
* intersection exposes a property named `
|
|
9
|
+
* intersection exposes a property named `__classification__`.
|
|
10
10
|
*
|
|
11
11
|
* Returns the list of dotted field paths where a brand was found
|
|
12
12
|
* (e.g. `['email', 'address.phone']`). An empty array means clean.
|
|
@@ -16,10 +16,10 @@ export function findPiiPaths(checker, type, path = '', depth = 0, seen = new Set
|
|
|
16
16
|
return [];
|
|
17
17
|
seen.add(type);
|
|
18
18
|
// ── Is this type itself branded? ─────────────────────────────────────────
|
|
19
|
-
// Private<T> = T & { readonly
|
|
20
|
-
// where one constituent has a `
|
|
19
|
+
// Private<T> = T & { readonly __classification__: 'private' } → isIntersection()
|
|
20
|
+
// where one constituent has a `__classification__` property.
|
|
21
21
|
if (type.isIntersection()) {
|
|
22
|
-
const branded = type.types.some((t) => t.getProperties().some((p) => p.name === '
|
|
22
|
+
const branded = type.types.some((t) => t.getProperties().some((p) => p.name === '__classification__'));
|
|
23
23
|
if (branded) {
|
|
24
24
|
return [path || '<return value>'];
|
|
25
25
|
}
|
|
@@ -203,6 +203,10 @@ export interface SerializableInspectorState {
|
|
|
203
203
|
meta: InspectorState['nodes']['meta'];
|
|
204
204
|
files: string[];
|
|
205
205
|
};
|
|
206
|
+
auth: {
|
|
207
|
+
providers: string[];
|
|
208
|
+
files: string[];
|
|
209
|
+
};
|
|
206
210
|
secrets: {
|
|
207
211
|
definitions: InspectorState['secrets']['definitions'];
|
|
208
212
|
files: string[];
|
|
@@ -98,6 +98,10 @@ export function serializeInspectorState(state) {
|
|
|
98
98
|
meta: state.nodes.meta,
|
|
99
99
|
files: Array.from(state.nodes.files),
|
|
100
100
|
},
|
|
101
|
+
auth: {
|
|
102
|
+
providers: state.auth.providers,
|
|
103
|
+
files: Array.from(state.auth.files),
|
|
104
|
+
},
|
|
101
105
|
secrets: {
|
|
102
106
|
definitions: state.secrets.definitions,
|
|
103
107
|
files: Array.from(state.secrets.files),
|
|
@@ -246,6 +250,10 @@ export function deserializeInspectorState(data) {
|
|
|
246
250
|
meta: data.nodes?.meta || {},
|
|
247
251
|
files: new Set(data.nodes?.files || []),
|
|
248
252
|
},
|
|
253
|
+
auth: {
|
|
254
|
+
providers: data.auth?.providers || [],
|
|
255
|
+
files: new Set(data.auth?.files || []),
|
|
256
|
+
},
|
|
249
257
|
secrets: {
|
|
250
258
|
definitions: data.secrets?.definitions || [],
|
|
251
259
|
files: new Set(data.secrets?.files || []),
|
package/dist/visit.js
CHANGED
|
@@ -17,6 +17,7 @@ import { addWireAddon } from './add/add-wire-addon.js';
|
|
|
17
17
|
import { addMiddleware } from './add/add-middleware.js';
|
|
18
18
|
import { addPermission } from './add/add-permission.js';
|
|
19
19
|
import { addCLI, addCLIRenderers } from './add/add-cli.js';
|
|
20
|
+
import { addAuth } from './add/add-auth.js';
|
|
20
21
|
import { addSecret } from './add/add-secret.js';
|
|
21
22
|
import { addCredential } from './add/add-credential.js';
|
|
22
23
|
import { addVariable } from './add/add-variable.js';
|
|
@@ -41,6 +42,7 @@ export const visitSetup = (logger, checker, node, state, options) => {
|
|
|
41
42
|
};
|
|
42
43
|
export const visitRoutes = (logger, checker, node, state, options) => {
|
|
43
44
|
addFunctions(logger, node, checker, state, options);
|
|
45
|
+
addAuth(logger, node, checker, state, options);
|
|
44
46
|
addSecret(logger, node, checker, state, options);
|
|
45
47
|
addCredential(logger, node, checker, state, options);
|
|
46
48
|
addVariable(logger, node, checker, state, options);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.14",
|
|
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.27",
|
|
39
39
|
"path-to-regexp": "^8.3.0",
|
|
40
40
|
"ts-json-schema-generator": "^2.5.0",
|
|
41
41
|
"tsx": "^4.21.0",
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
11
|
+
({
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
critical: (code: ErrorCode, message: string) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
19
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
20
|
+
}) satisfies InspectorLogger
|
|
21
|
+
|
|
22
|
+
describe('addAuth inspector', () => {
|
|
23
|
+
test('extracts provider string literals from wireAuth call', async () => {
|
|
24
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-'))
|
|
25
|
+
const file = join(rootDir, 'auth.ts')
|
|
26
|
+
|
|
27
|
+
await writeFile(
|
|
28
|
+
file,
|
|
29
|
+
[
|
|
30
|
+
"import { wireAuth } from '@pikku/auth-js'",
|
|
31
|
+
"wireAuth({ providers: ['github', 'google'] })",
|
|
32
|
+
].join('\n')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
36
|
+
try {
|
|
37
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
38
|
+
assert.equal(criticals.length, 0)
|
|
39
|
+
assert.deepEqual(state.auth.providers, ['github', 'google'])
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('deduplicates providers across multiple wireAuth calls', async () => {
|
|
46
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-dedup-'))
|
|
47
|
+
const file = join(rootDir, 'auth.ts')
|
|
48
|
+
|
|
49
|
+
await writeFile(
|
|
50
|
+
file,
|
|
51
|
+
[
|
|
52
|
+
"import { wireAuth } from '@pikku/auth-js'",
|
|
53
|
+
"wireAuth({ providers: ['github'] })",
|
|
54
|
+
"wireAuth({ providers: ['github', 'google'] })",
|
|
55
|
+
].join('\n')
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
59
|
+
try {
|
|
60
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
61
|
+
assert.equal(criticals.length, 0)
|
|
62
|
+
assert.deepEqual(state.auth.providers, ['github', 'google'])
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('logs critical error when a provider is a non-literal reference', async () => {
|
|
69
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-nonlit-'))
|
|
70
|
+
const file = join(rootDir, 'auth.ts')
|
|
71
|
+
|
|
72
|
+
await writeFile(
|
|
73
|
+
file,
|
|
74
|
+
[
|
|
75
|
+
"import { wireAuth } from '@pikku/auth-js'",
|
|
76
|
+
"const PROVIDER = 'github'",
|
|
77
|
+
'wireAuth({ providers: [PROVIDER] })',
|
|
78
|
+
].join('\n')
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
82
|
+
try {
|
|
83
|
+
await inspect(makeLogger(criticals), [file], { rootDir })
|
|
84
|
+
const hit = criticals.find(
|
|
85
|
+
(e) => e.code === ErrorCode.NON_LITERAL_WIRE_NAME
|
|
86
|
+
)
|
|
87
|
+
assert.ok(hit, 'expected NON_LITERAL_WIRE_NAME critical')
|
|
88
|
+
assert.match(hit!.message, /PROVIDER/)
|
|
89
|
+
} finally {
|
|
90
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('does not error when providers is absent (credentials-only wireAuth)', async () => {
|
|
95
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-creds-only-'))
|
|
96
|
+
const file = join(rootDir, 'auth.ts')
|
|
97
|
+
|
|
98
|
+
await writeFile(
|
|
99
|
+
file,
|
|
100
|
+
[
|
|
101
|
+
"import { wireAuth } from '@pikku/auth-js'",
|
|
102
|
+
'wireAuth({ credentials: { authorize: async () => null } })',
|
|
103
|
+
].join('\n')
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
107
|
+
try {
|
|
108
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
109
|
+
assert.equal(
|
|
110
|
+
criticals.length,
|
|
111
|
+
0,
|
|
112
|
+
'credentials-only wireAuth must not produce errors'
|
|
113
|
+
)
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
state.auth.providers,
|
|
116
|
+
[],
|
|
117
|
+
'no providers should be extracted'
|
|
118
|
+
)
|
|
119
|
+
assert.ok(state.auth.files.has(file), 'source file still tracked')
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('logs critical error when providers is not an array literal', async () => {
|
|
126
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-nonarray-'))
|
|
127
|
+
const file = join(rootDir, 'auth.ts')
|
|
128
|
+
|
|
129
|
+
await writeFile(
|
|
130
|
+
file,
|
|
131
|
+
[
|
|
132
|
+
"import { wireAuth } from '@pikku/auth-js'",
|
|
133
|
+
"const PROVIDERS = ['github']",
|
|
134
|
+
'wireAuth({ providers: PROVIDERS })',
|
|
135
|
+
].join('\n')
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
139
|
+
try {
|
|
140
|
+
await inspect(makeLogger(criticals), [file], { rootDir })
|
|
141
|
+
const hit = criticals.find((e) => e.code === ErrorCode.MISSING_NAME)
|
|
142
|
+
assert.ok(
|
|
143
|
+
hit,
|
|
144
|
+
'expected MISSING_NAME critical for non-array-literal providers'
|
|
145
|
+
)
|
|
146
|
+
} finally {
|
|
147
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('tracks source file in state.auth.files', async () => {
|
|
152
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-files-'))
|
|
153
|
+
const file = join(rootDir, 'auth.wiring.ts')
|
|
154
|
+
|
|
155
|
+
await writeFile(
|
|
156
|
+
file,
|
|
157
|
+
[
|
|
158
|
+
"import { wireAuth } from '@pikku/auth-js'",
|
|
159
|
+
"wireAuth({ providers: ['discord'] })",
|
|
160
|
+
].join('\n')
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
164
|
+
try {
|
|
165
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
166
|
+
assert.equal(criticals.length, 0)
|
|
167
|
+
assert.ok(
|
|
168
|
+
state.auth.files.has(file),
|
|
169
|
+
'source file should be tracked in state.auth.files'
|
|
170
|
+
)
|
|
171
|
+
} finally {
|
|
172
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import type { AddWiring } from '../types.js'
|
|
3
|
+
import { ErrorCode } from '../error-codes.js'
|
|
4
|
+
|
|
5
|
+
export const addAuth: AddWiring = (logger, node, _checker, state) => {
|
|
6
|
+
if (!ts.isCallExpression(node)) return
|
|
7
|
+
|
|
8
|
+
const expression = node.expression
|
|
9
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'wireAuth') return
|
|
10
|
+
|
|
11
|
+
const firstArg = node.arguments[0]
|
|
12
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return
|
|
13
|
+
|
|
14
|
+
const providersProp = firstArg.properties.find(
|
|
15
|
+
(p) =>
|
|
16
|
+
ts.isPropertyAssignment(p) &&
|
|
17
|
+
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
|
|
18
|
+
p.name.text === 'providers'
|
|
19
|
+
) as ts.PropertyAssignment | undefined
|
|
20
|
+
|
|
21
|
+
const sourceFile = node.getSourceFile().fileName
|
|
22
|
+
state.auth.files.add(sourceFile)
|
|
23
|
+
|
|
24
|
+
if (!providersProp) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!ts.isArrayLiteralExpression(providersProp.initializer)) {
|
|
29
|
+
logger.critical(
|
|
30
|
+
ErrorCode.MISSING_NAME,
|
|
31
|
+
'wireAuth: providers must be an array literal of string literals.'
|
|
32
|
+
)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const element of (providersProp.initializer as ts.ArrayLiteralExpression)
|
|
37
|
+
.elements) {
|
|
38
|
+
if (!ts.isStringLiteral(element)) {
|
|
39
|
+
logger.critical(
|
|
40
|
+
ErrorCode.NON_LITERAL_WIRE_NAME,
|
|
41
|
+
`wireAuth: each provider must be a string literal. Found: ${element.getText()}`
|
|
42
|
+
)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
if (!state.auth.providers.includes(element.text)) {
|
|
46
|
+
state.auth.providers.push(element.text)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/add/add-functions.ts
CHANGED
|
@@ -885,7 +885,7 @@ export const addFunctions: AddWiring = (
|
|
|
885
885
|
|
|
886
886
|
// ── PII brand check ───────────────────────────────────────────────────────
|
|
887
887
|
// Walk the function body's ACTUAL inferred return type looking for Private<T>
|
|
888
|
-
// / Secret<T> brands (
|
|
888
|
+
// / Pii<T> / Secret<T> brands (__classification__ property). This runs for every function,
|
|
889
889
|
// including those with a Zod output schema, because the TS return type
|
|
890
890
|
// reflects what the body actually returns before any Zod coercion.
|
|
891
891
|
{
|
|
@@ -23,13 +23,14 @@ function makeLogger() {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Inline Private<T>/Secret<T> definitions that the test source files use.
|
|
26
|
+
* Inline Private<T>/Pii<T>/Secret<T> definitions that the test source files use.
|
|
27
27
|
* Mirrors what schema.d.ts emits so the TypeScript program sees the correct
|
|
28
28
|
* structural brand type even without @pikku/core being importable from /tmp.
|
|
29
29
|
*/
|
|
30
30
|
const BRAND_TYPES = `
|
|
31
|
-
type Private<T> = T & { readonly
|
|
32
|
-
type
|
|
31
|
+
type Private<T> = T & { readonly __classification__: 'private' }
|
|
32
|
+
type Pii<T> = T & { readonly __classification__: 'pii' }
|
|
33
|
+
type Secret<T> = T & { readonly __classification__: 'secret' }
|
|
33
34
|
`
|
|
34
35
|
|
|
35
36
|
async function runInspect(sourceCode: string) {
|
|
@@ -104,7 +105,11 @@ export const getPublicData = pikkuFunc({
|
|
|
104
105
|
})
|
|
105
106
|
`)
|
|
106
107
|
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
107
|
-
assert.equal(
|
|
108
|
+
assert.equal(
|
|
109
|
+
hit,
|
|
110
|
+
undefined,
|
|
111
|
+
`Expected no PKU910 but got: ${JSON.stringify(hit)}`
|
|
112
|
+
)
|
|
108
113
|
})
|
|
109
114
|
|
|
110
115
|
test('does not flag a void-returning function', async () => {
|
package/src/inspector.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Recursively walks a resolved TypeScript type looking for `
|
|
4
|
+
* Recursively walks a resolved TypeScript type looking for `__classification__` brands —
|
|
5
5
|
* the structural marker emitted by `Private<T>` and `Secret<T>`.
|
|
6
6
|
*
|
|
7
|
-
* `Private<T> = T & { readonly
|
|
8
|
-
* system as an intersection whose constituents include a type with a `
|
|
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
9
|
* property. We detect that by checking whether any constituent of an
|
|
10
|
-
* intersection exposes a property named `
|
|
10
|
+
* intersection exposes a property named `__classification__`.
|
|
11
11
|
*
|
|
12
12
|
* Returns the list of dotted field paths where a brand was found
|
|
13
13
|
* (e.g. `['email', 'address.phone']`). An empty array means clean.
|
|
@@ -23,11 +23,11 @@ export function findPiiPaths(
|
|
|
23
23
|
seen.add(type)
|
|
24
24
|
|
|
25
25
|
// ── Is this type itself branded? ─────────────────────────────────────────
|
|
26
|
-
// Private<T> = T & { readonly
|
|
27
|
-
// where one constituent has a `
|
|
26
|
+
// Private<T> = T & { readonly __classification__: 'private' } → isIntersection()
|
|
27
|
+
// where one constituent has a `__classification__` property.
|
|
28
28
|
if (type.isIntersection()) {
|
|
29
29
|
const branded = type.types.some((t) =>
|
|
30
|
-
t.getProperties().some((p) => p.name === '
|
|
30
|
+
t.getProperties().some((p) => p.name === '__classification__')
|
|
31
31
|
)
|
|
32
32
|
if (branded) {
|
|
33
33
|
return [path || '<return value>']
|
|
@@ -54,12 +54,16 @@ export function findPiiPaths(
|
|
|
54
54
|
const numberIndex = checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
|
55
55
|
if (numberIndex) {
|
|
56
56
|
const idxPath = path ? `${path}[]` : '[]'
|
|
57
|
-
violations.push(
|
|
57
|
+
violations.push(
|
|
58
|
+
...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen)
|
|
59
|
+
)
|
|
58
60
|
}
|
|
59
61
|
const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String)
|
|
60
62
|
if (stringIndex) {
|
|
61
63
|
const idxPath = path ? `${path}[*]` : '[*]'
|
|
62
|
-
violations.push(
|
|
64
|
+
violations.push(
|
|
65
|
+
...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen)
|
|
66
|
+
)
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
for (const prop of type.getProperties()) {
|
|
@@ -68,7 +72,9 @@ export function findPiiPaths(
|
|
|
68
72
|
if (!decl) continue
|
|
69
73
|
const propType = checker.getTypeOfSymbolAtLocation(prop, decl)
|
|
70
74
|
const subPath = path ? `${path}.${prop.name}` : prop.name
|
|
71
|
-
violations.push(
|
|
75
|
+
violations.push(
|
|
76
|
+
...findPiiPaths(checker, propType, subPath, depth + 1, seen)
|
|
77
|
+
)
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -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)
|