@pikku/inspector 0.12.52 → 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 +302 -0
- package/dist/add/add-functions.js +46 -4
- 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-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,117 @@
|
|
|
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
|
+
* A method that writes its arguments somewhere a secret must never land, keyed
|
|
6
|
+
* by the core interface that owns it. `'*'` means every method on the type is a
|
|
7
|
+
* sink, which is true of a logger.
|
|
8
|
+
*/
|
|
9
|
+
const SINKS = [
|
|
10
|
+
{ coreType: 'Logger', methods: '*' },
|
|
11
|
+
{ coreType: 'QueueService', methods: ['add'] },
|
|
12
|
+
{ coreType: 'EmailService', methods: ['send'] },
|
|
13
|
+
{ coreType: 'WebhookService', methods: ['send'] },
|
|
14
|
+
{ coreType: 'AuditService', methods: ['audit', 'write'] },
|
|
15
|
+
{ coreType: 'AuditLog', methods: ['write'] },
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Finds revealed vault secrets that flow into a sink.
|
|
19
|
+
*
|
|
20
|
+
* `SecretValue` is nominally typed, so the type system alone stops it reaching
|
|
21
|
+
* any of these. `.reveal()` is the deliberate escape hatch, and it hands back
|
|
22
|
+
* `Secret<T>` — the erasable classification brand. That brand is what this scan
|
|
23
|
+
* follows, which means it catches the revealed value both inline
|
|
24
|
+
* (`logger.info(s.reveal())`) and through a local binding, because the local's
|
|
25
|
+
* inferred type carries the brand too.
|
|
26
|
+
*
|
|
27
|
+
* The brand is optional by design, so an explicit annotation (`const t: string =
|
|
28
|
+
* s.reveal()`) erases it and this scan goes quiet. That is the documented limit
|
|
29
|
+
* of a structural marker: it catches the mistake, not someone determined to
|
|
30
|
+
* silence it.
|
|
31
|
+
*
|
|
32
|
+
* Sinks are matched on the receiver's resolved core interface, which means the
|
|
33
|
+
* scan needs the services parameter to be typed. Real projects get that from
|
|
34
|
+
* the generated `#pikku` factory; a hand-rolled file that calls the raw core
|
|
35
|
+
* factory without annotating its services leaves the parameter `any`, and an
|
|
36
|
+
* `any` receiver matches nothing.
|
|
37
|
+
*/
|
|
38
|
+
export function findRevealedSecretSinks(checker, handler) {
|
|
39
|
+
const violations = [];
|
|
40
|
+
const visit = (node) => {
|
|
41
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
42
|
+
const sink = describeSink(checker, node.expression);
|
|
43
|
+
if (sink) {
|
|
44
|
+
for (const arg of node.arguments) {
|
|
45
|
+
const found = findSecretExpression(checker, arg);
|
|
46
|
+
if (found) {
|
|
47
|
+
violations.push({
|
|
48
|
+
sink,
|
|
49
|
+
path: found.getText(),
|
|
50
|
+
line: found
|
|
51
|
+
.getSourceFile()
|
|
52
|
+
.getLineAndCharacterOfPosition(found.getStart()).line + 1,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
ts.forEachChild(node, visit);
|
|
59
|
+
};
|
|
60
|
+
visit(handler);
|
|
61
|
+
return violations;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Finds the first expression inside a sink argument whose own type carries the
|
|
65
|
+
* secret brand, and returns it so the diagnostic can quote it.
|
|
66
|
+
*
|
|
67
|
+
* This walks the argument rather than typing it whole, because an object
|
|
68
|
+
* literal in an argument position is contextually typed by the parameter — for
|
|
69
|
+
* a guarded sink that parameter is `Safe<M>`, and resolving the literal against
|
|
70
|
+
* it drops the optional brand before it can be read. Each inner expression
|
|
71
|
+
* (`token.reveal()`, or a reference to a local holding it) carries its own type
|
|
72
|
+
* regardless of the surrounding context, so reading those is what actually
|
|
73
|
+
* sees the secret.
|
|
74
|
+
*/
|
|
75
|
+
function findSecretExpression(checker, node) {
|
|
76
|
+
// Descend first, so an object literal wrapping the secret reports the
|
|
77
|
+
// expression that actually holds it (`token.reveal()`) rather than the whole
|
|
78
|
+
// literal. Nothing inside a `SecretValue` receiver is itself branded, so a
|
|
79
|
+
// bare `token.reveal()` still falls through to the self-check below.
|
|
80
|
+
for (const child of node.getChildren()) {
|
|
81
|
+
// A property name is not a value, and typing it re-resolves the property.
|
|
82
|
+
if (ts.isPropertyAssignment(node) && child === node.name)
|
|
83
|
+
continue;
|
|
84
|
+
const found = findSecretExpression(checker, child);
|
|
85
|
+
if (found)
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
if (ts.isExpression(node) && !ts.isStringLiteralLike(node)) {
|
|
89
|
+
const type = checker.getTypeAtLocation(node);
|
|
90
|
+
const isSecret = findPiiPaths(checker, type).some((f) => f.classification === 'secret');
|
|
91
|
+
if (isSecret)
|
|
92
|
+
return node;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Names the sink a call writes to, or undefined when the call is not one.
|
|
98
|
+
* `console` is matched by identifier because it is a global with no Pikku type;
|
|
99
|
+
* everything else is matched on its resolved core interface so a renamed
|
|
100
|
+
* service or a decorator still counts.
|
|
101
|
+
*/
|
|
102
|
+
function describeSink(checker, callee) {
|
|
103
|
+
const method = callee.name.text;
|
|
104
|
+
const receiver = callee.expression;
|
|
105
|
+
if (ts.isIdentifier(receiver) && receiver.text === 'console') {
|
|
106
|
+
return `console.${method}`;
|
|
107
|
+
}
|
|
108
|
+
const receiverType = checker.getTypeAtLocation(receiver);
|
|
109
|
+
for (const { coreType, methods } of SINKS) {
|
|
110
|
+
if (methods !== '*' && !methods.includes(method))
|
|
111
|
+
continue;
|
|
112
|
+
if (doesTypeExtendsCore(receiverType, checker, new Set(), coreType)) {
|
|
113
|
+
return `${receiver.getText()}.${method}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
@@ -659,7 +659,10 @@ export function validateScenarioSteps(logger, state) {
|
|
|
659
659
|
// nothing threw. It is also invisible to witness coverage — it contributes
|
|
660
660
|
// 0/0, so it can never lower the number — which makes an assertion-free
|
|
661
661
|
// flow the cheapest way to make a suite look better covered than it is.
|
|
662
|
-
|
|
662
|
+
// `asserts` is the escape for a flow whose witness is an expectation helper
|
|
663
|
+
// instead: those are inline steps and leave no phase behind, but a recorded
|
|
664
|
+
// service call or a refusal is an assertion all the same.
|
|
665
|
+
if (phasesSeen.size > 0 && !phasesSeen.has('then') && !meta.asserts) {
|
|
663
666
|
logger.critical(ErrorCode.SCENARIO_HAS_NO_ASSERTION, `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
|
|
664
667
|
`Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
|
|
665
668
|
`only proves nothing threw, and contributes nothing to witness coverage.`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ErrorCode } from '../error-codes.js';
|
|
2
|
+
import type { InspectorLogger } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Claims the one call site a single-declaration construct is allowed.
|
|
5
|
+
*
|
|
6
|
+
* `definePersonas` takes a keyed object, so one call already declares as many
|
|
7
|
+
* entries as you like. Letting the call itself repeat buys nothing and costs
|
|
8
|
+
* the thing that matters: a single place to read the declared set from, and a
|
|
9
|
+
* single place to add to it. The same rule `pikkuBetterAuth` has had all along.
|
|
10
|
+
*
|
|
11
|
+
* A second call in the SAME file is refused too — "the file" is not an answer
|
|
12
|
+
* to "where do I add a persona?" when the file holds two calls, so allowing it
|
|
13
|
+
* would keep the ambiguity the rule exists to remove.
|
|
14
|
+
*
|
|
15
|
+
* A generated file is exempt, and never claims the slot either. The rule exists
|
|
16
|
+
* to name the one place a person adds to, and nobody adds to a file the CLI
|
|
17
|
+
* rewrites on the next run — `pikku-personas.gen.ts` would otherwise make every
|
|
18
|
+
* project that scaffolds one unbuildable, and take the app's own declaration
|
|
19
|
+
* down with it.
|
|
20
|
+
*
|
|
21
|
+
* `defineScope` and `defineSystemRole` held this rule too, briefly, and do not
|
|
22
|
+
* until the `admin` tree stops being app-declared. The exemption above is not
|
|
23
|
+
* enough for them: the CLI generates a `defineScope` in `user-admin.gen.ts` and
|
|
24
|
+
* `@pikku/addon-console` spells the same tree out again in a hand-written file,
|
|
25
|
+
* so two non-generated declarations remain and the build still fails. The fix
|
|
26
|
+
* is for `admin` to be a default scope nobody declares, and the rule comes back
|
|
27
|
+
* for scopes and roles once that lands.
|
|
28
|
+
*
|
|
29
|
+
* @param files - The construct's `files` set, which doubles as the claim.
|
|
30
|
+
* @returns true when the caller holds the claim and should carry on.
|
|
31
|
+
*/
|
|
32
|
+
export declare const claimSingleDeclaration: (logger: InspectorLogger, files: Set<string>, code: ErrorCode, helper: string, sourceFile: string) => boolean;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claims the one call site a single-declaration construct is allowed.
|
|
3
|
+
*
|
|
4
|
+
* `definePersonas` takes a keyed object, so one call already declares as many
|
|
5
|
+
* entries as you like. Letting the call itself repeat buys nothing and costs
|
|
6
|
+
* the thing that matters: a single place to read the declared set from, and a
|
|
7
|
+
* single place to add to it. The same rule `pikkuBetterAuth` has had all along.
|
|
8
|
+
*
|
|
9
|
+
* A second call in the SAME file is refused too — "the file" is not an answer
|
|
10
|
+
* to "where do I add a persona?" when the file holds two calls, so allowing it
|
|
11
|
+
* would keep the ambiguity the rule exists to remove.
|
|
12
|
+
*
|
|
13
|
+
* A generated file is exempt, and never claims the slot either. The rule exists
|
|
14
|
+
* to name the one place a person adds to, and nobody adds to a file the CLI
|
|
15
|
+
* rewrites on the next run — `pikku-personas.gen.ts` would otherwise make every
|
|
16
|
+
* project that scaffolds one unbuildable, and take the app's own declaration
|
|
17
|
+
* down with it.
|
|
18
|
+
*
|
|
19
|
+
* `defineScope` and `defineSystemRole` held this rule too, briefly, and do not
|
|
20
|
+
* until the `admin` tree stops being app-declared. The exemption above is not
|
|
21
|
+
* enough for them: the CLI generates a `defineScope` in `user-admin.gen.ts` and
|
|
22
|
+
* `@pikku/addon-console` spells the same tree out again in a hand-written file,
|
|
23
|
+
* so two non-generated declarations remain and the build still fails. The fix
|
|
24
|
+
* is for `admin` to be a default scope nobody declares, and the rule comes back
|
|
25
|
+
* for scopes and roles once that lands.
|
|
26
|
+
*
|
|
27
|
+
* @param files - The construct's `files` set, which doubles as the claim.
|
|
28
|
+
* @returns true when the caller holds the claim and should carry on.
|
|
29
|
+
*/
|
|
30
|
+
export const claimSingleDeclaration = (logger, files, code, helper, sourceFile) => {
|
|
31
|
+
if (sourceFile.endsWith('.gen.ts')) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
const [first] = files;
|
|
35
|
+
if (first === undefined) {
|
|
36
|
+
files.add(sourceFile);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
logger.critical(code, first === sourceFile
|
|
40
|
+
? `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.`
|
|
41
|
+
: `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.`);
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
@@ -26,7 +26,7 @@ export function validateExposedFunctionsGated(logger, state) {
|
|
|
26
26
|
// this check has no way to see. Taking the claim at face value is the
|
|
27
27
|
// point: without it the warning fires forever on functions that are fine,
|
|
28
28
|
// and a warning that is usually wrong stops being read.
|
|
29
|
-
if (meta.
|
|
29
|
+
if (meta.permissionsInBody === true)
|
|
30
30
|
continue;
|
|
31
31
|
// The function gates itself.
|
|
32
32
|
if (meta.auth === true)
|
|
@@ -43,17 +43,25 @@ export function validateExposedFunctionsGated(logger, state) {
|
|
|
43
43
|
return;
|
|
44
44
|
const shown = ungated.slice(0, 10);
|
|
45
45
|
const rest = ungated.length - shown.length;
|
|
46
|
+
const one = ungated.length === 1;
|
|
47
|
+
// Every function here is sessionless — that is the population, not the
|
|
48
|
+
// finding, so the message names it as given rather than reporting it as a
|
|
49
|
+
// defect. The remedies are the ones that can actually apply to an anonymous
|
|
50
|
+
// caller: `scopes` cannot, because `verifyScopes` fails closed on a session
|
|
51
|
+
// that does not exist, and telling someone to add them sends them to a
|
|
52
|
+
// MissingScopeError on every public request.
|
|
46
53
|
logger.diagnostic({
|
|
47
54
|
severity: 'warn',
|
|
48
55
|
code: ErrorCode.EXPOSED_FUNCTION_HAS_NO_GATE,
|
|
49
|
-
message: `${ungated.length} exposed function${
|
|
50
|
-
`
|
|
51
|
-
`${
|
|
56
|
+
message: `${ungated.length} exposed sessionless function${one ? '' : 's'} ` +
|
|
57
|
+
`carr${one ? 'ies' : 'y'} no permission and no addon gate, so ` +
|
|
58
|
+
`${one ? 'it is' : 'they are'} reachable by anyone through ` +
|
|
52
59
|
`POST /rpc/:rpcName: ${shown.join(', ')}` +
|
|
53
60
|
(rest > 0 ? `, and ${rest} more` : '') +
|
|
54
|
-
`. Add
|
|
55
|
-
`
|
|
56
|
-
`
|
|
61
|
+
`. Add permissions to the function, set \`auth: true\` if a session is ` +
|
|
62
|
+
`required after all, gate the whole addon with ` +
|
|
63
|
+
`wireAddon({ auth: true }), or drop \`expose: true\` if ` +
|
|
64
|
+
`${one ? 'it was' : 'they were'} not meant to be callable from outside.`,
|
|
57
65
|
});
|
|
58
66
|
}
|
|
59
67
|
/**
|
|
@@ -8,6 +8,12 @@ export interface ExtractionResult {
|
|
|
8
8
|
steps?: WorkflowStepMeta[];
|
|
9
9
|
/** Workflow context (top-level variables) */
|
|
10
10
|
context?: WorkflowContext;
|
|
11
|
+
/**
|
|
12
|
+
* The flow asserts something through an expectation helper rather than a
|
|
13
|
+
* `then` step. Recorded separately because those helpers are inline steps and
|
|
14
|
+
* leave no phase behind, so PKU680 has no other way to see them.
|
|
15
|
+
*/
|
|
16
|
+
asserts?: boolean;
|
|
11
17
|
reason?: string;
|
|
12
18
|
}
|
|
13
19
|
/**
|
|
@@ -1,7 +1,53 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
-
import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
|
|
2
|
+
import { isWorkflowDoCall, isWorkflowExpectEventuallyCall, isScenarioStepCall, isScenarioExpectationCall, getScenarioStepPhase, DYNAMIC_SCENARIO_STEP_TARGET, extractActorFromOptions, isWorkflowSleepCall, isWorkflowSuspendCall, isWorkflowApprovalCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
|
|
3
3
|
import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
|
|
4
4
|
import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
|
|
5
|
+
/**
|
|
6
|
+
* A function the walk must not step into, because nothing here says it runs.
|
|
7
|
+
*
|
|
8
|
+
* A callback handed straight to a call — a fanout's `map`, an inline
|
|
9
|
+
* `workflow.do` step — executes as part of the body and counts. A function that
|
|
10
|
+
* is merely *declared* or assigned to a name does not: an assertion parked in an
|
|
11
|
+
* unused helper asserts nothing, and counting it would let a scenario silence
|
|
12
|
+
* PKU680 with dead code.
|
|
13
|
+
*/
|
|
14
|
+
function isUncalledFunction(node) {
|
|
15
|
+
if (!ts.isFunctionDeclaration(node) &&
|
|
16
|
+
!ts.isFunctionExpression(node) &&
|
|
17
|
+
!ts.isArrowFunction(node)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (ts.isFunctionDeclaration(node))
|
|
21
|
+
return true;
|
|
22
|
+
const parent = node.parent;
|
|
23
|
+
return !(parent &&
|
|
24
|
+
ts.isCallExpression(parent) &&
|
|
25
|
+
parent.arguments.some((argument) => argument === node));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether the body calls one of the expectation helpers anywhere it runs.
|
|
29
|
+
*
|
|
30
|
+
* A whole-body walk rather than a check inside step extraction: the helpers
|
|
31
|
+
* produce no step of their own, so there is no extraction result to hang the
|
|
32
|
+
* answer off, and one nested inside a branch asserts just as much as one at the
|
|
33
|
+
* top level.
|
|
34
|
+
*/
|
|
35
|
+
function hasScenarioExpectation(body) {
|
|
36
|
+
let found = false;
|
|
37
|
+
const visit = (node) => {
|
|
38
|
+
if (found)
|
|
39
|
+
return;
|
|
40
|
+
if (ts.isCallExpression(node) && isScenarioExpectationCall(node)) {
|
|
41
|
+
found = true;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (node !== body && isUncalledFunction(node))
|
|
45
|
+
return;
|
|
46
|
+
ts.forEachChild(node, visit);
|
|
47
|
+
};
|
|
48
|
+
visit(body);
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
5
51
|
/**
|
|
6
52
|
* Extract DSL workflow metadata from a function declaration
|
|
7
53
|
*/
|
|
@@ -74,6 +120,7 @@ export function extractDSLWorkflow(funcNode, checker, options) {
|
|
|
74
120
|
status: 'ok',
|
|
75
121
|
steps,
|
|
76
122
|
context: Object.keys(workflowContext).length > 0 ? workflowContext : undefined,
|
|
123
|
+
asserts: hasScenarioExpectation(arrowFunc.body) || undefined,
|
|
77
124
|
};
|
|
78
125
|
}
|
|
79
126
|
catch (error) {
|
|
@@ -20,12 +20,12 @@ export declare function isWorkflowDoCall(node: ts.CallExpression, checker: ts.Ty
|
|
|
20
20
|
* hit repeatedly on workflow graph round-trips.
|
|
21
21
|
*/
|
|
22
22
|
export declare const DYNAMIC_SCENARIO_STEP_TARGET = "<dynamic>";
|
|
23
|
-
declare const SCENARIO_STEP_PHASES: readonly ["
|
|
23
|
+
declare const SCENARIO_STEP_PHASES: readonly ["given", "when", "then"];
|
|
24
24
|
export type ScenarioStepPhaseName = (typeof SCENARIO_STEP_PHASES)[number];
|
|
25
25
|
/**
|
|
26
|
-
* Check if a call expression is scenario.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Check if a call expression is scenario.given()/when()/then() — a declared
|
|
27
|
+
* `pikkuScenarioStep` invoked by name. `given`/`when` are pure sugar for each
|
|
28
|
+
* other; `then` additionally makes the step's bindings witnesses.
|
|
29
29
|
*/
|
|
30
30
|
export declare function isScenarioStepCall(node: ts.CallExpression): boolean;
|
|
31
31
|
/**
|
|
@@ -33,6 +33,11 @@ export declare function isScenarioStepCall(node: ts.CallExpression): boolean;
|
|
|
33
33
|
* isn't one.
|
|
34
34
|
*/
|
|
35
35
|
export declare function getScenarioStepPhase(node: ts.CallExpression): ScenarioStepPhaseName | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Check if a call expression is one of the scenario expectation helpers —
|
|
38
|
+
* `scenario.expectService()`, `.expectError()` or `.expectEventually()`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isScenarioExpectationCall(node: ts.CallExpression): boolean;
|
|
36
41
|
/**
|
|
37
42
|
* Check if a call expression is workflow.expectEventually()
|
|
38
43
|
*/
|
|
@@ -31,11 +31,11 @@ export function isWorkflowDoCall(node, checker) {
|
|
|
31
31
|
* hit repeatedly on workflow graph round-trips.
|
|
32
32
|
*/
|
|
33
33
|
export const DYNAMIC_SCENARIO_STEP_TARGET = '<dynamic>';
|
|
34
|
-
const SCENARIO_STEP_PHASES = ['
|
|
34
|
+
const SCENARIO_STEP_PHASES = ['given', 'when', 'then'];
|
|
35
35
|
/**
|
|
36
|
-
* Check if a call expression is scenario.
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Check if a call expression is scenario.given()/when()/then() — a declared
|
|
37
|
+
* `pikkuScenarioStep` invoked by name. `given`/`when` are pure sugar for each
|
|
38
|
+
* other; `then` additionally makes the step's bindings witnesses.
|
|
39
39
|
*/
|
|
40
40
|
export function isScenarioStepCall(node) {
|
|
41
41
|
return getScenarioStepPhase(node) !== undefined;
|
|
@@ -54,6 +54,28 @@ export function getScenarioStepPhase(node) {
|
|
|
54
54
|
}
|
|
55
55
|
return SCENARIO_STEP_PHASES.find((phase) => phase === propAccess.name.text);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The expectation helpers that assert without being a `then`.
|
|
59
|
+
*
|
|
60
|
+
* `expectService` and `expectError` are inline steps rather than scenario
|
|
61
|
+
* steps, so they carry no phase and never reach the step meta — but each one is
|
|
62
|
+
* a witness: a recorded service call, or a refusal. PKU680 has to see them, or
|
|
63
|
+
* a scenario whose only assertion is one of these reads as asserting nothing.
|
|
64
|
+
*/
|
|
65
|
+
const SCENARIO_EXPECTATION_CALLS = [
|
|
66
|
+
'expectService',
|
|
67
|
+
'expectError',
|
|
68
|
+
'expectEventually',
|
|
69
|
+
];
|
|
70
|
+
/**
|
|
71
|
+
* Check if a call expression is one of the scenario expectation helpers —
|
|
72
|
+
* `scenario.expectService()`, `.expectError()` or `.expectEventually()`.
|
|
73
|
+
*/
|
|
74
|
+
export function isScenarioExpectationCall(node) {
|
|
75
|
+
return (ts.isPropertyAccessExpression(node.expression) &&
|
|
76
|
+
SCENARIO_EXPECTATION_CALLS.some((name) => name === node.expression.name.text) &&
|
|
77
|
+
isWorkflowWireIdentifier(node.expression.expression));
|
|
78
|
+
}
|
|
57
79
|
/**
|
|
58
80
|
* Check if a call expression is workflow.expectEventually()
|
|
59
81
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.54",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.76"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.76",
|
|
52
52
|
"@types/node": "^24.11.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
package/src/add/add-functions.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from '../utils/extract-function-name.js'
|
|
9
9
|
import { extractFunctionNode } from '../utils/extract-function-node.js'
|
|
10
10
|
import { extractUsedWires } from '../utils/extract-services.js'
|
|
11
|
-
import type { FunctionServicesMeta } from '@pikku/core'
|
|
11
|
+
import type { AuditDurability, FunctionServicesMeta } from '@pikku/core'
|
|
12
12
|
import type { ScenarioStepKind, ScenarioSurface } from '@pikku/core/workflow'
|
|
13
13
|
import { formatVersionedId, parseVersionedId } from '@pikku/core'
|
|
14
14
|
|
|
@@ -42,6 +42,7 @@ import { resolvePermissions } from '../utils/permissions.js'
|
|
|
42
42
|
import { extractWireNames } from '../utils/post-process.js'
|
|
43
43
|
import { ErrorCode } from '../error-codes.js'
|
|
44
44
|
import { findPiiPaths } from '../utils/check-pii-output.js'
|
|
45
|
+
import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js'
|
|
45
46
|
import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js'
|
|
46
47
|
import type { NodeType } from '@pikku/core/node'
|
|
47
48
|
|
|
@@ -580,7 +581,9 @@ export const addFunctions: AddWiring = (
|
|
|
580
581
|
*/
|
|
581
582
|
let auth: boolean | undefined
|
|
582
583
|
/** The author's claim that the body authorizes its own callers. */
|
|
583
|
-
let
|
|
584
|
+
let permissionsInBody: boolean | undefined
|
|
585
|
+
/** The function's `audit` config, resolved to its object form. */
|
|
586
|
+
let audit: { durability: AuditDurability } | undefined
|
|
584
587
|
let readonly_: boolean | undefined
|
|
585
588
|
let deploy: 'serverless' | 'server' | 'auto' | undefined
|
|
586
589
|
let approvalRequired: boolean | undefined
|
|
@@ -679,9 +682,43 @@ export const addFunctions: AddWiring = (
|
|
|
679
682
|
remote = getPropertyValue(firstArg, 'remote') as boolean | undefined
|
|
680
683
|
mcp = getPropertyValue(firstArg, 'mcp') as boolean | undefined
|
|
681
684
|
auth = getPropertyValue(firstArg, 'auth') as boolean | undefined
|
|
682
|
-
|
|
685
|
+
permissionsInBody = getPropertyValue(firstArg, 'permissionsInBody') as
|
|
683
686
|
| boolean
|
|
684
687
|
| undefined
|
|
688
|
+
if (permissionsInBody && !options.allow?.permissionsInBody) {
|
|
689
|
+
logger.critical(
|
|
690
|
+
ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED,
|
|
691
|
+
`'${name}' declares \`permissionsInBody: true\`, which this project has not enabled. ` +
|
|
692
|
+
`It silences the exposed-and-ungated audit without gating anything, so it is opt-in: ` +
|
|
693
|
+
`set \`allow: { permissionsInBody: true }\` in pikku.config.json if the project accepts ` +
|
|
694
|
+
`permission checks that only a reader can verify. Otherwise express the check as a ` +
|
|
695
|
+
`\`permissions\` entry, or drop \`expose: true\` if the function was not meant to be ` +
|
|
696
|
+
`callable from outside.`
|
|
697
|
+
)
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
// `audit` is either `true` or `{ durability }`, so it needs the nested read:
|
|
701
|
+
// getPropertyValue stringifies an object initializer rather than parsing it.
|
|
702
|
+
// Mirrors core's `resolveAuditConfig` — the two must agree, or meta would
|
|
703
|
+
// claim a durability the runner does not use.
|
|
704
|
+
const auditProp = firstArg.properties.find(
|
|
705
|
+
(p) =>
|
|
706
|
+
ts.isPropertyAssignment(p) &&
|
|
707
|
+
ts.isIdentifier(p.name) &&
|
|
708
|
+
p.name.text === 'audit'
|
|
709
|
+
)
|
|
710
|
+
if (auditProp && ts.isPropertyAssignment(auditProp)) {
|
|
711
|
+
const auditInit = auditProp.initializer
|
|
712
|
+
if (auditInit.kind === ts.SyntaxKind.TrueKeyword) {
|
|
713
|
+
audit = { durability: 'best-effort' }
|
|
714
|
+
} else if (ts.isObjectLiteralExpression(auditInit)) {
|
|
715
|
+
const durability = getPropertyValue(
|
|
716
|
+
auditInit,
|
|
717
|
+
'durability'
|
|
718
|
+
) as AuditDurability | null
|
|
719
|
+
audit = { durability: durability ?? 'best-effort' }
|
|
720
|
+
}
|
|
721
|
+
}
|
|
685
722
|
readonly_ = getPropertyValue(firstArg, 'readonly') as boolean | undefined
|
|
686
723
|
deploy = getPropertyValue(firstArg, 'deploy') as
|
|
687
724
|
| 'serverless'
|
|
@@ -1196,6 +1233,20 @@ export const addFunctions: AddWiring = (
|
|
|
1196
1233
|
// it runs ONLY when explicitly requested (`pikku all --security`) — see the
|
|
1197
1234
|
// classificationCheck option. Default codegen skips it entirely.
|
|
1198
1235
|
if (options.classificationCheck) {
|
|
1236
|
+
// A revealed vault secret is invisible to the return-type scan below when it
|
|
1237
|
+
// is written out mid-body rather than returned, so walk the sinks too.
|
|
1238
|
+
for (const violation of findRevealedSecretSinks(checker, handler)) {
|
|
1239
|
+
logger.diagnostic({
|
|
1240
|
+
severity: 'error',
|
|
1241
|
+
code: ErrorCode.SECRET_REVEALED_INTO_SINK,
|
|
1242
|
+
message:
|
|
1243
|
+
`Function '${name}' passes a revealed secret to '${violation.sink}' ` +
|
|
1244
|
+
`at line ${violation.line} (path '${violation.path}').\n ` +
|
|
1245
|
+
`A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
|
|
1246
|
+
`Pass the value only to the client that needs it, and reveal it there.`,
|
|
1247
|
+
})
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1199
1250
|
const sig = checker.getSignatureFromDeclaration(handler)
|
|
1200
1251
|
if (sig) {
|
|
1201
1252
|
const rawRet = checker.getReturnTypeOfSignature(sig)
|
|
@@ -1232,7 +1283,7 @@ export const addFunctions: AddWiring = (
|
|
|
1232
1283
|
`Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
|
|
1233
1284
|
privatePaths.map((p) => `'${p}'`).join(', ') +
|
|
1234
1285
|
`.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
|
|
1235
|
-
`Either require a session (use pikkuFunc) or
|
|
1286
|
+
`Either require a session (use pikkuFunc) or set the column's security to 'public' in db/annotations.ts if it is safe to expose publicly.`,
|
|
1236
1287
|
})
|
|
1237
1288
|
}
|
|
1238
1289
|
}
|
|
@@ -1311,7 +1362,8 @@ export const addFunctions: AddWiring = (
|
|
|
1311
1362
|
outputs: outputNames.filter((n) => n !== 'void') ?? null,
|
|
1312
1363
|
expose: expose || undefined,
|
|
1313
1364
|
auth: auth || undefined,
|
|
1314
|
-
|
|
1365
|
+
permissionsInBody: permissionsInBody || undefined,
|
|
1366
|
+
audit,
|
|
1315
1367
|
remote: remote || undefined,
|
|
1316
1368
|
scenarioStep: isScenarioStep || undefined,
|
|
1317
1369
|
scenario: isScenario || undefined,
|