@pikku/inspector 0.12.52 → 0.12.55
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 +341 -0
- package/dist/add/add-functions.js +46 -4
- package/dist/add/add-keyed-wiring.js +3 -1
- package/dist/add/add-personas.js +7 -1
- package/dist/add/add-workflow.js +12 -1
- package/dist/error-codes.d.ts +8 -1
- package/dist/error-codes.js +12 -0
- package/dist/types.d.ts +9 -0
- package/dist/utils/check-secret-sinks.d.ts +31 -0
- package/dist/utils/check-secret-sinks.js +117 -0
- package/dist/utils/post-process.js +4 -1
- package/dist/utils/single-declaration.d.ts +32 -0
- package/dist/utils/single-declaration.js +43 -0
- package/dist/utils/validate-exposed-functions-gated.js +15 -7
- 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/package.json +3 -3
- package/src/add/add-functions.ts +57 -5
- package/src/add/add-keyed-wiring.ts +3 -0
- package/src/add/add-personas.test.ts +115 -1
- package/src/add/add-personas.ts +18 -9
- package/src/add/add-scenario-step.test.ts +38 -2
- package/src/add/add-scope.test.ts +73 -0
- package/src/add/add-system-role.test.ts +83 -0
- package/src/add/add-workflow.ts +21 -1
- package/src/add/allow-escape-hatches.test.ts +144 -0
- package/src/add/audit-meta.test.ts +75 -0
- package/src/add/secret-sink-check.test.ts +211 -0
- package/src/error-codes.ts +13 -0
- package/src/types.ts +9 -0
- package/src/utils/check-secret-sinks.ts +145 -0
- package/src/utils/post-process.ts +4 -1
- package/src/utils/single-declaration.ts +57 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +2 -2
- package/src/utils/validate-exposed-functions-gated.ts +16 -7
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +57 -0
- package/src/utils/workflow/dsl/patterns.ts +33 -4
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|
package/src/error-codes.ts
CHANGED
|
@@ -29,6 +29,7 @@ export enum ErrorCode {
|
|
|
29
29
|
WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
|
|
30
30
|
INVALID_DSL_WORKFLOW = 'PKU641',
|
|
31
31
|
WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
|
|
32
|
+
COMPLEX_WORKFLOW_NOT_ALLOWED = 'PKU643',
|
|
32
33
|
|
|
33
34
|
// Remote addon (wireRemoteAddon) validation
|
|
34
35
|
REMOTE_ADDON_NOT_DEV_DEPENDENCY = 'PKU338',
|
|
@@ -38,6 +39,9 @@ export enum ErrorCode {
|
|
|
38
39
|
DB_COLUMN_NAME_TYPE_CONTRADICTION = 'PKU480',
|
|
39
40
|
DB_JSON_COLUMN_UNTYPED = 'PKU481',
|
|
40
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',
|
|
41
45
|
|
|
42
46
|
// Configuration errors
|
|
43
47
|
SCHEMA_GENERATION_ERROR = 'PKU456',
|
|
@@ -50,12 +54,20 @@ export enum ErrorCode {
|
|
|
50
54
|
DUPLICATE_AUTH_DEFINITION = 'PKU581',
|
|
51
55
|
AUTH_NOT_EXPORTED = 'PKU582',
|
|
52
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
|
+
|
|
53
64
|
// HTTP Route errors
|
|
54
65
|
ROUTE_PARAM_MISMATCH = 'PKU571',
|
|
55
66
|
ROUTE_QUERY_MISMATCH = 'PKU572',
|
|
56
67
|
AUTH_DISABLED_REQUIRES_SESSIONLESS = 'PKU573',
|
|
57
68
|
EXPOSED_FUNCTION_HAS_NO_GATE = 'PKU574',
|
|
58
69
|
TAG_RESOLVES_TO_NO_MIDDLEWARE = 'PKU575',
|
|
70
|
+
PERMISSIONS_IN_BODY_NOT_ALLOWED = 'PKU576',
|
|
59
71
|
|
|
60
72
|
// Versioning errors
|
|
61
73
|
DUPLICATE_FUNCTION_VERSION = 'PKU850',
|
|
@@ -103,6 +115,7 @@ export enum ErrorCode {
|
|
|
103
115
|
SECRET_SERVICE_ALIASED = 'PKU950',
|
|
104
116
|
SECRET_NOT_DECLARED = 'PKU951',
|
|
105
117
|
SECRET_KEY_NOT_STATIC = 'PKU952',
|
|
118
|
+
SECRET_REVEALED_INTO_SINK = 'PKU953',
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
/**
|
package/src/types.ts
CHANGED
|
@@ -335,6 +335,15 @@ export type InspectorOptions = Partial<{
|
|
|
335
335
|
* function, which is expensive. Enabled via `pikku all --security`.
|
|
336
336
|
*/
|
|
337
337
|
classificationCheck: boolean
|
|
338
|
+
/**
|
|
339
|
+
* Escape hatches the project has opted into, from `allow` in
|
|
340
|
+
* `pikku.config.json`. Absent or false means the feature is refused with a
|
|
341
|
+
* critical naming the flag that would permit it.
|
|
342
|
+
*/
|
|
343
|
+
allow: Partial<{
|
|
344
|
+
permissionsInBody: boolean
|
|
345
|
+
complexWorkflows: boolean
|
|
346
|
+
}>
|
|
338
347
|
}>
|
|
339
348
|
|
|
340
349
|
export interface InspectorLogger {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import { findPiiPaths } from './check-pii-output.js'
|
|
3
|
+
import { doesTypeExtendsCore } from './does-type-extend-core-type.js'
|
|
4
|
+
|
|
5
|
+
export type SecretSinkViolation = {
|
|
6
|
+
/** How the value reaches the outside world, e.g. `logger.info`. */
|
|
7
|
+
sink: string
|
|
8
|
+
/** Dotted path to the secret inside the argument, or `<argument>` when bare. */
|
|
9
|
+
path: string
|
|
10
|
+
/** Position of the offending argument, for the diagnostic message. */
|
|
11
|
+
line: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A method that writes its arguments somewhere a secret must never land, keyed
|
|
16
|
+
* by the core interface that owns it. `'*'` means every method on the type is a
|
|
17
|
+
* sink, which is true of a logger.
|
|
18
|
+
*/
|
|
19
|
+
const SINKS: Array<{ coreType: string; methods: '*' | string[] }> = [
|
|
20
|
+
{ coreType: 'Logger', methods: '*' },
|
|
21
|
+
{ coreType: 'QueueService', methods: ['add'] },
|
|
22
|
+
{ coreType: 'EmailService', methods: ['send'] },
|
|
23
|
+
{ coreType: 'WebhookService', methods: ['send'] },
|
|
24
|
+
{ coreType: 'AuditService', methods: ['audit', 'write'] },
|
|
25
|
+
{ coreType: 'AuditLog', methods: ['write'] },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Finds revealed vault secrets that flow into a sink.
|
|
30
|
+
*
|
|
31
|
+
* `SecretValue` is nominally typed, so the type system alone stops it reaching
|
|
32
|
+
* any of these. `.reveal()` is the deliberate escape hatch, and it hands back
|
|
33
|
+
* `Secret<T>` — the erasable classification brand. That brand is what this scan
|
|
34
|
+
* follows, which means it catches the revealed value both inline
|
|
35
|
+
* (`logger.info(s.reveal())`) and through a local binding, because the local's
|
|
36
|
+
* inferred type carries the brand too.
|
|
37
|
+
*
|
|
38
|
+
* The brand is optional by design, so an explicit annotation (`const t: string =
|
|
39
|
+
* s.reveal()`) erases it and this scan goes quiet. That is the documented limit
|
|
40
|
+
* of a structural marker: it catches the mistake, not someone determined to
|
|
41
|
+
* silence it.
|
|
42
|
+
*
|
|
43
|
+
* Sinks are matched on the receiver's resolved core interface, which means the
|
|
44
|
+
* scan needs the services parameter to be typed. Real projects get that from
|
|
45
|
+
* the generated `#pikku` factory; a hand-rolled file that calls the raw core
|
|
46
|
+
* factory without annotating its services leaves the parameter `any`, and an
|
|
47
|
+
* `any` receiver matches nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function findRevealedSecretSinks(
|
|
50
|
+
checker: ts.TypeChecker,
|
|
51
|
+
handler: ts.Node
|
|
52
|
+
): SecretSinkViolation[] {
|
|
53
|
+
const violations: SecretSinkViolation[] = []
|
|
54
|
+
|
|
55
|
+
const visit = (node: ts.Node): void => {
|
|
56
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
57
|
+
const sink = describeSink(checker, node.expression)
|
|
58
|
+
if (sink) {
|
|
59
|
+
for (const arg of node.arguments) {
|
|
60
|
+
const found = findSecretExpression(checker, arg)
|
|
61
|
+
if (found) {
|
|
62
|
+
violations.push({
|
|
63
|
+
sink,
|
|
64
|
+
path: found.getText(),
|
|
65
|
+
line:
|
|
66
|
+
found
|
|
67
|
+
.getSourceFile()
|
|
68
|
+
.getLineAndCharacterOfPosition(found.getStart()).line + 1,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
ts.forEachChild(node, visit)
|
|
75
|
+
}
|
|
76
|
+
visit(handler)
|
|
77
|
+
|
|
78
|
+
return violations
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Finds the first expression inside a sink argument whose own type carries the
|
|
83
|
+
* secret brand, and returns it so the diagnostic can quote it.
|
|
84
|
+
*
|
|
85
|
+
* This walks the argument rather than typing it whole, because an object
|
|
86
|
+
* literal in an argument position is contextually typed by the parameter — for
|
|
87
|
+
* a guarded sink that parameter is `Safe<M>`, and resolving the literal against
|
|
88
|
+
* it drops the optional brand before it can be read. Each inner expression
|
|
89
|
+
* (`token.reveal()`, or a reference to a local holding it) carries its own type
|
|
90
|
+
* regardless of the surrounding context, so reading those is what actually
|
|
91
|
+
* sees the secret.
|
|
92
|
+
*/
|
|
93
|
+
function findSecretExpression(
|
|
94
|
+
checker: ts.TypeChecker,
|
|
95
|
+
node: ts.Node
|
|
96
|
+
): ts.Expression | undefined {
|
|
97
|
+
// Descend first, so an object literal wrapping the secret reports the
|
|
98
|
+
// expression that actually holds it (`token.reveal()`) rather than the whole
|
|
99
|
+
// literal. Nothing inside a `SecretValue` receiver is itself branded, so a
|
|
100
|
+
// bare `token.reveal()` still falls through to the self-check below.
|
|
101
|
+
for (const child of node.getChildren()) {
|
|
102
|
+
// A property name is not a value, and typing it re-resolves the property.
|
|
103
|
+
if (ts.isPropertyAssignment(node) && child === node.name) continue
|
|
104
|
+
const found = findSecretExpression(checker, child)
|
|
105
|
+
if (found) return found
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
|
|
109
|
+
const type = checker.getTypeAtLocation(node)
|
|
110
|
+
const isSecret = findPiiPaths(checker, type).some(
|
|
111
|
+
(f) => f.classification === 'secret'
|
|
112
|
+
)
|
|
113
|
+
if (isSecret) return node
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Names the sink a call writes to, or undefined when the call is not one.
|
|
121
|
+
* `console` is matched by identifier because it is a global with no Pikku type;
|
|
122
|
+
* everything else is matched on its resolved core interface so a renamed
|
|
123
|
+
* service or a decorator still counts.
|
|
124
|
+
*/
|
|
125
|
+
function describeSink(
|
|
126
|
+
checker: ts.TypeChecker,
|
|
127
|
+
callee: ts.PropertyAccessExpression
|
|
128
|
+
): string | undefined {
|
|
129
|
+
const method = callee.name.text
|
|
130
|
+
const receiver = callee.expression
|
|
131
|
+
|
|
132
|
+
if (ts.isIdentifier(receiver) && receiver.text === 'console') {
|
|
133
|
+
return `console.${method}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const receiverType = checker.getTypeAtLocation(receiver)
|
|
137
|
+
for (const { coreType, methods } of SINKS) {
|
|
138
|
+
if (methods !== '*' && !methods.includes(method)) continue
|
|
139
|
+
if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
|
|
140
|
+
return `${receiver.getText()}.${method}`
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
@@ -817,7 +817,10 @@ export function validateScenarioSteps(
|
|
|
817
817
|
// nothing threw. It is also invisible to witness coverage — it contributes
|
|
818
818
|
// 0/0, so it can never lower the number — which makes an assertion-free
|
|
819
819
|
// flow the cheapest way to make a suite look better covered than it is.
|
|
820
|
-
|
|
820
|
+
// `asserts` is the escape for a flow whose witness is an expectation helper
|
|
821
|
+
// instead: those are inline steps and leave no phase behind, but a recorded
|
|
822
|
+
// service call or a refusal is an assertion all the same.
|
|
823
|
+
if (phasesSeen.size > 0 && !phasesSeen.has('then') && !meta.asserts) {
|
|
821
824
|
logger.critical(
|
|
822
825
|
ErrorCode.SCENARIO_HAS_NO_ASSERTION,
|
|
823
826
|
`Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ErrorCode } from '../error-codes.js'
|
|
2
|
+
import type { InspectorLogger } from '../types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Claims the one call site a single-declaration construct is allowed.
|
|
6
|
+
*
|
|
7
|
+
* `definePersonas` takes a keyed object, so one call already declares as many
|
|
8
|
+
* entries as you like. Letting the call itself repeat buys nothing and costs
|
|
9
|
+
* the thing that matters: a single place to read the declared set from, and a
|
|
10
|
+
* single place to add to it. The same rule `pikkuBetterAuth` has had all along.
|
|
11
|
+
*
|
|
12
|
+
* A second call in the SAME file is refused too — "the file" is not an answer
|
|
13
|
+
* to "where do I add a persona?" when the file holds two calls, so allowing it
|
|
14
|
+
* would keep the ambiguity the rule exists to remove.
|
|
15
|
+
*
|
|
16
|
+
* A generated file is exempt, and never claims the slot either. The rule exists
|
|
17
|
+
* to name the one place a person adds to, and nobody adds to a file the CLI
|
|
18
|
+
* rewrites on the next run — `pikku-personas.gen.ts` would otherwise make every
|
|
19
|
+
* project that scaffolds one unbuildable, and take the app's own declaration
|
|
20
|
+
* down with it.
|
|
21
|
+
*
|
|
22
|
+
* `defineScope` and `defineSystemRole` held this rule too, briefly, and do not
|
|
23
|
+
* until the `admin` tree stops being app-declared. The exemption above is not
|
|
24
|
+
* enough for them: the CLI generates a `defineScope` in `user-admin.gen.ts` and
|
|
25
|
+
* `@pikku/addon-console` spells the same tree out again in a hand-written file,
|
|
26
|
+
* so two non-generated declarations remain and the build still fails. The fix
|
|
27
|
+
* is for `admin` to be a default scope nobody declares, and the rule comes back
|
|
28
|
+
* for scopes and roles once that lands.
|
|
29
|
+
*
|
|
30
|
+
* @param files - The construct's `files` set, which doubles as the claim.
|
|
31
|
+
* @returns true when the caller holds the claim and should carry on.
|
|
32
|
+
*/
|
|
33
|
+
export const claimSingleDeclaration = (
|
|
34
|
+
logger: InspectorLogger,
|
|
35
|
+
files: Set<string>,
|
|
36
|
+
code: ErrorCode,
|
|
37
|
+
helper: string,
|
|
38
|
+
sourceFile: string
|
|
39
|
+
): boolean => {
|
|
40
|
+
if (sourceFile.endsWith('.gen.ts')) {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [first] = files
|
|
45
|
+
if (first === undefined) {
|
|
46
|
+
files.add(sourceFile)
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
logger.critical(
|
|
51
|
+
code,
|
|
52
|
+
first === sourceFile
|
|
53
|
+
? `Only one ${helper}(...) is allowed per codebase, so there is one place to read the declarations from and one place to add to. Found a second call in ${sourceFile}. Declare them all in one call.`
|
|
54
|
+
: `Only one ${helper}(...) is allowed per codebase, so there is one place to read the declarations from and one place to add to. Found a second in ${sourceFile} (first: ${first}). Declare them all in one call.`
|
|
55
|
+
)
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
@@ -108,7 +108,7 @@ describe('validateExposedFunctionsGated', () => {
|
|
|
108
108
|
// nobody reads.
|
|
109
109
|
run(
|
|
110
110
|
stateWith({
|
|
111
|
-
stripeWebhook: ungatedFunction({
|
|
111
|
+
stripeWebhook: ungatedFunction({ permissionsInBody: true }),
|
|
112
112
|
})
|
|
113
113
|
)
|
|
114
114
|
|
|
@@ -118,7 +118,7 @@ describe('validateExposedFunctionsGated', () => {
|
|
|
118
118
|
test('still warns when the declaration is explicitly false', () => {
|
|
119
119
|
run(
|
|
120
120
|
stateWith({
|
|
121
|
-
credentialGet: ungatedFunction({
|
|
121
|
+
credentialGet: ungatedFunction({ permissionsInBody: false }),
|
|
122
122
|
})
|
|
123
123
|
)
|
|
124
124
|
|
|
@@ -32,7 +32,7 @@ export function validateExposedFunctionsGated(
|
|
|
32
32
|
// this check has no way to see. Taking the claim at face value is the
|
|
33
33
|
// point: without it the warning fires forever on functions that are fine,
|
|
34
34
|
// and a warning that is usually wrong stops being read.
|
|
35
|
-
if (meta.
|
|
35
|
+
if (meta.permissionsInBody === true) continue
|
|
36
36
|
|
|
37
37
|
// The function gates itself.
|
|
38
38
|
if (meta.auth === true) continue
|
|
@@ -49,18 +49,27 @@ export function validateExposedFunctionsGated(
|
|
|
49
49
|
const shown = ungated.slice(0, 10)
|
|
50
50
|
const rest = ungated.length - shown.length
|
|
51
51
|
|
|
52
|
+
const one = ungated.length === 1
|
|
53
|
+
|
|
54
|
+
// Every function here is sessionless — that is the population, not the
|
|
55
|
+
// finding, so the message names it as given rather than reporting it as a
|
|
56
|
+
// defect. The remedies are the ones that can actually apply to an anonymous
|
|
57
|
+
// caller: `scopes` cannot, because `verifyScopes` fails closed on a session
|
|
58
|
+
// that does not exist, and telling someone to add them sends them to a
|
|
59
|
+
// MissingScopeError on every public request.
|
|
52
60
|
logger.diagnostic({
|
|
53
61
|
severity: 'warn',
|
|
54
62
|
code: ErrorCode.EXPOSED_FUNCTION_HAS_NO_GATE,
|
|
55
63
|
message:
|
|
56
|
-
`${ungated.length} exposed function${
|
|
57
|
-
`
|
|
58
|
-
`${
|
|
64
|
+
`${ungated.length} exposed sessionless function${one ? '' : 's'} ` +
|
|
65
|
+
`carr${one ? 'ies' : 'y'} no permission and no addon gate, so ` +
|
|
66
|
+
`${one ? 'it is' : 'they are'} reachable by anyone through ` +
|
|
59
67
|
`POST /rpc/:rpcName: ${shown.join(', ')}` +
|
|
60
68
|
(rest > 0 ? `, and ${rest} more` : '') +
|
|
61
|
-
`. Add
|
|
62
|
-
`
|
|
63
|
-
`
|
|
69
|
+
`. Add permissions to the function, set \`auth: true\` if a session is ` +
|
|
70
|
+
`required after all, gate the whole addon with ` +
|
|
71
|
+
`wireAddon({ auth: true }), or drop \`expose: true\` if ` +
|
|
72
|
+
`${one ? 'it was' : 'they were'} not meant to be callable from outside.`,
|
|
64
73
|
})
|
|
65
74
|
}
|
|
66
75
|
|