@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,305 @@
|
|
|
1
|
+
## 0.12.54
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 62ea4cc: The audit trail is now readable — in the generated meta, through an RPC, and as
|
|
6
|
+
a page in the console.
|
|
7
|
+
|
|
8
|
+
`audit: true` reaches `FunctionRuntimeMeta.audit` as its resolved form
|
|
9
|
+
(`{ durability }`), so which functions record anything is answerable without
|
|
10
|
+
running them. It is informational: the runner still resolves audit from the live
|
|
11
|
+
function config, so meta and runtime cannot disagree.
|
|
12
|
+
|
|
13
|
+
`AuditService` grows an optional read side — `query(AuditQuery)` and `facets()`.
|
|
14
|
+
Optional because a sink can legitimately be write-only: a queue producer that
|
|
15
|
+
hands events to another system has nothing to read back, and a reader that finds
|
|
16
|
+
these absent should say the trail is not readable here rather than that it is
|
|
17
|
+
empty. The two are very different answers to give someone auditing a system.
|
|
18
|
+
|
|
19
|
+
`KyselyAuditService` implements both, newest first with offset paging, filtered
|
|
20
|
+
by user, action and time window. Two things it now gets right that are easy to
|
|
21
|
+
get wrong: an empty filter array means "match nothing" rather than "no filter",
|
|
22
|
+
and results are read by physical _and_ camelCase key, because `CamelCasePlugin`
|
|
23
|
+
is on most pikku Kysely instances and renames result keys on the way out — the
|
|
24
|
+
mismatch does not throw, it returns a page of `undefined`. `init()` creates the
|
|
25
|
+
`audit` table for projects that do not migrate it themselves, from a new
|
|
26
|
+
exported `auditSchema` that stays out of `pikkuSchemas` because the runtime does
|
|
27
|
+
not need it.
|
|
28
|
+
|
|
29
|
+
The console addon exposes `console:getAudits` and `console:getAuditFilters`
|
|
30
|
+
behind a new `pikku:audit:read` scope, and forwards the application's `audit`
|
|
31
|
+
service into the addon's own services — without that last part every install
|
|
32
|
+
reported the trail as unreadable, whatever sink it had configured.
|
|
33
|
+
|
|
34
|
+
The console gets an Audit trail page: an infinite list filtered server-side by
|
|
35
|
+
user and action, and a row that opens the whole event, metadata rendered as a
|
|
36
|
+
JSON tree. Refused, unreadable and empty are three different screens, because
|
|
37
|
+
"you may not read this", "nobody can read this" and "nothing happened" are three
|
|
38
|
+
different facts.
|
|
39
|
+
|
|
40
|
+
Events name the person who caused them. The trail records a user id — the only
|
|
41
|
+
thing stable enough to record, since a name can change after the event — so
|
|
42
|
+
`getAudits` resolves those ids against better-auth's user directory at read
|
|
43
|
+
time, and the page shows the name while keeping the recorded id on the event.
|
|
44
|
+
The filter follows: pick a colleague by name, filter by the id. A scenario
|
|
45
|
+
actor is labelled as one, so synthetic traffic is not mistaken for real, and a
|
|
46
|
+
caller who was signed out shows the wire identity pikku resolved for them
|
|
47
|
+
rather than being credited to the system.
|
|
48
|
+
|
|
49
|
+
**Breaking, for anyone already reading `AuditEvent`:** `actor` is now
|
|
50
|
+
`userIdentity`, and its type `AuditActor` is `AuditUserIdentity`; `AuditQuery`
|
|
51
|
+
takes `userIds`/`orgId` in place of `actorUserIds`/`actorOrgId`, and
|
|
52
|
+
`AuditFacets` returns `userIds`. In pikku an _actor_ is a synthetic person a
|
|
53
|
+
scenario drives, flagged on the user row — so naming the causer of an event
|
|
54
|
+
`actor` made the synthetic case unsayable (`actor.actor === true`) and implied
|
|
55
|
+
every recorded action was a test. The overwhelming majority are ordinary
|
|
56
|
+
customers. The `audit` table follows: `actor_user_id` / `actor_org_id`
|
|
57
|
+
are now `user_id` / `org_id`, and a `pikku_user_id` column joins them so the
|
|
58
|
+
wire identity of a caller who never signed in survives the round trip — the
|
|
59
|
+
sink was dropping it, which left the console's Session field permanently
|
|
60
|
+
blank. A project that already migrated the table needs to rename the two
|
|
61
|
+
columns and add the third; `KyselyAuditService.init()` creates the new shape
|
|
62
|
+
for anyone who did not.
|
|
63
|
+
|
|
64
|
+
- 9dddff8: Split a column's at-rest form out of its classification.
|
|
65
|
+
|
|
66
|
+
`security: 'encrypted'` sat beside `'secret'` as though the two were
|
|
67
|
+
alternatives, which made the field unanswerable: a token hash and a live bearer
|
|
68
|
+
token are both secret, one must never be encrypted — the digest _is_ the lookup
|
|
69
|
+
key — and the other must always be. A column now carries a second, independent
|
|
70
|
+
`form: 'plain' | 'hashed' | 'wrapped' | 'sealed'` saying how the bytes are held.
|
|
71
|
+
|
|
72
|
+
Declaring a form other than `plain` makes the column's INSERT/UPDATE type
|
|
73
|
+
nominal — `WrappedValue`, `SealedValue`, `HashedValue` — so a plain string no
|
|
74
|
+
longer compiles there and the only way to write the column is with something an
|
|
75
|
+
encrypt, seal or hash call produced. `envelopeEncrypt`, `envelopeRewrap` and
|
|
76
|
+
`wrapDEK` now return the brand, and a new `hashToken` produces `HashedValue`, so
|
|
77
|
+
the round trip needs no casts; `column-form.ts` exports deliberately-named
|
|
78
|
+
`unsafeAs*` assertions for backfills, fixtures and values sealed elsewhere.
|
|
79
|
+
Reads are unaffected — the brands widen to `string` and compose with the
|
|
80
|
+
classification brand as `Secret<WrappedValue>`.
|
|
81
|
+
|
|
82
|
+
`wrapped` and `sealed` stay distinct because a sealed value is one the
|
|
83
|
+
application cannot read back; storing one where the other belongs is a row
|
|
84
|
+
nobody can open.
|
|
85
|
+
|
|
86
|
+
A `secret` column that has not declared a form now warns (PKU483), and a form on
|
|
87
|
+
a non-text column warns and is dropped (PKU484). Both are warnings, so existing
|
|
88
|
+
projects keep migrating — `pikku db --fail-on-warn` opts into the ratchet, and
|
|
89
|
+
an explicit `form: 'plain'` is the acknowledgement that silences it. The legacy
|
|
90
|
+
`security: 'encrypted'` keeps working and now expands to the pair it always
|
|
91
|
+
meant, `secret` + `wrapped`.
|
|
92
|
+
|
|
93
|
+
- 9dddff8: Read schema-qualified annotations, and let a project name its default schema.
|
|
94
|
+
|
|
95
|
+
`DbClassificationMap` nests tables under their schema whenever a project sets
|
|
96
|
+
`db.schema`, but `loadAnnotations` read the sidecar as a flat table→column map.
|
|
97
|
+
It therefore took the schema for a table and each table for a column, found no
|
|
98
|
+
recognised fields, and dropped every annotation on the floor — so a project
|
|
99
|
+
could mark a column `secret` and watch it generate as `private`, with no error
|
|
100
|
+
anywhere. The parser now detects the extra level (a column entry's values are
|
|
101
|
+
primitives; a table's are objects) and flattens it, so both shapes load.
|
|
102
|
+
|
|
103
|
+
New `db.defaultSchema` drops one schema's qualifier from the generated types:
|
|
104
|
+
with `defaultSchema: 'app'`, `app.user` is queried as `selectFrom('user')` and
|
|
105
|
+
typed as `User` rather than `AppUser`, matching what a project whose
|
|
106
|
+
`search_path` already resolves the schema actually writes. Tables in other
|
|
107
|
+
schemas stay qualified. Where dropping the qualifier would make two tables
|
|
108
|
+
share a name, the table keeps its qualifier and the codegen warns (PKU485)
|
|
109
|
+
rather than letting one silently shadow the other — queries against the loser
|
|
110
|
+
would have typechecked against the wrong columns.
|
|
111
|
+
|
|
112
|
+
The generated key is what Kysely puts in the SQL, so this is opt-in and
|
|
113
|
+
separate from `db.schema`: setting it for a schema the connection does not
|
|
114
|
+
resolve gives you queries that compile and then fail to find their table.
|
|
115
|
+
|
|
116
|
+
- 155528a: The PKU910 sessionless-output diagnostic advised marking a column `@public`, a
|
|
117
|
+
SQL-comment annotation syntax that no longer exists. Column classification is
|
|
118
|
+
sourced solely from the hand-authored `db/annotations.ts`, so the message now
|
|
119
|
+
points there.
|
|
120
|
+
- 78b29f0: `SecretService` now returns a `SecretValue<T>` rather than the bare value, so a
|
|
121
|
+
vault secret cannot reach a sink by accident.
|
|
122
|
+
|
|
123
|
+
`SecretValue` is nominally typed, which means it is not assignable to `string`
|
|
124
|
+
(or to any other concretely-typed field). Every sink with a real type — a
|
|
125
|
+
database column, an email body, a session payload — rejects it with no lint
|
|
126
|
+
rule involved. The sinks typed `any`, `unknown`, or a free generic — the logger,
|
|
127
|
+
queue payloads, webhook and email inputs, and a function's own output — are
|
|
128
|
+
guarded with `Safe<T>`, which collapses a `SecretValue` found anywhere inside
|
|
129
|
+
`T`, however deeply nested, to `never`.
|
|
130
|
+
|
|
131
|
+
Unwrap deliberately at the point the secret reaches the wire:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const secret = await secrets.getSecret('BETTER_AUTH_SECRET')
|
|
135
|
+
betterAuth({ secret: secret.reveal() })
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Two behaviours cover what types cannot see. Structured serialization redacts —
|
|
139
|
+
`JSON.stringify` and node's inspect both yield `[secret]`, so an audit or log
|
|
140
|
+
write stays honest without crashing the request. String coercion throws
|
|
141
|
+
`SecretCoercionError`, because a template literal is always a leak.
|
|
142
|
+
|
|
143
|
+
`AuditLog.write` is guarded the same way as the logger, since an audit event
|
|
144
|
+
carries `input` and `metadata` as `unknown` and nominality alone cannot stop a
|
|
145
|
+
secret landing in one.
|
|
146
|
+
|
|
147
|
+
`.reveal()` is the deliberate escape hatch, and what it hands back is an
|
|
148
|
+
ordinary string as far as every sink signature is concerned. **PKU953** closes
|
|
149
|
+
that gap: under `pikku all --security` the inspector reports a revealed secret
|
|
150
|
+
that flows into a logger, an audit, a queue, an email or a webhook — `console` included.
|
|
151
|
+
|
|
152
|
+
This also fixed a real one: `remote-addon-auth.ts` called `String(token)` on an
|
|
153
|
+
`unknown` and wrote the result straight into an `Authorization` header.
|
|
154
|
+
|
|
155
|
+
- Updated dependencies [62ea4cc]
|
|
156
|
+
- Updated dependencies [9dddff8]
|
|
157
|
+
- Updated dependencies [78b29f0]
|
|
158
|
+
- @pikku/core@0.12.76
|
|
159
|
+
|
|
160
|
+
## 0.12.53
|
|
161
|
+
|
|
162
|
+
### Patch Changes
|
|
163
|
+
|
|
164
|
+
- fd72e58: Drop `scenario.step` — a scenario step is now always a `given`, `when` or
|
|
165
|
+
`then`.
|
|
166
|
+
|
|
167
|
+
`step` rendered no keyword, which made it the phase to reach for whenever a
|
|
168
|
+
step did not obviously fit one of the three. That is exactly the step a reader
|
|
169
|
+
cannot check: a scenario is read by people deciding whether it describes the
|
|
170
|
+
behaviour they wanted, and a row that says what it does without saying whether
|
|
171
|
+
it is setup, action or claim tells them nothing to agree or disagree with. It
|
|
172
|
+
was also the escape hatch from the assertion lint — a scenario with no `then`
|
|
173
|
+
could be made to stop complaining by demoting its steps rather than by
|
|
174
|
+
asserting anything.
|
|
175
|
+
|
|
176
|
+
Replace `scenario.step(...)` with whichever of `given`, `when` or `then` the
|
|
177
|
+
step actually is. `then` is not a rename: it makes the step's bindings
|
|
178
|
+
witnesses rather than alternatives, so every declared surface runs and they
|
|
179
|
+
must agree.
|
|
180
|
+
|
|
181
|
+
- d041d5b: Let a generated file declare scopes, roles and personas without taking the
|
|
182
|
+
single-declaration slot.
|
|
183
|
+
|
|
184
|
+
`defineScope`, `defineSystemRole` and `definePersonas` are one-per-codebase, and
|
|
185
|
+
the CLI generates declarations of its own — `user-admin.gen.ts` ships the whole
|
|
186
|
+
`admin` scope tree. Any project that scaffolded one therefore had two
|
|
187
|
+
declarations and failed codegen with PKU583, and the losing file's scopes were
|
|
188
|
+
dropped from the metadata rather than merged.
|
|
189
|
+
|
|
190
|
+
The rule exists to name the one place a person reads from and adds to, and
|
|
191
|
+
nobody adds to a file the next codegen run overwrites. A generated declaration
|
|
192
|
+
is still extracted; it just neither claims the slot nor collides with the app's
|
|
193
|
+
own.
|
|
194
|
+
|
|
195
|
+
- fd72e58: Make personas a first-class surface rather than a detail of the test runner.
|
|
196
|
+
|
|
197
|
+
A persona is now read in three places — the knowledge base resolves `persona:`
|
|
198
|
+
URIs against it, scenarios cast it as an actor, and a virtual user runs as it —
|
|
199
|
+
so it gets its own page at `/personas` under a new **People** section in the
|
|
200
|
+
rail, alongside Users. The card is a profile: avatar, name, job title, computed
|
|
201
|
+
address, the system roles they hold, and how many scenarios cast them. Opening
|
|
202
|
+
one expands each role to the scopes it confers, which is the half of the picture
|
|
203
|
+
that explains a 403.
|
|
204
|
+
|
|
205
|
+
`definePersonas` takes an optional `avatarUrl` — any URL a browser can load.
|
|
206
|
+
Nothing is derived from the address: a persona's address is synthetic, so a
|
|
207
|
+
derived identicon would be the same shrug for everyone. Omitted, the console
|
|
208
|
+
keeps drawing the deterministic colour-and-icon avatar from the persona's id.
|
|
209
|
+
|
|
210
|
+
- fd72e58: Read the actors that are not people on the personas page.
|
|
211
|
+
|
|
212
|
+
The platform — the app acting on itself, what `pikkuPlatformScenarioStep`
|
|
213
|
+
declares — now has a row of its own, alongside one per addon whose system a
|
|
214
|
+
step makes act. They sit behind a People / System / All filter that opens on
|
|
215
|
+
the people: a subject holds no roles and signs in as nobody, so leading with it
|
|
216
|
+
would put the rows nothing is authorized through above the ones that are.
|
|
217
|
+
|
|
218
|
+
The platform row is built in rather than derived. A project that has never
|
|
219
|
+
written a platform step still has a platform, and a card that appeared the
|
|
220
|
+
moment somebody declared their first step would read as a feature they had
|
|
221
|
+
switched on.
|
|
222
|
+
|
|
223
|
+
Also: PKU680 now counts `expectService`, `expectError` and `expectEventually`
|
|
224
|
+
as assertions. They are inline steps and carry no phase, so a scenario whose
|
|
225
|
+
only witness was a recorded service call was being told it never asserts.
|
|
226
|
+
|
|
227
|
+
- 894b2f8: `defineScope` and `defineSystemRole` accumulate across call sites again. Only `definePersonas` is one-per-codebase.
|
|
228
|
+
|
|
229
|
+
The previous release made all three single-declaration constructs, which no project scaffolding user-admin could satisfy: the CLI generates a `defineScope` of its own in `user-admin.gen.ts` carrying the whole `admin` tree, and `@pikku/addon-console` spells the same tree out again, so a second hand-written declaration failed the build with PKU583 — and the losing file's scopes were dropped from the metadata rather than merged.
|
|
230
|
+
|
|
231
|
+
Exempting generated files would have reinstated exactly the ambiguity the rule removes, only for the files nobody can read the rule from. The real fix is for `admin` to be a default scope nobody declares, at which point the rule can come back for scopes and roles.
|
|
232
|
+
|
|
233
|
+
`definePersonas` is unaffected: nothing generates one, so its single call site stands.
|
|
234
|
+
|
|
235
|
+
- dd19aa7: Drop `scopes` from sessionless functions, rename `selfAuthenticated`, and make both
|
|
236
|
+
escape hatches opt-in.
|
|
237
|
+
|
|
238
|
+
**`scopes` are gone from `pikkuSessionlessFunc`.** They are AND-ed and `verifyScopes`
|
|
239
|
+
fails closed on a session that does not exist, so every scope listed on a sessionless
|
|
240
|
+
function rejected the anonymous caller it exists to serve. `CorePikkuSessionlessFunctionConfig`
|
|
241
|
+
now states this once in core, and the generated `pikkuSessionlessFunc` / `pikkuVoidFunc`
|
|
242
|
+
configs derive from it — so the field is absent rather than subtracted.
|
|
243
|
+
|
|
244
|
+
`@pikku/addon-console`'s `installAddon` and `installOpenapiAddon` are now `pikkuFunc`.
|
|
245
|
+
Both set `auth: true` and `scopes: ['admin']`, and a test exercises that gate, so the
|
|
246
|
+
scopes were load-bearing — they only compiled as sessionless because the config accepted
|
|
247
|
+
a field it could not honour. No behaviour change: both already required a session.
|
|
248
|
+
|
|
249
|
+
**`selfAuthenticated` is now `permissionsInBody`.** It never described authentication:
|
|
250
|
+
what it records is that the permission check lives in the function body rather than in a
|
|
251
|
+
declared `permissions` entry.
|
|
252
|
+
|
|
253
|
+
**Both escape hatches must be opted into**, via a new `allow` block in
|
|
254
|
+
`pikku.config.json`:
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
"allow": { "permissionsInBody": true, "complexWorkflows": true }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Unset means unavailable, and using the feature is a build error naming the flag that
|
|
261
|
+
would permit it — PKU576 for `permissionsInBody`, PKU643 for `pikkuWorkflowComplexFunc`.
|
|
262
|
+
Both trade something the tooling can inspect for something only a reader can verify: a
|
|
263
|
+
permission check buried in a body, or workflow steps that cannot be serialized into the
|
|
264
|
+
graph, replayed, or migrated. Both are occasionally right, and both are the path of least
|
|
265
|
+
resistance whenever the declarative form is merely inconvenient. Whoever owns the project
|
|
266
|
+
makes that call once, in writing, instead of every author making it silently at the call
|
|
267
|
+
site.
|
|
268
|
+
|
|
269
|
+
**PKU574's message no longer contradicts any of this.** Every function it reports is
|
|
270
|
+
sessionless — that is how the population is selected, not a finding — yet it opened by
|
|
271
|
+
reporting that they "require neither a session", then advised adding scopes. It now names
|
|
272
|
+
them as sessionless and recommends only gates an anonymous caller can meet:
|
|
273
|
+
`permissions`, `auth: true`, `wireAddon({ auth: true })`, or dropping `expose: true`.
|
|
274
|
+
`permissionsInBody` is deliberately absent from that list: a diagnostic should not
|
|
275
|
+
advertise its own escape hatch.
|
|
276
|
+
|
|
277
|
+
- 50ec500: Make `defineScope`, `defineSystemRole` and `definePersonas` single-declaration constructs
|
|
278
|
+
— exactly one call site per codebase, the rule `pikkuBetterAuth` has always had.
|
|
279
|
+
|
|
280
|
+
Each of the three already takes a keyed object, so one call declares as many entries as
|
|
281
|
+
you like. Spreading the calls across files bought nothing and cost the thing that matters:
|
|
282
|
+
there was no answer to "where do I add a persona?", so downstream tooling and agents had
|
|
283
|
+
nowhere unambiguous to read from or append to. The only duplicate handling that existed
|
|
284
|
+
caught a narrow case — the same id declared twice with different content — and said
|
|
285
|
+
nothing about the same id declared twice in two files.
|
|
286
|
+
|
|
287
|
+
A second call now fails the build with `PKU583` (`defineScope`), `PKU584`
|
|
288
|
+
(`defineSystemRole`) or `PKU585` (`definePersonas`), naming both source files and saying
|
|
289
|
+
to declare them all in one call. A second call in the _same_ file is refused too: "the
|
|
290
|
+
file" is not an answer either when the file holds two calls.
|
|
291
|
+
|
|
292
|
+
- Updated dependencies [32277d5]
|
|
293
|
+
- Updated dependencies [ea8aabf]
|
|
294
|
+
- Updated dependencies [33e96ab]
|
|
295
|
+
- Updated dependencies [fd72e58]
|
|
296
|
+
- Updated dependencies [fd72e58]
|
|
297
|
+
- Updated dependencies [fd72e58]
|
|
298
|
+
- Updated dependencies [894b2f8]
|
|
299
|
+
- Updated dependencies [dd19aa7]
|
|
300
|
+
- Updated dependencies [50ec500]
|
|
301
|
+
- @pikku/core@0.12.75
|
|
302
|
+
|
|
1
303
|
## 0.12.52
|
|
2
304
|
|
|
3
305
|
### Patch Changes
|
|
@@ -29,6 +29,7 @@ import { resolvePermissions } from '../utils/permissions.js';
|
|
|
29
29
|
import { extractWireNames } from '../utils/post-process.js';
|
|
30
30
|
import { ErrorCode } from '../error-codes.js';
|
|
31
31
|
import { findPiiPaths } from '../utils/check-pii-output.js';
|
|
32
|
+
import { findRevealedSecretSinks } from '../utils/check-secret-sinks.js';
|
|
32
33
|
import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js';
|
|
33
34
|
const isValidVariableName = (name) => {
|
|
34
35
|
const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
|
|
@@ -403,7 +404,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
403
404
|
*/
|
|
404
405
|
let auth;
|
|
405
406
|
/** The author's claim that the body authorizes its own callers. */
|
|
406
|
-
let
|
|
407
|
+
let permissionsInBody;
|
|
408
|
+
/** The function's `audit` config, resolved to its object form. */
|
|
409
|
+
let audit;
|
|
407
410
|
let readonly_;
|
|
408
411
|
let deploy;
|
|
409
412
|
let approvalRequired;
|
|
@@ -480,7 +483,33 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
480
483
|
remote = getPropertyValue(firstArg, 'remote');
|
|
481
484
|
mcp = getPropertyValue(firstArg, 'mcp');
|
|
482
485
|
auth = getPropertyValue(firstArg, 'auth');
|
|
483
|
-
|
|
486
|
+
permissionsInBody = getPropertyValue(firstArg, 'permissionsInBody');
|
|
487
|
+
if (permissionsInBody && !options.allow?.permissionsInBody) {
|
|
488
|
+
logger.critical(ErrorCode.PERMISSIONS_IN_BODY_NOT_ALLOWED, `'${name}' declares \`permissionsInBody: true\`, which this project has not enabled. ` +
|
|
489
|
+
`It silences the exposed-and-ungated audit without gating anything, so it is opt-in: ` +
|
|
490
|
+
`set \`allow: { permissionsInBody: true }\` in pikku.config.json if the project accepts ` +
|
|
491
|
+
`permission checks that only a reader can verify. Otherwise express the check as a ` +
|
|
492
|
+
`\`permissions\` entry, or drop \`expose: true\` if the function was not meant to be ` +
|
|
493
|
+
`callable from outside.`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// `audit` is either `true` or `{ durability }`, so it needs the nested read:
|
|
497
|
+
// getPropertyValue stringifies an object initializer rather than parsing it.
|
|
498
|
+
// Mirrors core's `resolveAuditConfig` — the two must agree, or meta would
|
|
499
|
+
// claim a durability the runner does not use.
|
|
500
|
+
const auditProp = firstArg.properties.find((p) => ts.isPropertyAssignment(p) &&
|
|
501
|
+
ts.isIdentifier(p.name) &&
|
|
502
|
+
p.name.text === 'audit');
|
|
503
|
+
if (auditProp && ts.isPropertyAssignment(auditProp)) {
|
|
504
|
+
const auditInit = auditProp.initializer;
|
|
505
|
+
if (auditInit.kind === ts.SyntaxKind.TrueKeyword) {
|
|
506
|
+
audit = { durability: 'best-effort' };
|
|
507
|
+
}
|
|
508
|
+
else if (ts.isObjectLiteralExpression(auditInit)) {
|
|
509
|
+
const durability = getPropertyValue(auditInit, 'durability');
|
|
510
|
+
audit = { durability: durability ?? 'best-effort' };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
484
513
|
readonly_ = getPropertyValue(firstArg, 'readonly');
|
|
485
514
|
deploy = getPropertyValue(firstArg, 'deploy');
|
|
486
515
|
approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
|
|
@@ -847,6 +876,18 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
847
876
|
// it runs ONLY when explicitly requested (`pikku all --security`) — see the
|
|
848
877
|
// classificationCheck option. Default codegen skips it entirely.
|
|
849
878
|
if (options.classificationCheck) {
|
|
879
|
+
// A revealed vault secret is invisible to the return-type scan below when it
|
|
880
|
+
// is written out mid-body rather than returned, so walk the sinks too.
|
|
881
|
+
for (const violation of findRevealedSecretSinks(checker, handler)) {
|
|
882
|
+
logger.diagnostic({
|
|
883
|
+
severity: 'error',
|
|
884
|
+
code: ErrorCode.SECRET_REVEALED_INTO_SINK,
|
|
885
|
+
message: `Function '${name}' passes a revealed secret to '${violation.sink}' ` +
|
|
886
|
+
`at line ${violation.line} (path '${violation.path}').\n ` +
|
|
887
|
+
`A vault secret must not be written to a log, an audit, a queue, an email or a webhook. ` +
|
|
888
|
+
`Pass the value only to the client that needs it, and reveal it there.`,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
850
891
|
const sig = checker.getSignatureFromDeclaration(handler);
|
|
851
892
|
if (sig) {
|
|
852
893
|
const rawRet = checker.getReturnTypeOfSignature(sig);
|
|
@@ -875,7 +916,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
875
916
|
message: `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
|
|
876
917
|
privatePaths.map((p) => `'${p}'`).join(', ') +
|
|
877
918
|
`.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
|
|
878
|
-
`Either require a session (use pikkuFunc) or
|
|
919
|
+
`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.`,
|
|
879
920
|
});
|
|
880
921
|
}
|
|
881
922
|
}
|
|
@@ -937,7 +978,8 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
937
978
|
outputs: outputNames.filter((n) => n !== 'void') ?? null,
|
|
938
979
|
expose: expose || undefined,
|
|
939
980
|
auth: auth || undefined,
|
|
940
|
-
|
|
981
|
+
permissionsInBody: permissionsInBody || undefined,
|
|
982
|
+
audit,
|
|
941
983
|
remote: remote || undefined,
|
|
942
984
|
scenarioStep: isScenarioStep || undefined,
|
|
943
985
|
scenario: isScenario || undefined,
|
package/dist/add/add-personas.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as ts from 'typescript';
|
|
|
2
2
|
import { DISPOSITIONS } from '@pikku/core/virtual-user';
|
|
3
3
|
import { isRunnablePersona } from '@pikku/core/persona';
|
|
4
4
|
import { ErrorCode } from '../error-codes.js';
|
|
5
|
+
import { claimSingleDeclaration } from '../utils/single-declaration.js';
|
|
5
6
|
import { booleanProperty, getProperty, numberProperty, stringArrayProperty, stringProperty, unwrapAs, } from '../utils/literal-properties.js';
|
|
6
7
|
/** A dial and the range outside which it is a mistake rather than a choice. */
|
|
7
8
|
const RATES = [
|
|
@@ -123,6 +124,9 @@ export const addPersonas = (logger, node, _checker, state) => {
|
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
126
|
const sourceFile = node.getSourceFile().fileName;
|
|
127
|
+
if (!claimSingleDeclaration(logger, state.personas.files, ErrorCode.DUPLICATE_PERSONAS_DEFINITION, 'definePersonas', sourceFile)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
126
130
|
const known = Object.keys(DISPOSITIONS);
|
|
127
131
|
for (const prop of unwrapped.properties) {
|
|
128
132
|
if (!ts.isPropertyAssignment(prop)) {
|
|
@@ -182,6 +186,9 @@ export const addPersonas = (logger, node, _checker, state) => {
|
|
|
182
186
|
const description = stringProperty(config, 'description');
|
|
183
187
|
if (description !== undefined)
|
|
184
188
|
persona.description = description;
|
|
189
|
+
const avatarUrl = stringProperty(config, 'avatarUrl');
|
|
190
|
+
if (avatarUrl !== undefined)
|
|
191
|
+
persona.avatarUrl = avatarUrl;
|
|
185
192
|
const personality = stringProperty(config, 'personality');
|
|
186
193
|
if (personality !== undefined)
|
|
187
194
|
persona.personality = personality;
|
|
@@ -204,7 +211,6 @@ export const addPersonas = (logger, node, _checker, state) => {
|
|
|
204
211
|
const linkedAccounts = linkedAccountsProperty(config, id, logger);
|
|
205
212
|
if (linkedAccounts !== undefined)
|
|
206
213
|
persona.linkedAccounts = linkedAccounts;
|
|
207
|
-
state.personas.files.add(sourceFile);
|
|
208
214
|
state.personas.definitions.push(persona);
|
|
209
215
|
}
|
|
210
216
|
};
|
package/dist/add/add-workflow.js
CHANGED
|
@@ -208,7 +208,7 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
|
|
|
208
208
|
* Inspector for pikkuWorkflowFunc(), pikkuWorkflowComplexFunc() and
|
|
209
209
|
* pikkuScenario() calls. Detects workflow registration and extracts metadata.
|
|
210
210
|
*/
|
|
211
|
-
export const addWorkflow = (logger, node, checker, state) => {
|
|
211
|
+
export const addWorkflow = (logger, node, checker, state, options) => {
|
|
212
212
|
if (!ts.isCallExpression(node)) {
|
|
213
213
|
return;
|
|
214
214
|
}
|
|
@@ -223,6 +223,14 @@ export const addWorkflow = (logger, node, checker, state) => {
|
|
|
223
223
|
wrapperType = 'dsl';
|
|
224
224
|
}
|
|
225
225
|
else if (expression.text === 'pikkuWorkflowComplexFunc') {
|
|
226
|
+
if (!options.allow?.complexWorkflows) {
|
|
227
|
+
logger.critical(ErrorCode.COMPLEX_WORKFLOW_NOT_ALLOWED, `'${expression.text}' is used here, but this project has not enabled complex workflows. ` +
|
|
228
|
+
`Their inline steps cannot be serialized, so they are absent from the workflow graph and ` +
|
|
229
|
+
`cannot be replayed or migrated across a definition change. Express the workflow with ` +
|
|
230
|
+
`pikkuWorkflowFunc and the step DSL, or set \`allow: { complexWorkflows: true }\` in ` +
|
|
231
|
+
`pikku.config.json if the project accepts that trade.`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
226
234
|
wrapperType = 'complex';
|
|
227
235
|
}
|
|
228
236
|
else if (expression.text === 'pikkuScenario') {
|
|
@@ -292,6 +300,7 @@ export const addWorkflow = (logger, node, checker, state) => {
|
|
|
292
300
|
let steps = [];
|
|
293
301
|
let context = undefined;
|
|
294
302
|
let dsl = undefined;
|
|
303
|
+
let asserts = undefined;
|
|
295
304
|
// Try DSL workflow extraction first
|
|
296
305
|
// Pass the whole CallExpression node so findWorkflowFunction can find the arrow function
|
|
297
306
|
const result = extractDSLWorkflow(node, checker, {
|
|
@@ -301,6 +310,7 @@ export const addWorkflow = (logger, node, checker, state) => {
|
|
|
301
310
|
// Extraction succeeded
|
|
302
311
|
steps = result.steps;
|
|
303
312
|
context = result.context;
|
|
313
|
+
asserts = result.asserts;
|
|
304
314
|
// Check if workflow contains inline steps (non-serializable)
|
|
305
315
|
if (hasInlineSteps(steps)) {
|
|
306
316
|
if (wrapperType === 'dsl') {
|
|
@@ -374,6 +384,7 @@ export const addWorkflow = (logger, node, checker, state) => {
|
|
|
374
384
|
expose,
|
|
375
385
|
skip,
|
|
376
386
|
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
387
|
+
asserts,
|
|
377
388
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
378
389
|
};
|
|
379
390
|
// The scenario "logger/config only" rule (PKU673) and the implicit
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -27,21 +27,27 @@ export declare enum ErrorCode {
|
|
|
27
27
|
WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = "PKU600",
|
|
28
28
|
INVALID_DSL_WORKFLOW = "PKU641",
|
|
29
29
|
WORKFLOW_GRAPH_ADDON_NOT_WIRED = "PKU642",
|
|
30
|
+
COMPLEX_WORKFLOW_NOT_ALLOWED = "PKU643",
|
|
30
31
|
REMOTE_ADDON_NOT_DEV_DEPENDENCY = "PKU338",
|
|
31
32
|
REMOTE_ADDON_AUTH_UNRESOLVED = "PKU339",
|
|
32
33
|
DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
|
|
33
34
|
DB_JSON_COLUMN_UNTYPED = "PKU481",
|
|
34
35
|
DB_FORMAT_ON_NON_STRING = "PKU482",
|
|
36
|
+
DB_SECRET_COLUMN_STORED_PLAIN = "PKU483",
|
|
37
|
+
DB_FORM_ON_NON_STRING = "PKU484",
|
|
38
|
+
DB_DEFAULT_SCHEMA_NAME_COLLISION = "PKU485",
|
|
35
39
|
SCHEMA_GENERATION_ERROR = "PKU456",
|
|
36
40
|
INLINE_SCHEMA = "PKU489",
|
|
37
41
|
FUNCTION_METADATA_NOT_FOUND = "PKU559",
|
|
38
42
|
DUPLICATE_AUTH_DEFINITION = "PKU581",
|
|
39
43
|
AUTH_NOT_EXPORTED = "PKU582",
|
|
44
|
+
DUPLICATE_PERSONAS_DEFINITION = "PKU585",
|
|
40
45
|
ROUTE_PARAM_MISMATCH = "PKU571",
|
|
41
46
|
ROUTE_QUERY_MISMATCH = "PKU572",
|
|
42
47
|
AUTH_DISABLED_REQUIRES_SESSIONLESS = "PKU573",
|
|
43
48
|
EXPOSED_FUNCTION_HAS_NO_GATE = "PKU574",
|
|
44
49
|
TAG_RESOLVES_TO_NO_MIDDLEWARE = "PKU575",
|
|
50
|
+
PERMISSIONS_IN_BODY_NOT_ALLOWED = "PKU576",
|
|
45
51
|
DUPLICATE_FUNCTION_VERSION = "PKU850",
|
|
46
52
|
DUPLICATE_FUNCTION_NAME = "PKU851",
|
|
47
53
|
MANIFEST_MISSING = "PKU860",
|
|
@@ -67,7 +73,8 @@ export declare enum ErrorCode {
|
|
|
67
73
|
RPC_INVOCATION_TYPE_CAST = "PKU940",
|
|
68
74
|
SECRET_SERVICE_ALIASED = "PKU950",
|
|
69
75
|
SECRET_NOT_DECLARED = "PKU951",
|
|
70
|
-
SECRET_KEY_NOT_STATIC = "PKU952"
|
|
76
|
+
SECRET_KEY_NOT_STATIC = "PKU952",
|
|
77
|
+
SECRET_REVEALED_INTO_SINK = "PKU953"
|
|
71
78
|
}
|
|
72
79
|
/**
|
|
73
80
|
* Severity of a tracked, coded diagnostic. `critical` always blocks the build;
|
package/dist/error-codes.js
CHANGED
|
@@ -29,6 +29,7 @@ export var ErrorCode;
|
|
|
29
29
|
ErrorCode["WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED"] = "PKU600";
|
|
30
30
|
ErrorCode["INVALID_DSL_WORKFLOW"] = "PKU641";
|
|
31
31
|
ErrorCode["WORKFLOW_GRAPH_ADDON_NOT_WIRED"] = "PKU642";
|
|
32
|
+
ErrorCode["COMPLEX_WORKFLOW_NOT_ALLOWED"] = "PKU643";
|
|
32
33
|
// Remote addon (wireRemoteAddon) validation
|
|
33
34
|
ErrorCode["REMOTE_ADDON_NOT_DEV_DEPENDENCY"] = "PKU338";
|
|
34
35
|
ErrorCode["REMOTE_ADDON_AUTH_UNRESOLVED"] = "PKU339";
|
|
@@ -36,6 +37,9 @@ export var ErrorCode;
|
|
|
36
37
|
ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
|
|
37
38
|
ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
|
|
38
39
|
ErrorCode["DB_FORMAT_ON_NON_STRING"] = "PKU482";
|
|
40
|
+
ErrorCode["DB_SECRET_COLUMN_STORED_PLAIN"] = "PKU483";
|
|
41
|
+
ErrorCode["DB_FORM_ON_NON_STRING"] = "PKU484";
|
|
42
|
+
ErrorCode["DB_DEFAULT_SCHEMA_NAME_COLLISION"] = "PKU485";
|
|
39
43
|
// Configuration errors
|
|
40
44
|
ErrorCode["SCHEMA_GENERATION_ERROR"] = "PKU456";
|
|
41
45
|
ErrorCode["INLINE_SCHEMA"] = "PKU489";
|
|
@@ -44,12 +48,19 @@ export var ErrorCode;
|
|
|
44
48
|
// Auth errors
|
|
45
49
|
ErrorCode["DUPLICATE_AUTH_DEFINITION"] = "PKU581";
|
|
46
50
|
ErrorCode["AUTH_NOT_EXPORTED"] = "PKU582";
|
|
51
|
+
// Single-declaration constructs — one call site per codebase.
|
|
52
|
+
// PKU583 (defineScope) and PKU584 (defineSystemRole) are retired, not free:
|
|
53
|
+
// scopes and roles held this rule until the generated `admin` tree made it
|
|
54
|
+
// unsatisfiable, and get it back once `admin` is a default scope nobody
|
|
55
|
+
// declares. Do not reuse those two numbers for anything else.
|
|
56
|
+
ErrorCode["DUPLICATE_PERSONAS_DEFINITION"] = "PKU585";
|
|
47
57
|
// HTTP Route errors
|
|
48
58
|
ErrorCode["ROUTE_PARAM_MISMATCH"] = "PKU571";
|
|
49
59
|
ErrorCode["ROUTE_QUERY_MISMATCH"] = "PKU572";
|
|
50
60
|
ErrorCode["AUTH_DISABLED_REQUIRES_SESSIONLESS"] = "PKU573";
|
|
51
61
|
ErrorCode["EXPOSED_FUNCTION_HAS_NO_GATE"] = "PKU574";
|
|
52
62
|
ErrorCode["TAG_RESOLVES_TO_NO_MIDDLEWARE"] = "PKU575";
|
|
63
|
+
ErrorCode["PERMISSIONS_IN_BODY_NOT_ALLOWED"] = "PKU576";
|
|
53
64
|
// Versioning errors
|
|
54
65
|
ErrorCode["DUPLICATE_FUNCTION_VERSION"] = "PKU850";
|
|
55
66
|
ErrorCode["DUPLICATE_FUNCTION_NAME"] = "PKU851";
|
|
@@ -86,4 +97,5 @@ export var ErrorCode;
|
|
|
86
97
|
ErrorCode["SECRET_SERVICE_ALIASED"] = "PKU950";
|
|
87
98
|
ErrorCode["SECRET_NOT_DECLARED"] = "PKU951";
|
|
88
99
|
ErrorCode["SECRET_KEY_NOT_STATIC"] = "PKU952";
|
|
100
|
+
ErrorCode["SECRET_REVEALED_INTO_SINK"] = "PKU953";
|
|
89
101
|
})(ErrorCode || (ErrorCode = {}));
|
package/dist/types.d.ts
CHANGED
|
@@ -268,6 +268,15 @@ export type InspectorOptions = Partial<{
|
|
|
268
268
|
* function, which is expensive. Enabled via `pikku all --security`.
|
|
269
269
|
*/
|
|
270
270
|
classificationCheck: boolean;
|
|
271
|
+
/**
|
|
272
|
+
* Escape hatches the project has opted into, from `allow` in
|
|
273
|
+
* `pikku.config.json`. Absent or false means the feature is refused with a
|
|
274
|
+
* critical naming the flag that would permit it.
|
|
275
|
+
*/
|
|
276
|
+
allow: Partial<{
|
|
277
|
+
permissionsInBody: boolean;
|
|
278
|
+
complexWorkflows: boolean;
|
|
279
|
+
}>;
|
|
271
280
|
}>;
|
|
272
281
|
export interface InspectorLogger {
|
|
273
282
|
info: (message: string) => void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
export type SecretSinkViolation = {
|
|
3
|
+
/** How the value reaches the outside world, e.g. `logger.info`. */
|
|
4
|
+
sink: string;
|
|
5
|
+
/** Dotted path to the secret inside the argument, or `<argument>` when bare. */
|
|
6
|
+
path: string;
|
|
7
|
+
/** Position of the offending argument, for the diagnostic message. */
|
|
8
|
+
line: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Finds revealed vault secrets that flow into a sink.
|
|
12
|
+
*
|
|
13
|
+
* `SecretValue` is nominally typed, so the type system alone stops it reaching
|
|
14
|
+
* any of these. `.reveal()` is the deliberate escape hatch, and it hands back
|
|
15
|
+
* `Secret<T>` — the erasable classification brand. That brand is what this scan
|
|
16
|
+
* follows, which means it catches the revealed value both inline
|
|
17
|
+
* (`logger.info(s.reveal())`) and through a local binding, because the local's
|
|
18
|
+
* inferred type carries the brand too.
|
|
19
|
+
*
|
|
20
|
+
* The brand is optional by design, so an explicit annotation (`const t: string =
|
|
21
|
+
* s.reveal()`) erases it and this scan goes quiet. That is the documented limit
|
|
22
|
+
* of a structural marker: it catches the mistake, not someone determined to
|
|
23
|
+
* silence it.
|
|
24
|
+
*
|
|
25
|
+
* Sinks are matched on the receiver's resolved core interface, which means the
|
|
26
|
+
* scan needs the services parameter to be typed. Real projects get that from
|
|
27
|
+
* the generated `#pikku` factory; a hand-rolled file that calls the raw core
|
|
28
|
+
* factory without annotating its services leaves the parameter `any`, and an
|
|
29
|
+
* `any` receiver matches nothing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function findRevealedSecretSinks(checker: ts.TypeChecker, handler: ts.Node): SecretSinkViolation[];
|