@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
package/src/inspector.ts
CHANGED
|
@@ -15,22 +15,31 @@ import {
|
|
|
15
15
|
aggregateRequiredServices,
|
|
16
16
|
stampAuthHandlerServices,
|
|
17
17
|
validateAgentModels,
|
|
18
|
+
validateAgentToolReferences,
|
|
18
19
|
validateSecretOverrides,
|
|
19
20
|
validateVariableOverrides,
|
|
20
21
|
validateCredentialOverrides,
|
|
21
22
|
validateRemoteAddonDependencies,
|
|
22
23
|
validateRemoteAddonAuth,
|
|
23
24
|
validateScopeReferences,
|
|
25
|
+
validateSystemRoleScopes,
|
|
26
|
+
validatePersonaRoles,
|
|
24
27
|
computeResolvedIOTypes,
|
|
25
28
|
computeMiddlewareGroupsMeta,
|
|
26
29
|
computePermissionsGroupsMeta,
|
|
27
30
|
computeRequiredSchemas,
|
|
31
|
+
computeSecretBrokers,
|
|
32
|
+
validateNoSecretAliasServices,
|
|
33
|
+
validateSecretUsage,
|
|
28
34
|
computeDiagnostics,
|
|
29
35
|
validateSchemaWiringSeparation,
|
|
30
36
|
validateScenarioServices,
|
|
31
37
|
validateScenarioSteps,
|
|
32
38
|
validateWorkflowGraphAddons,
|
|
33
39
|
} from './utils/post-process.js'
|
|
40
|
+
import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js'
|
|
41
|
+
import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js'
|
|
42
|
+
import { annotateHttpRouteAuth } from './utils/annotate-http-route-auth.js'
|
|
34
43
|
import { generateOpenAPISpec } from './utils/serialize-openapi-json.js'
|
|
35
44
|
import { pikkuState } from '@pikku/core/internal'
|
|
36
45
|
import { resolveLatestVersions } from './utils/resolve-versions.js'
|
|
@@ -40,6 +49,7 @@ import {
|
|
|
40
49
|
finalizeWorkflowWires,
|
|
41
50
|
} from './utils/workflow/graph/finalize-workflow-wires.js'
|
|
42
51
|
import { generateAllSchemas } from './utils/schema-generator.js'
|
|
52
|
+
import { extractSecretUsage } from './utils/extract-secret-usage.js'
|
|
43
53
|
import {
|
|
44
54
|
loadAddonFunctionsMeta,
|
|
45
55
|
loadAddonSchemas,
|
|
@@ -169,6 +179,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
169
179
|
secrets: {
|
|
170
180
|
definitions: [],
|
|
171
181
|
files: new Set(),
|
|
182
|
+
usage: new Map(),
|
|
172
183
|
},
|
|
173
184
|
credentials: {
|
|
174
185
|
definitions: [],
|
|
@@ -178,6 +189,14 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
178
189
|
definitions: [],
|
|
179
190
|
files: new Set(),
|
|
180
191
|
},
|
|
192
|
+
systemRoles: {
|
|
193
|
+
definitions: [],
|
|
194
|
+
files: new Set(),
|
|
195
|
+
},
|
|
196
|
+
personas: {
|
|
197
|
+
definitions: [],
|
|
198
|
+
files: new Set(),
|
|
199
|
+
},
|
|
181
200
|
variables: {
|
|
182
201
|
definitions: [],
|
|
183
202
|
files: new Set(),
|
|
@@ -354,6 +373,13 @@ export const inspect = async (
|
|
|
354
373
|
|
|
355
374
|
const state = getInitialInspectorState(rootDir)
|
|
356
375
|
|
|
376
|
+
for (const sourceFile of sourceFiles) {
|
|
377
|
+
const usage = extractSecretUsage(sourceFile)
|
|
378
|
+
if (usage.keys.length > 0 || usage.dynamic.length > 0) {
|
|
379
|
+
state.secrets.usage.set(sourceFile.fileName, usage)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
357
383
|
// First sweep: add all functions
|
|
358
384
|
const startSetup = performance.now()
|
|
359
385
|
for (const sourceFile of sourceFiles) {
|
|
@@ -451,11 +477,21 @@ export const inspect = async (
|
|
|
451
477
|
computeResolvedIOTypes(state)
|
|
452
478
|
computeMiddlewareGroupsMeta(state)
|
|
453
479
|
computePermissionsGroupsMeta(state)
|
|
480
|
+
computeSecretBrokers(state)
|
|
481
|
+
validateNoSecretAliasServices(logger, checker, state)
|
|
482
|
+
validateSecretUsage(logger, state)
|
|
454
483
|
computeDiagnostics(state)
|
|
455
484
|
validateSchemaWiringSeparation(logger, state)
|
|
456
485
|
validateWorkflowGraphAddons(logger, state)
|
|
457
486
|
validateScenarioServices(logger, state)
|
|
458
487
|
validateScenarioSteps(logger, state)
|
|
488
|
+
// Needs every route, every function and every wireAddon declaration
|
|
489
|
+
// together, so it can only run once the whole program is inspected.
|
|
490
|
+
annotateHttpRouteAuth(state)
|
|
491
|
+
// Last of the validators: they read function meta and every wireAddon
|
|
492
|
+
// declaration together, so they need the whole program inspected.
|
|
493
|
+
validateExposedFunctionsGated(logger, state)
|
|
494
|
+
validateTagsResolveToMiddleware(logger, state)
|
|
459
495
|
|
|
460
496
|
if (options.openAPI) {
|
|
461
497
|
state.openAPISpec = await generateOpenAPISpec(
|
|
@@ -469,12 +505,15 @@ export const inspect = async (
|
|
|
469
505
|
}
|
|
470
506
|
|
|
471
507
|
validateAgentModels(logger, state)
|
|
508
|
+
validateAgentToolReferences(logger, state, options)
|
|
472
509
|
validateSecretOverrides(logger, state)
|
|
473
510
|
validateVariableOverrides(logger, state)
|
|
474
511
|
validateCredentialOverrides(logger, state)
|
|
475
512
|
validateRemoteAddonDependencies(logger, state)
|
|
476
513
|
validateRemoteAddonAuth(logger, state)
|
|
477
514
|
validateScopeReferences(logger, state)
|
|
515
|
+
validateSystemRoleScopes(logger, state)
|
|
516
|
+
validatePersonaRoles(logger, state)
|
|
478
517
|
}
|
|
479
518
|
|
|
480
519
|
state.program = program
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as ts from 'typescript'
|
|
2
|
+
import type { SecretUsage } from './utils/extract-secret-usage.js'
|
|
2
3
|
import type { ChannelMessageMeta, ChannelsMeta } from '@pikku/core/channel'
|
|
3
4
|
import type { GatewaysMeta } from '@pikku/core/gateway'
|
|
4
5
|
import type { HTTPWiringsMeta } from '@pikku/core/http'
|
|
@@ -18,6 +19,8 @@ import type { NodesMeta } from '@pikku/core/node'
|
|
|
18
19
|
import type { SecretDefinitions } from '@pikku/core/secret'
|
|
19
20
|
import type { CredentialDefinitions } from '@pikku/core/credential'
|
|
20
21
|
import type { ScopeDefinitions } from '@pikku/core/scope'
|
|
22
|
+
import type { SystemRoleDefinitions } from '@pikku/core/role'
|
|
23
|
+
import type { PersonaDefinitions } from '@pikku/core/persona'
|
|
21
24
|
import type { VariableDefinitions } from '@pikku/core/variable'
|
|
22
25
|
import type { TypesMap } from './types-map.js'
|
|
23
26
|
import type {
|
|
@@ -48,14 +51,31 @@ export type MetaInputTypes = Map<
|
|
|
48
51
|
}
|
|
49
52
|
>
|
|
50
53
|
|
|
51
|
-
|
|
54
|
+
/** One `addHTTPMiddleware`/`addTagMiddleware`/`addChannelMiddleware` call. */
|
|
55
|
+
export interface MiddlewareRegistration {
|
|
52
56
|
exportName: string | null // null if not exported
|
|
53
57
|
sourceFile: string
|
|
54
58
|
position: number
|
|
59
|
+
isFactory: boolean // true if wrapped in () => add...()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MiddlewareGroupMeta extends MiddlewareRegistration {
|
|
55
63
|
services: FunctionServicesMeta
|
|
56
64
|
count: number
|
|
57
65
|
instanceIds: string[]
|
|
58
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The registrations after the first, when several files add middleware for
|
|
68
|
+
* the same pattern or tag.
|
|
69
|
+
*
|
|
70
|
+
* A group is keyed by its pattern/tag, so without this a second file's call
|
|
71
|
+
* overwrote the first's and only the survivor got an import emitted into
|
|
72
|
+
* pikku-middleware.gen.ts — the losing file was never imported, so its
|
|
73
|
+
* registration never ran. The runtime composes repeated registrations for a
|
|
74
|
+
* pattern happily; only codegen dropped one, which is how an app could lose
|
|
75
|
+
* its session-bridge middleware to an unrelated `'*'` group and see nothing
|
|
76
|
+
* go wrong until a request arrived without a session.
|
|
77
|
+
*/
|
|
78
|
+
additionalRegistrations?: MiddlewareRegistration[]
|
|
59
79
|
}
|
|
60
80
|
|
|
61
81
|
export interface InspectorHTTPState {
|
|
@@ -272,6 +292,12 @@ export type InspectorOptions = Partial<{
|
|
|
272
292
|
setupOnly: boolean
|
|
273
293
|
rootDir: string
|
|
274
294
|
isAddon: boolean
|
|
295
|
+
/**
|
|
296
|
+
* Fail the build on metadata that only an agent needs — currently an agent
|
|
297
|
+
* tool with no description. Off by default: the runtime falls back to the
|
|
298
|
+
* tool's own name, so this is a quality gate rather than a correctness one.
|
|
299
|
+
*/
|
|
300
|
+
strictMeta: boolean
|
|
275
301
|
sourceFile: ts.SourceFile
|
|
276
302
|
/**
|
|
277
303
|
* The project's tsconfig, read for its path mappings only. The inspector's
|
|
@@ -309,6 +335,15 @@ export type InspectorOptions = Partial<{
|
|
|
309
335
|
* function, which is expensive. Enabled via `pikku all --security`.
|
|
310
336
|
*/
|
|
311
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
|
+
}>
|
|
312
347
|
}>
|
|
313
348
|
|
|
314
349
|
export interface InspectorLogger {
|
|
@@ -530,6 +565,15 @@ export interface InspectorState {
|
|
|
530
565
|
package: string
|
|
531
566
|
rpcEndpoint?: string
|
|
532
567
|
mcp?: boolean
|
|
568
|
+
/**
|
|
569
|
+
* The addon's own gates. `runPikkuFunc` applies these to every function
|
|
570
|
+
* in the package, on every wiring path — not just `namespace:function`.
|
|
571
|
+
* Absent when the declared value was not a statically-knowable literal,
|
|
572
|
+
* which must be read as "unknown", never as "ungated".
|
|
573
|
+
*/
|
|
574
|
+
auth?: boolean
|
|
575
|
+
tags?: string[]
|
|
576
|
+
scopes?: string[]
|
|
533
577
|
/** True when declared via `wireRemoteAddon` — import the addon's `.remote.gen` map, not `.internal.gen` */
|
|
534
578
|
remote?: boolean
|
|
535
579
|
/** wireRemoteAddon: whether an `auth` binding was supplied (vs a public surface) */
|
|
@@ -586,6 +630,8 @@ export interface InspectorState {
|
|
|
586
630
|
secrets: {
|
|
587
631
|
definitions: SecretDefinitions
|
|
588
632
|
files: Set<string>
|
|
633
|
+
/** Secret reads found per source file, keyed by absolute path. */
|
|
634
|
+
usage: Map<string, SecretUsage>
|
|
589
635
|
}
|
|
590
636
|
credentials: {
|
|
591
637
|
definitions: CredentialDefinitions
|
|
@@ -595,6 +641,14 @@ export interface InspectorState {
|
|
|
595
641
|
definitions: ScopeDefinitions
|
|
596
642
|
files: Set<string>
|
|
597
643
|
}
|
|
644
|
+
systemRoles: {
|
|
645
|
+
definitions: SystemRoleDefinitions
|
|
646
|
+
files: Set<string>
|
|
647
|
+
}
|
|
648
|
+
personas: {
|
|
649
|
+
definitions: PersonaDefinitions
|
|
650
|
+
files: Set<string>
|
|
651
|
+
}
|
|
598
652
|
variables: {
|
|
599
653
|
definitions: VariableDefinitions
|
|
600
654
|
files: Set<string>
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { annotateHttpRouteAuth } from './annotate-http-route-auth.js'
|
|
4
|
+
|
|
5
|
+
const ADDON_PACKAGE = '@addon/console'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A single GET route plus the function it points at, with the four inputs the
|
|
9
|
+
* resolution reads spread across both.
|
|
10
|
+
*/
|
|
11
|
+
const resolve = (
|
|
12
|
+
route: Record<string, unknown>,
|
|
13
|
+
meta: Record<string, unknown> = {},
|
|
14
|
+
addons: Record<string, unknown> = {}
|
|
15
|
+
): boolean | undefined => {
|
|
16
|
+
const state = {
|
|
17
|
+
functions: { meta: { handler: { pikkuFuncId: 'handler', ...meta } } },
|
|
18
|
+
http: {
|
|
19
|
+
meta: {
|
|
20
|
+
get: {
|
|
21
|
+
'/thing': {
|
|
22
|
+
pikkuFuncId: 'handler',
|
|
23
|
+
route: '/thing',
|
|
24
|
+
method: 'get',
|
|
25
|
+
...route,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
rpc: { wireAddonDeclarations: new Map(Object.entries(addons)) },
|
|
31
|
+
} as any
|
|
32
|
+
|
|
33
|
+
annotateHttpRouteAuth(state)
|
|
34
|
+
return state.http.meta.get['/thing'].requiresSession
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('annotateHttpRouteAuth', () => {
|
|
38
|
+
test('a pikkuFunc always requires a session', () => {
|
|
39
|
+
assert.equal(resolve({}, { sessionless: false }), true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('a sessionless function on a plain route does not', () => {
|
|
43
|
+
assert.equal(resolve({}, { sessionless: true }), false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("the function's own auth requires one", () => {
|
|
47
|
+
assert.equal(resolve({}, { sessionless: true, auth: true }), true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("the route's auth requires one, even of a sessionless function", () => {
|
|
51
|
+
assert.equal(resolve({ auth: true }, { sessionless: true }), true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('an explicit auth: false on the route does not undo the function', () => {
|
|
55
|
+
// Layers only ever tighten. A route cannot open a pikkuFunc back up.
|
|
56
|
+
assert.equal(resolve({ auth: false }, { sessionless: false }), true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('function scopes require one', () => {
|
|
60
|
+
// Scopes are matched against the session's and fail closed, so an
|
|
61
|
+
// anonymous caller is rejected — reporting the route as open would be a lie.
|
|
62
|
+
assert.equal(resolve({}, { sessionless: true, scopes: ['admin'] }), true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("the addon's auth requires one", () => {
|
|
66
|
+
assert.equal(
|
|
67
|
+
resolve(
|
|
68
|
+
{ packageName: ADDON_PACKAGE },
|
|
69
|
+
{ sessionless: true },
|
|
70
|
+
{ console: { package: ADDON_PACKAGE, auth: true } }
|
|
71
|
+
),
|
|
72
|
+
true
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("the addon's scopes require one", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
resolve(
|
|
79
|
+
{ packageName: ADDON_PACKAGE },
|
|
80
|
+
{ sessionless: true },
|
|
81
|
+
{ console: { package: ADDON_PACKAGE, scopes: ['admin'] } }
|
|
82
|
+
),
|
|
83
|
+
true
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test("another package's addon gate does not reach this route", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
resolve(
|
|
90
|
+
{ packageName: '@app/api' },
|
|
91
|
+
{ sessionless: true },
|
|
92
|
+
{ console: { package: ADDON_PACKAGE, auth: true } }
|
|
93
|
+
),
|
|
94
|
+
false
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('an ungated addon leaves the route as its function found it', () => {
|
|
99
|
+
assert.equal(
|
|
100
|
+
resolve(
|
|
101
|
+
{ packageName: ADDON_PACKAGE },
|
|
102
|
+
{ sessionless: true },
|
|
103
|
+
{ console: { package: ADDON_PACKAGE } }
|
|
104
|
+
),
|
|
105
|
+
false
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a ref route resolves against its target', () => {
|
|
110
|
+
const state = {
|
|
111
|
+
functions: {
|
|
112
|
+
meta: {
|
|
113
|
+
inline: { pikkuFuncId: 'inline', sessionless: true },
|
|
114
|
+
target: { pikkuFuncId: 'target', sessionless: false },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
http: {
|
|
118
|
+
meta: {
|
|
119
|
+
get: {
|
|
120
|
+
'/thing': {
|
|
121
|
+
pikkuFuncId: 'inline',
|
|
122
|
+
refTarget: 'target',
|
|
123
|
+
route: '/thing',
|
|
124
|
+
method: 'get',
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
rpc: { wireAddonDeclarations: new Map() },
|
|
130
|
+
} as any
|
|
131
|
+
|
|
132
|
+
annotateHttpRouteAuth(state)
|
|
133
|
+
assert.equal(state.http.meta.get['/thing'].requiresSession, true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('a route whose function meta is missing is not reported as open', () => {
|
|
137
|
+
const state = {
|
|
138
|
+
functions: { meta: {} },
|
|
139
|
+
http: {
|
|
140
|
+
meta: {
|
|
141
|
+
get: {
|
|
142
|
+
'/thing': {
|
|
143
|
+
pikkuFuncId: 'gone',
|
|
144
|
+
route: '/thing',
|
|
145
|
+
method: 'get',
|
|
146
|
+
auth: true,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
rpc: { wireAddonDeclarations: new Map() },
|
|
152
|
+
} as any
|
|
153
|
+
|
|
154
|
+
annotateHttpRouteAuth(state)
|
|
155
|
+
assert.equal(state.http.meta.get['/thing'].requiresSession, true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('tolerates http state being absent', () => {
|
|
159
|
+
assert.doesNotThrow(() =>
|
|
160
|
+
annotateHttpRouteAuth({ functions: { meta: {} } } as any)
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { InspectorState } from '../types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve, per HTTP route, whether reaching it requires a session.
|
|
5
|
+
*
|
|
6
|
+
* Four separate places can demand one — the function's `sessionless`, the
|
|
7
|
+
* function's own `auth`, the route's (or its group's) `auth`, and the addon the
|
|
8
|
+
* function belongs to — and they combine with OR: any of them is enough. Until
|
|
9
|
+
* this was written down, "which routes are open?" could only be answered by
|
|
10
|
+
* joining all four by hand, and getting the join wrong reads an open route as a
|
|
11
|
+
* closed one, which is the direction that costs something.
|
|
12
|
+
*
|
|
13
|
+
* A scope gate counts as requiring a session. Scopes are checked against the
|
|
14
|
+
* session's and fail closed, so an anonymous caller is rejected either way; not
|
|
15
|
+
* counting them would mark a route open that nobody can reach anonymously.
|
|
16
|
+
*
|
|
17
|
+
* Anything not statically knowable resolves to requiring a session, matching
|
|
18
|
+
* `validateExposedFunctionsGated`: an over-cautious `true` costs a route that
|
|
19
|
+
* looks stricter than it is, a wrong `false` costs an audit.
|
|
20
|
+
*
|
|
21
|
+
* knowledge: decisions/security/addon-auth-and-tags-only-tighten.md
|
|
22
|
+
*/
|
|
23
|
+
export function annotateHttpRouteAuth(state: InspectorState): void {
|
|
24
|
+
const gatedPackages = packagesGatedByTheirAddon(state)
|
|
25
|
+
|
|
26
|
+
for (const routes of Object.values(state.http?.meta ?? {})) {
|
|
27
|
+
for (const route of Object.values(routes)) {
|
|
28
|
+
const meta = state.functions.meta[route.refTarget ?? route.pikkuFuncId]
|
|
29
|
+
|
|
30
|
+
route.requiresSession =
|
|
31
|
+
// A pikkuFunc always requires a session, on every path.
|
|
32
|
+
meta?.sessionless === false ||
|
|
33
|
+
meta?.auth === true ||
|
|
34
|
+
(meta?.scopes?.length ?? 0) > 0 ||
|
|
35
|
+
route.auth === true ||
|
|
36
|
+
(route.packageName !== undefined &&
|
|
37
|
+
gatedPackages.has(route.packageName))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Packages whose `wireAddon` demands a session from everything inside them. */
|
|
43
|
+
function packagesGatedByTheirAddon(state: InspectorState): Set<string> {
|
|
44
|
+
const gated = new Set<string>()
|
|
45
|
+
for (const declaration of state.rpc?.wireAddonDeclarations?.values() ?? []) {
|
|
46
|
+
if (!declaration.package) continue
|
|
47
|
+
if (declaration.auth === true || (declaration.scopes?.length ?? 0) > 0) {
|
|
48
|
+
gated.add(declaration.package)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return gated
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -46,9 +46,19 @@ export function funcIdToTypeName(id: string): string {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
49
|
+
* The step wrappers, whichever kind of subject acts in them. A scenario calls a
|
|
50
|
+
* platform or addon step by name exactly as it calls a persona one.
|
|
51
|
+
*/
|
|
52
|
+
const SCENARIO_STEP_WRAPPERS = new Set([
|
|
53
|
+
'pikkuScenarioStep',
|
|
54
|
+
'pikkuPlatformScenarioStep',
|
|
55
|
+
'pikkuAddonScenarioStep',
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A scenario step is the one wrapper whose `name` is its identity: scenarios
|
|
60
|
+
* reference a step by that string, so it must become the pikkuFuncId (and hence
|
|
61
|
+
* the generated step map's key), not just a cosmetic label the way
|
|
52
62
|
* `pikkuMiddleware`/`pikkuAgent` use it.
|
|
53
63
|
*/
|
|
54
64
|
function extractScenarioStepName(
|
|
@@ -58,7 +68,7 @@ function extractScenarioStepName(
|
|
|
58
68
|
if (
|
|
59
69
|
!ts.isCallExpression(callExpr) ||
|
|
60
70
|
!ts.isIdentifier(callExpr.expression) ||
|
|
61
|
-
callExpr.expression.text
|
|
71
|
+
!SCENARIO_STEP_WRAPPERS.has(callExpr.expression.text)
|
|
62
72
|
) {
|
|
63
73
|
return
|
|
64
74
|
}
|
|
@@ -4,6 +4,17 @@ import {
|
|
|
4
4
|
resolveFunctionDeclaration,
|
|
5
5
|
} from './type-utils.js'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Where a config object's implementation can live, in the order it is looked for.
|
|
9
|
+
*
|
|
10
|
+
* `func` is the ordinary case. A `pikkuScenarioStep` instead declares one
|
|
11
|
+
* implementation per surface, and every binding shares the step's input and
|
|
12
|
+
* output — that is what makes them interchangeable — so any one of them types
|
|
13
|
+
* the step. `default` is preferred because it is the binding every step is
|
|
14
|
+
* expected to have.
|
|
15
|
+
*/
|
|
16
|
+
const IMPLEMENTATION_KEYS = ['func', 'default', 'browser', 'cli'] as const
|
|
17
|
+
|
|
7
18
|
/**
|
|
8
19
|
* Extracts the actual function node from a pikkuFunc/pikkuWorkflowFunc call
|
|
9
20
|
* Handles both direct function form and config object form { func: ... }
|
|
@@ -22,12 +33,13 @@ export function extractFunctionNode(
|
|
|
22
33
|
// Check if first argument is a config object with 'func' property
|
|
23
34
|
if (ts.isObjectLiteralExpression(firstArg)) {
|
|
24
35
|
isDirectFunction = false
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
let funcInitializer: ts.Expression | undefined
|
|
37
|
+
for (const key of IMPLEMENTATION_KEYS) {
|
|
38
|
+
funcInitializer =
|
|
39
|
+
getPropertyAssignmentInitializer(firstArg, key, true, checker) ??
|
|
40
|
+
undefined
|
|
41
|
+
if (funcInitializer) break
|
|
42
|
+
}
|
|
31
43
|
|
|
32
44
|
if (funcInitializer) {
|
|
33
45
|
funcNode = funcInitializer
|