@pikku/inspector 0.12.50 → 0.12.54
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 +743 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +199 -8
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +216 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +17 -1
- package/dist/error-codes.js +23 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +55 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +246 -13
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +93 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.d.ts +6 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +48 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +9 -4
- package/dist/utils/workflow/dsl/patterns.js +26 -4
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +273 -11
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +382 -0
- package/src/add/add-personas.ts +285 -0
- package/src/add/add-scenario-step.test.ts +87 -5
- package/src/add/add-scope.test.ts +103 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +297 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/add-workflow.ts +21 -1
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +26 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +56 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/check-secret-sinks.ts +145 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +313 -12
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +103 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -104,17 +104,17 @@ describe('addon authoring bans', () => {
|
|
|
104
104
|
})
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
test('
|
|
107
|
+
test('defineSecret inside an addon is allowed', async () => {
|
|
108
108
|
const source = [
|
|
109
|
-
"import {
|
|
110
|
-
"
|
|
109
|
+
"import { defineSecret } from '@pikku/core'",
|
|
110
|
+
"defineSecret({ name: 'example', secretId: 'EXAMPLE' } as any)",
|
|
111
111
|
].join('\n')
|
|
112
112
|
await withTempApp(source, async (file, rootDir) => {
|
|
113
113
|
const { logger, criticals } = recordingLogger()
|
|
114
114
|
await inspect(logger, [file], { rootDir, isAddon: true })
|
|
115
115
|
assert.ok(
|
|
116
116
|
!criticals.some((c) => c.code === ErrorCode.ADDON_WIRING_NOT_ALLOWED),
|
|
117
|
-
`expected
|
|
117
|
+
`expected defineSecret to be allowed, got ${JSON.stringify(criticals)}`
|
|
118
118
|
)
|
|
119
119
|
})
|
|
120
120
|
})
|
|
@@ -0,0 +1,144 @@
|
|
|
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, InspectorOptions } from '../types.js'
|
|
9
|
+
|
|
10
|
+
function makeLogger(
|
|
11
|
+
criticals: Array<{ code: string; message: string }>
|
|
12
|
+
): InspectorLogger {
|
|
13
|
+
return {
|
|
14
|
+
debug: () => {},
|
|
15
|
+
info: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
diagnostic: ({ code, message }: any) => criticals.push({ code, message }),
|
|
19
|
+
critical: (code: any, message: string) => criticals.push({ code, message }),
|
|
20
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
21
|
+
} as InspectorLogger
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function run(source: string, allow: InspectorOptions['allow'] = {}) {
|
|
25
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-allow-'))
|
|
26
|
+
const file = join(rootDir, 'subject.ts')
|
|
27
|
+
await writeFile(file, source)
|
|
28
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
29
|
+
const state = await inspect(makeLogger(criticals), [file], {
|
|
30
|
+
rootDir,
|
|
31
|
+
allow,
|
|
32
|
+
})
|
|
33
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
34
|
+
return { state, criticals }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const has = (criticals: Array<{ code: string }>, code: ErrorCode) =>
|
|
38
|
+
criticals.some((c) => c.code === code)
|
|
39
|
+
|
|
40
|
+
const PERMISSIONS_IN_BODY = [
|
|
41
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
42
|
+
'export const stripeWebhook = pikkuSessionlessFunc({',
|
|
43
|
+
' expose: true,',
|
|
44
|
+
' permissionsInBody: true,',
|
|
45
|
+
' func: async () => ({ ok: true }),',
|
|
46
|
+
'})',
|
|
47
|
+
].join('\n')
|
|
48
|
+
|
|
49
|
+
const COMPLEX_WORKFLOW = [
|
|
50
|
+
"import { pikkuWorkflowComplexFunc } from '@pikku/core/workflow'",
|
|
51
|
+
'export const cleanup = pikkuWorkflowComplexFunc(',
|
|
52
|
+
' async (_services, _input, { workflow }: any) => {',
|
|
53
|
+
' for (const id of [1, 2, 3]) {',
|
|
54
|
+
" await workflow.step('touch', { id })",
|
|
55
|
+
' }',
|
|
56
|
+
' return { ok: true }',
|
|
57
|
+
' }',
|
|
58
|
+
')',
|
|
59
|
+
].join('\n')
|
|
60
|
+
|
|
61
|
+
describe('allow: escape hatches are refused unless opted into', () => {
|
|
62
|
+
test('permissionsInBody is a critical when not allowed', async () => {
|
|
63
|
+
const { criticals } = await run(PERMISSIONS_IN_BODY)
|
|
64
|
+
assert.ok(
|
|
65
|
+
has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
|
|
66
|
+
`expected PKU576, got ${JSON.stringify(criticals)}`
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('the message names the flag that would permit it', async () => {
|
|
71
|
+
const { criticals } = await run(PERMISSIONS_IN_BODY)
|
|
72
|
+
const found = criticals.find(
|
|
73
|
+
(c) => c.code === ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED
|
|
74
|
+
)
|
|
75
|
+
assert.match(found!.message, /allow: \{ permissionsInBody: true \}/)
|
|
76
|
+
assert.match(found!.message, /stripeWebhook/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('permissionsInBody is accepted once allowed', async () => {
|
|
80
|
+
const { criticals, state } = await run(PERMISSIONS_IN_BODY, {
|
|
81
|
+
permissionsInBody: true,
|
|
82
|
+
})
|
|
83
|
+
assert.equal(
|
|
84
|
+
has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
|
|
85
|
+
false
|
|
86
|
+
)
|
|
87
|
+
const meta = Object.values(state.functions.meta) as any[]
|
|
88
|
+
assert.equal(
|
|
89
|
+
meta.find((m) => m.name === 'stripeWebhook')?.permissionsInBody,
|
|
90
|
+
true
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('a refused function is not registered at all', async () => {
|
|
95
|
+
const { state } = await run(PERMISSIONS_IN_BODY)
|
|
96
|
+
const meta = Object.values(state.functions.meta) as any[]
|
|
97
|
+
assert.equal(
|
|
98
|
+
meta.some((m) => m.name === 'stripeWebhook'),
|
|
99
|
+
false,
|
|
100
|
+
'a refused function must not reach the meta, or the build would emit it anyway'
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('complexWorkflows is a critical when not allowed', async () => {
|
|
105
|
+
const { criticals } = await run(COMPLEX_WORKFLOW)
|
|
106
|
+
assert.ok(
|
|
107
|
+
has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED),
|
|
108
|
+
`expected PKU643, got ${JSON.stringify(criticals)}`
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('the complex-workflow message points at the DSL', async () => {
|
|
113
|
+
const { criticals } = await run(COMPLEX_WORKFLOW)
|
|
114
|
+
const found = criticals.find(
|
|
115
|
+
(c) => c.code === ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED
|
|
116
|
+
)
|
|
117
|
+
assert.match(found!.message, /pikkuWorkflowFunc/)
|
|
118
|
+
assert.match(found!.message, /allow: \{ complexWorkflows: true \}/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('complexWorkflows is accepted once allowed', async () => {
|
|
122
|
+
const { criticals } = await run(COMPLEX_WORKFLOW, {
|
|
123
|
+
complexWorkflows: true,
|
|
124
|
+
})
|
|
125
|
+
assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('an ordinary function and DSL workflow are unaffected', async () => {
|
|
129
|
+
const { criticals } = await run(
|
|
130
|
+
[
|
|
131
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
132
|
+
'export const ordinary = pikkuFunc({',
|
|
133
|
+
' expose: true,',
|
|
134
|
+
' func: async () => ({ ok: true }),',
|
|
135
|
+
'})',
|
|
136
|
+
].join('\n')
|
|
137
|
+
)
|
|
138
|
+
assert.equal(
|
|
139
|
+
has(criticals, ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED),
|
|
140
|
+
false
|
|
141
|
+
)
|
|
142
|
+
assert.equal(has(criticals, ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED), false)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
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 type { InspectorLogger } from '../types.js'
|
|
8
|
+
|
|
9
|
+
const silentLogger = (): InspectorLogger =>
|
|
10
|
+
({
|
|
11
|
+
debug: () => {},
|
|
12
|
+
info: () => {},
|
|
13
|
+
warn: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
diagnostic: () => {},
|
|
16
|
+
critical: () => {},
|
|
17
|
+
hasCriticalErrors: () => false,
|
|
18
|
+
}) as InspectorLogger
|
|
19
|
+
|
|
20
|
+
async function metaFor(source: string) {
|
|
21
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-audit-'))
|
|
22
|
+
const file = join(rootDir, 'subject.ts')
|
|
23
|
+
await writeFile(file, source)
|
|
24
|
+
const state = await inspect(silentLogger(), [file], { rootDir })
|
|
25
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
26
|
+
return Object.values(state.functions.meta) as any[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const func = (name: string, auditLine: string | null) =>
|
|
30
|
+
[
|
|
31
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
32
|
+
`export const ${name} = pikkuFunc({`,
|
|
33
|
+
...(auditLine ? [` ${auditLine}`] : []),
|
|
34
|
+
' func: async () => ({ ok: true }),',
|
|
35
|
+
'})',
|
|
36
|
+
].join('\n')
|
|
37
|
+
|
|
38
|
+
describe('the audit flag reaches the function meta', () => {
|
|
39
|
+
test('`audit: true` resolves to best-effort', async () => {
|
|
40
|
+
const meta = await metaFor(func('cancelInvoice', 'audit: true,'))
|
|
41
|
+
assert.deepEqual(meta.find((m) => m.name === 'cancelInvoice')?.audit, {
|
|
42
|
+
durability: 'best-effort',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('an explicit durability is carried through', async () => {
|
|
47
|
+
const meta = await metaFor(
|
|
48
|
+
func('chargeCard', "audit: { durability: 'transactional' },")
|
|
49
|
+
)
|
|
50
|
+
assert.deepEqual(meta.find((m) => m.name === 'chargeCard')?.audit, {
|
|
51
|
+
durability: 'transactional',
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('an object without a durability defaults to best-effort', async () => {
|
|
56
|
+
const meta = await metaFor(func('refund', 'audit: {},'))
|
|
57
|
+
assert.deepEqual(meta.find((m) => m.name === 'refund')?.audit, {
|
|
58
|
+
durability: 'best-effort',
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Absent rather than `false`: an unmarked function drops `auditLog.write()`
|
|
63
|
+
// with a warning, so "no audit key" has to be the honest reading of it.
|
|
64
|
+
test('a function with no audit config carries no audit key', async () => {
|
|
65
|
+
const meta = await metaFor(func('listInvoices', null))
|
|
66
|
+
const found = meta.find((m) => m.name === 'listInvoices')
|
|
67
|
+
assert.ok(found, 'the function should still be registered')
|
|
68
|
+
assert.equal(found.audit, undefined)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('`audit: false` is the same as not declaring it', async () => {
|
|
72
|
+
const meta = await metaFor(func('healthCheck', 'audit: false,'))
|
|
73
|
+
assert.equal(meta.find((m) => m.name === 'healthCheck')?.audit, undefined)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -36,17 +36,17 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
|
36
36
|
* downstream, which is exactly how `displayName` went unused.
|
|
37
37
|
*/
|
|
38
38
|
describe('config value metadata (docsUrl)', () => {
|
|
39
|
-
test('carries docsUrl from
|
|
39
|
+
test('carries docsUrl from defineSecret into the definition', async () => {
|
|
40
40
|
const rootDir = await mkdtemp(`${fixtureRoot}-secret-`)
|
|
41
41
|
const file = join(rootDir, 'secret.ts')
|
|
42
42
|
|
|
43
43
|
await writeFile(
|
|
44
44
|
file,
|
|
45
45
|
[
|
|
46
|
-
"import {
|
|
46
|
+
"import { defineSecret } from '@pikku/core'",
|
|
47
47
|
"import { z } from 'zod'",
|
|
48
48
|
'export const StripeKeySchema = z.string()',
|
|
49
|
-
'
|
|
49
|
+
'defineSecret({',
|
|
50
50
|
" name: 'stripeKey',",
|
|
51
51
|
" displayName: 'Stripe Secret Key',",
|
|
52
52
|
" secretId: 'STRIPE_SECRET_KEY',",
|
|
@@ -76,10 +76,10 @@ describe('config value metadata (docsUrl)', () => {
|
|
|
76
76
|
await writeFile(
|
|
77
77
|
file,
|
|
78
78
|
[
|
|
79
|
-
"import {
|
|
79
|
+
"import { defineSecret } from '@pikku/core'",
|
|
80
80
|
"import { z } from 'zod'",
|
|
81
81
|
'export const ApiKeySchema = z.string()',
|
|
82
|
-
'
|
|
82
|
+
'defineSecret({',
|
|
83
83
|
" name: 'apiKey',",
|
|
84
84
|
" displayName: 'API Key',",
|
|
85
85
|
" secretId: 'API_KEY',",
|
|
@@ -101,17 +101,17 @@ describe('config value metadata (docsUrl)', () => {
|
|
|
101
101
|
}
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
test('carries docsUrl from
|
|
104
|
+
test('carries docsUrl from defineVariable into the definition', async () => {
|
|
105
105
|
const rootDir = await mkdtemp(`${fixtureRoot}-variable-`)
|
|
106
106
|
const file = join(rootDir, 'variable.ts')
|
|
107
107
|
|
|
108
108
|
await writeFile(
|
|
109
109
|
file,
|
|
110
110
|
[
|
|
111
|
-
"import {
|
|
111
|
+
"import { defineVariable } from '@pikku/core'",
|
|
112
112
|
"import { z } from 'zod'",
|
|
113
113
|
'export const ConsoleUrlSchema = z.string()',
|
|
114
|
-
'
|
|
114
|
+
'defineVariable({',
|
|
115
115
|
" name: 'consoleUrl',",
|
|
116
116
|
" displayName: 'Console URL',",
|
|
117
117
|
" variableId: 'CONSOLE_URL',",
|
|
@@ -134,17 +134,17 @@ describe('config value metadata (docsUrl)', () => {
|
|
|
134
134
|
}
|
|
135
135
|
})
|
|
136
136
|
|
|
137
|
-
test('carries docsUrl from
|
|
137
|
+
test('carries docsUrl from defineCredential into the definition', async () => {
|
|
138
138
|
const rootDir = await mkdtemp(`${fixtureRoot}-credential-`)
|
|
139
139
|
const file = join(rootDir, 'credential.ts')
|
|
140
140
|
|
|
141
141
|
await writeFile(
|
|
142
142
|
file,
|
|
143
143
|
[
|
|
144
|
-
"import {
|
|
144
|
+
"import { defineCredential } from '@pikku/core'",
|
|
145
145
|
"import { z } from 'zod'",
|
|
146
146
|
'export const TokenSchema = z.object({ token: z.string() })',
|
|
147
|
-
'
|
|
147
|
+
'defineCredential({',
|
|
148
148
|
" name: 'githubToken',",
|
|
149
149
|
" displayName: 'GitHub Token',",
|
|
150
150
|
" type: 'wire',",
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
function makeLogger() {
|
|
11
|
+
const diagnostics: Array<{ code: ErrorCode; message: string }> = []
|
|
12
|
+
const logger: InspectorLogger = {
|
|
13
|
+
debug: () => {},
|
|
14
|
+
info: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
diagnostic: ({ code, message }) => diagnostics.push({ code, message }),
|
|
18
|
+
critical: (code, message) => diagnostics.push({ code, message }),
|
|
19
|
+
hasCriticalErrors: () => diagnostics.length > 0,
|
|
20
|
+
}
|
|
21
|
+
return { logger, diagnostics }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Stands in for @pikku/core, which is not importable from a tmp dir. The shapes
|
|
26
|
+
* are what matters: `Secret<T>` is the optional brand `.reveal()` returns, and
|
|
27
|
+
* the service interfaces are matched by name, so these must keep the real names.
|
|
28
|
+
*/
|
|
29
|
+
const PRELUDE = `
|
|
30
|
+
type Secret<T> = T & { readonly __classification__?: 'secret' }
|
|
31
|
+
|
|
32
|
+
declare class SecretValue<T = string> {
|
|
33
|
+
private brand: T
|
|
34
|
+
reveal(): Secret<T>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Logger {
|
|
38
|
+
info(messageOrObj: any, ...meta: any[]): void
|
|
39
|
+
error(messageOrObj: any, ...meta: any[]): void
|
|
40
|
+
}
|
|
41
|
+
interface QueueService {
|
|
42
|
+
add<T>(queueName: string, data: T): Promise<string>
|
|
43
|
+
}
|
|
44
|
+
interface EmailService {
|
|
45
|
+
send(input: any): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
interface WebhookService {
|
|
48
|
+
send(input: any): Promise<void>
|
|
49
|
+
}
|
|
50
|
+
interface AuditService {
|
|
51
|
+
audit(event: { type: string; input?: unknown }): Promise<void>
|
|
52
|
+
}
|
|
53
|
+
interface AuditLog {
|
|
54
|
+
write(event: { type: string; input?: unknown }): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Services = {
|
|
58
|
+
logger: Logger
|
|
59
|
+
queueService: QueueService
|
|
60
|
+
email: EmailService
|
|
61
|
+
webhooks: WebhookService
|
|
62
|
+
audit?: AuditService
|
|
63
|
+
auditLog?: AuditLog
|
|
64
|
+
secrets: { getSecret(key: string): Promise<SecretValue<string>> }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
declare const pikkuFunc: <In, Out>(cfg: {
|
|
68
|
+
func: (services: Services, data: In) => Promise<Out>
|
|
69
|
+
}) => unknown
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
async function runInspect(source: string) {
|
|
73
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-secret-sink-test-'))
|
|
74
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
75
|
+
await writeFile(file, PRELUDE + source)
|
|
76
|
+
const { logger, diagnostics } = makeLogger()
|
|
77
|
+
try {
|
|
78
|
+
await inspect(logger, [file], { rootDir: tmpDir, classificationCheck: true })
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
81
|
+
}
|
|
82
|
+
return diagnostics
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sinkErrors = (diagnostics: Array<{ code: ErrorCode; message: string }>) =>
|
|
86
|
+
diagnostics.filter((d) => d.code === ErrorCode.SECRET_REVEALED_INTO_SINK)
|
|
87
|
+
|
|
88
|
+
describe('PKU953 — a revealed secret reaching a sink', () => {
|
|
89
|
+
test('flags a secret logged inline', async () => {
|
|
90
|
+
const diagnostics = await runInspect(`
|
|
91
|
+
export const leak = pikkuFunc<void, void>({
|
|
92
|
+
func: async ({ logger, secrets }) => {
|
|
93
|
+
const token = await secrets.getSecret('API_KEY')
|
|
94
|
+
logger.info(token.reveal())
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
`)
|
|
98
|
+
const errors = sinkErrors(diagnostics)
|
|
99
|
+
assert.equal(errors.length, 1)
|
|
100
|
+
assert.match(errors[0]!.message, /logger\.info/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('flags a secret that reaches the sink through a local binding', async () => {
|
|
104
|
+
const diagnostics = await runInspect(`
|
|
105
|
+
export const leak = pikkuFunc<void, void>({
|
|
106
|
+
func: async ({ logger, secrets }) => {
|
|
107
|
+
const token = (await secrets.getSecret('API_KEY')).reveal()
|
|
108
|
+
logger.error('auth failed', { token })
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
`)
|
|
112
|
+
const errors = sinkErrors(diagnostics)
|
|
113
|
+
assert.equal(errors.length, 1)
|
|
114
|
+
assert.match(errors[0]!.message, /logger\.error/)
|
|
115
|
+
assert.match(errors[0]!.message, /token/)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('flags a secret enqueued, emailed or sent to a webhook', async () => {
|
|
119
|
+
const diagnostics = await runInspect(`
|
|
120
|
+
export const leak = pikkuFunc<void, void>({
|
|
121
|
+
func: async ({ queueService, email, webhooks, secrets }) => {
|
|
122
|
+
const token = (await secrets.getSecret('API_KEY')).reveal()
|
|
123
|
+
await queueService.add('jobs', { token })
|
|
124
|
+
await email.send({ to: 'a@b.c', text: token })
|
|
125
|
+
await webhooks.send({ url: 'https://x', data: { token } })
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
`)
|
|
129
|
+
assert.equal(sinkErrors(diagnostics).length, 3)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('flags a secret written to an audit, through the optional chain it is reached by', async () => {
|
|
133
|
+
const diagnostics = await runInspect(`
|
|
134
|
+
export const leak = pikkuFunc<void, void>({
|
|
135
|
+
func: async ({ audit, auditLog, secrets }) => {
|
|
136
|
+
const token = (await secrets.getSecret('API_KEY')).reveal()
|
|
137
|
+
await audit?.audit({ type: 'used', input: { token } })
|
|
138
|
+
await auditLog?.write({ type: 'used', input: token })
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
`)
|
|
142
|
+
const errors = sinkErrors(diagnostics)
|
|
143
|
+
assert.equal(errors.length, 2)
|
|
144
|
+
assert.match(errors[0]!.message, /audit\.audit/)
|
|
145
|
+
assert.match(errors[1]!.message, /auditLog\.write/)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('flags console, which no type guard covers', async () => {
|
|
149
|
+
const diagnostics = await runInspect(`
|
|
150
|
+
export const leak = pikkuFunc<void, void>({
|
|
151
|
+
func: async ({ secrets }) => {
|
|
152
|
+
const token = await secrets.getSecret('API_KEY')
|
|
153
|
+
console.log(token.reveal())
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
`)
|
|
157
|
+
const errors = sinkErrors(diagnostics)
|
|
158
|
+
assert.equal(errors.length, 1)
|
|
159
|
+
assert.match(errors[0]!.message, /console\.log/)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('stays quiet when the secret never reaches a sink', async () => {
|
|
163
|
+
const diagnostics = await runInspect(`
|
|
164
|
+
declare function authenticate(token: string): Promise<void>
|
|
165
|
+
|
|
166
|
+
export const fine = pikkuFunc<void, void>({
|
|
167
|
+
func: async ({ logger, secrets }) => {
|
|
168
|
+
const token = (await secrets.getSecret('API_KEY')).reveal()
|
|
169
|
+
await authenticate(token)
|
|
170
|
+
logger.info('authenticated')
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
`)
|
|
174
|
+
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('stays quiet for ordinary logging', async () => {
|
|
178
|
+
const diagnostics = await runInspect(`
|
|
179
|
+
export const fine = pikkuFunc<{ id: string }, void>({
|
|
180
|
+
func: async ({ logger }, data) => {
|
|
181
|
+
logger.info('processing', { id: data.id, at: new Date() })
|
|
182
|
+
},
|
|
183
|
+
})
|
|
184
|
+
`)
|
|
185
|
+
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('does not run without the classification flag', async () => {
|
|
189
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-secret-sink-off-'))
|
|
190
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
191
|
+
await writeFile(
|
|
192
|
+
file,
|
|
193
|
+
PRELUDE +
|
|
194
|
+
`
|
|
195
|
+
export const leak = pikkuFunc<void, void>({
|
|
196
|
+
func: async ({ logger, secrets }) => {
|
|
197
|
+
const token = await secrets.getSecret('API_KEY')
|
|
198
|
+
logger.info(token.reveal())
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
`
|
|
202
|
+
)
|
|
203
|
+
const { logger, diagnostics } = makeLogger()
|
|
204
|
+
try {
|
|
205
|
+
await inspect(logger, [file], { rootDir: tmpDir })
|
|
206
|
+
} finally {
|
|
207
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
208
|
+
}
|
|
209
|
+
assert.equal(sinkErrors(diagnostics).length, 0)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
@@ -62,9 +62,9 @@ describe('wiring name must be a string literal', () => {
|
|
|
62
62
|
await writeFile(
|
|
63
63
|
file,
|
|
64
64
|
[
|
|
65
|
-
"import {
|
|
65
|
+
"import { defineSecret } from '@pikku/core'",
|
|
66
66
|
'const SECRET_ID = "STRIPE_SECRET_KEY"',
|
|
67
|
-
'
|
|
67
|
+
'defineSecret({',
|
|
68
68
|
' secretId: SECRET_ID,',
|
|
69
69
|
" name: 'Stripe secret key',",
|
|
70
70
|
" displayName: 'Stripe secret key',",
|
package/src/error-codes.ts
CHANGED
|
@@ -25,9 +25,11 @@ export enum ErrorCode {
|
|
|
25
25
|
SCENARIO_BROWSER_STEP_NEEDS_ACTOR = 'PKU677',
|
|
26
26
|
SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
|
|
27
27
|
SCENARIO_NOT_EXTRACTABLE = 'PKU679',
|
|
28
|
+
SCENARIO_HAS_NO_ASSERTION = 'PKU680',
|
|
28
29
|
WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
|
|
29
30
|
INVALID_DSL_WORKFLOW = 'PKU641',
|
|
30
31
|
WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
|
|
32
|
+
COMPLEX_WORKFLOW_NOT_ALLOWED = 'PKU643',
|
|
31
33
|
|
|
32
34
|
// Remote addon (wireRemoteAddon) validation
|
|
33
35
|
REMOTE_ADDON_NOT_DEV_DEPENDENCY = 'PKU338',
|
|
@@ -37,6 +39,9 @@ export enum ErrorCode {
|
|
|
37
39
|
DB_COLUMN_NAME_TYPE_CONTRADICTION = 'PKU480',
|
|
38
40
|
DB_JSON_COLUMN_UNTYPED = 'PKU481',
|
|
39
41
|
DB_FORMAT_ON_NON_STRING = 'PKU482',
|
|
42
|
+
DB_SECRET_COLUMN_STORED_PLAIN = 'PKU483',
|
|
43
|
+
DB_FORM_ON_NON_STRING = 'PKU484',
|
|
44
|
+
DB_DEFAULT_SCHEMA_NAME_COLLISION = 'PKU485',
|
|
40
45
|
|
|
41
46
|
// Configuration errors
|
|
42
47
|
SCHEMA_GENERATION_ERROR = 'PKU456',
|
|
@@ -49,10 +54,20 @@ export enum ErrorCode {
|
|
|
49
54
|
DUPLICATE_AUTH_DEFINITION = 'PKU581',
|
|
50
55
|
AUTH_NOT_EXPORTED = 'PKU582',
|
|
51
56
|
|
|
57
|
+
// Single-declaration constructs — one call site per codebase.
|
|
58
|
+
// PKU583 (defineScope) and PKU584 (defineSystemRole) are retired, not free:
|
|
59
|
+
// scopes and roles held this rule until the generated `admin` tree made it
|
|
60
|
+
// unsatisfiable, and get it back once `admin` is a default scope nobody
|
|
61
|
+
// declares. Do not reuse those two numbers for anything else.
|
|
62
|
+
DUPLICATE_PERSONAS_DEFINITION = 'PKU585',
|
|
63
|
+
|
|
52
64
|
// HTTP Route errors
|
|
53
65
|
ROUTE_PARAM_MISMATCH = 'PKU571',
|
|
54
66
|
ROUTE_QUERY_MISMATCH = 'PKU572',
|
|
55
67
|
AUTH_DISABLED_REQUIRES_SESSIONLESS = 'PKU573',
|
|
68
|
+
EXPOSED_FUNCTION_HAS_NO_GATE = 'PKU574',
|
|
69
|
+
TAG_RESOLVES_TO_NO_MIDDLEWARE = 'PKU575',
|
|
70
|
+
PERMISSIONS_IN_BODY_NOT_ALLOWED = 'PKU576',
|
|
56
71
|
|
|
57
72
|
// Versioning errors
|
|
58
73
|
DUPLICATE_FUNCTION_VERSION = 'PKU850',
|
|
@@ -70,6 +85,11 @@ export enum ErrorCode {
|
|
|
70
85
|
MISSING_MODEL = 'PKU145',
|
|
71
86
|
INVALID_MODEL = 'PKU146',
|
|
72
87
|
|
|
88
|
+
// Agent tool reference errors
|
|
89
|
+
AGENT_TOOL_UNKNOWN_NAMESPACE = 'PKU152',
|
|
90
|
+
AGENT_TOOL_NOT_FOUND = 'PKU153',
|
|
91
|
+
AGENT_TOOL_MISSING_DESCRIPTION = 'PKU154',
|
|
92
|
+
|
|
73
93
|
// File structure errors
|
|
74
94
|
SCHEMA_AND_WIRING_COLOCATED = 'PKU490',
|
|
75
95
|
|
|
@@ -90,6 +110,12 @@ export enum ErrorCode {
|
|
|
90
110
|
ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = 'PKU921',
|
|
91
111
|
|
|
92
112
|
RPC_INVOCATION_TYPE_CAST = 'PKU940',
|
|
113
|
+
|
|
114
|
+
// Secret boundary errors
|
|
115
|
+
SECRET_SERVICE_ALIASED = 'PKU950',
|
|
116
|
+
SECRET_NOT_DECLARED = 'PKU951',
|
|
117
|
+
SECRET_KEY_NOT_STATIC = 'PKU952',
|
|
118
|
+
SECRET_REVEALED_INTO_SINK = 'PKU953',
|
|
93
119
|
}
|
|
94
120
|
|
|
95
121
|
/**
|
package/src/index.ts
CHANGED
|
@@ -32,7 +32,10 @@ export type {
|
|
|
32
32
|
VersionManifest,
|
|
33
33
|
VersionManifestEntry,
|
|
34
34
|
} from './utils/contract-hashes.js'
|
|
35
|
-
export type {
|
|
35
|
+
export type {
|
|
36
|
+
InspectorFeature,
|
|
37
|
+
InspectorFeatureEntry,
|
|
38
|
+
} from './types.js'
|
|
36
39
|
export { serializeMCPJson } from './utils/serialize-mcp-json.js'
|
|
37
40
|
export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
|
|
38
41
|
export {
|