@checkstack/secrets-backend 0.2.16 → 0.3.0
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 +79 -0
- package/package.json +9 -9
- package/src/config-secret-channel.ts +483 -0
- package/src/index.ts +16 -1
- package/src/walk-secret-fields.ts +38 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# @checkstack/secrets-backend
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- faf98f5: Security: config secrets (health-check strategy/collector credentials such as
|
|
8
|
+
SSH passwords, DB credentials, HTTP auth, and integration connection
|
|
9
|
+
credentials) ride ONE shared, domain-agnostic extraction channel instead of
|
|
10
|
+
being stored as plaintext or re-implemented per plugin.
|
|
11
|
+
|
|
12
|
+
New primitive and shared service:
|
|
13
|
+
|
|
14
|
+
- `configSecret({ id })` (in `@checkstack/backend-api`) declares an
|
|
15
|
+
extraction-channel secret keyed by a STABLE `id`, independent of field name or
|
|
16
|
+
position, so renaming or reordering a field never orphans its value. Use it
|
|
17
|
+
(not `configString({ "x-secret": true })`) for any credential whose config is
|
|
18
|
+
relayed to a satellite, projected to AI, or diffed by GitOps. `validateSecretIds`
|
|
19
|
+
rejects, at plugin registration, an `x-secret` field with no `id`, a duplicate
|
|
20
|
+
`id`, or a secret nested in an un-keyable container (array / record / tuple /
|
|
21
|
+
map) - so a mis-keyable schema fails boot rather than at run time.
|
|
22
|
+
- `ConfigSecretChannel` (in `@checkstack/secrets-backend`) is the single
|
|
23
|
+
extract / inflate / collect / redact / merge / delete / prune implementation.
|
|
24
|
+
Health-checks and integration connections both BIND it to their own scope
|
|
25
|
+
(marker prefix + internal-secret key layout); neither re-implements the walk.
|
|
26
|
+
|
|
27
|
+
Lifecycle (both bindings):
|
|
28
|
+
|
|
29
|
+
- **Write**: an inline value is extracted into the encrypted internal secret
|
|
30
|
+
store; the stored config keeps only an opaque marker. `${{ secrets.NAME }}`
|
|
31
|
+
references are stored verbatim and resolve through the active backend (local
|
|
32
|
+
or Vault) at run time.
|
|
33
|
+
- **Read**: configuration and connection reads strip `x-secret` values and
|
|
34
|
+
internal markers while keeping `${{ secrets.NAME }}` references visible; the
|
|
35
|
+
AI `getConfigurations` tool and create/update responses are redacted too. A
|
|
36
|
+
value never reaches a browser or an AI model context.
|
|
37
|
+
- **Run**: the core executor inflates markers/references in memory just before
|
|
38
|
+
the client is built. Satellites receive markers only and fetch values
|
|
39
|
+
just-in-time over the authenticated WS channel, per run, never persisted, then
|
|
40
|
+
fail CLOSED if any marker/reference survives resolution.
|
|
41
|
+
- **No orphan**: clearing a secret, removing a field/collector, swapping an
|
|
42
|
+
inline value for a reference, updating a connection, or deleting a
|
|
43
|
+
configuration/connection deletes the now-unreferenced internal secret. Cleanup
|
|
44
|
+
is schema-free (scans markers by prefix) and best-effort on delete, so it works
|
|
45
|
+
even when the owning plugin is uninstalled and never blocks a delete.
|
|
46
|
+
- **Forged-marker safe**: extract/inflate key each internal secret by the
|
|
47
|
+
SCHEMA leaf's stable `id`, never by an id parsed out of a stored marker string,
|
|
48
|
+
so a crafted marker can never resolve or delete another scope's secret.
|
|
49
|
+
|
|
50
|
+
Health-checks additionally get an idempotent, advisory-locked backfill that
|
|
51
|
+
moves pre-existing plaintext values into the internal store, and per-config-id
|
|
52
|
+
locking so concurrent writers across pods can never leave a dangling marker.
|
|
53
|
+
Integration connection credentials keep their released `__connref__:` marker
|
|
54
|
+
prefix and key layout (id equals the flat field name), so existing stored
|
|
55
|
+
connections are byte-compatible.
|
|
56
|
+
|
|
57
|
+
BREAKING CHANGES:
|
|
58
|
+
|
|
59
|
+
- Configuration and connection reads no longer include `x-secret` field values
|
|
60
|
+
(clients must treat blank-on-save as keep-existing; the bundled editors
|
|
61
|
+
already do).
|
|
62
|
+
- Satellites must be upgraded together with the core: an old satellite cannot
|
|
63
|
+
resolve the markers a new core stores, so its credentialed checks fail until
|
|
64
|
+
upgraded.
|
|
65
|
+
|
|
66
|
+
### Patch Changes
|
|
67
|
+
|
|
68
|
+
- Updated dependencies [faf98f5]
|
|
69
|
+
- @checkstack/backend-api@0.29.0
|
|
70
|
+
- @checkstack/secrets-common@0.3.0
|
|
71
|
+
- @checkstack/common@0.20.0
|
|
72
|
+
- @checkstack/command-backend@0.2.18
|
|
73
|
+
|
|
74
|
+
## 0.2.17
|
|
75
|
+
|
|
76
|
+
### Patch Changes
|
|
77
|
+
|
|
78
|
+
- Updated dependencies [e819276]
|
|
79
|
+
- @checkstack/backend-api@0.28.0
|
|
80
|
+
- @checkstack/command-backend@0.2.17
|
|
81
|
+
|
|
3
82
|
## 0.2.16
|
|
4
83
|
|
|
5
84
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/secrets-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Secrets platform backend: resolver service, masking, backend extension point, RPC router",
|
|
5
5
|
"author": "Checkstack contributors",
|
|
6
6
|
"license": "Elastic-2.0",
|
|
@@ -25,22 +25,22 @@
|
|
|
25
25
|
"test": "bun test"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@checkstack/backend-api": "0.
|
|
29
|
-
"@checkstack/command-backend": "0.2.
|
|
30
|
-
"@checkstack/common": "0.
|
|
31
|
-
"@checkstack/secrets-common": "0.
|
|
28
|
+
"@checkstack/backend-api": "0.29.0",
|
|
29
|
+
"@checkstack/command-backend": "0.2.18",
|
|
30
|
+
"@checkstack/common": "0.20.0",
|
|
31
|
+
"@checkstack/secrets-common": "0.3.0",
|
|
32
32
|
"@orpc/server": "^1.14.4",
|
|
33
33
|
"drizzle-orm": "^0.45.0",
|
|
34
34
|
"uuid": "^14.0.0",
|
|
35
35
|
"zod": "^4.2.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@checkstack/scripts": "0.7.
|
|
39
|
-
"@checkstack/dev-server": "2.2.
|
|
40
|
-
"@checkstack/backend": "0.23.
|
|
38
|
+
"@checkstack/scripts": "0.7.1",
|
|
39
|
+
"@checkstack/dev-server": "2.2.6",
|
|
40
|
+
"@checkstack/backend": "0.23.3",
|
|
41
41
|
"@checkstack/tsconfig": "0.0.7",
|
|
42
42
|
"@checkstack/drizzle-helper": "0.0.6",
|
|
43
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
43
|
+
"@checkstack/test-utils-backend": "0.1.52",
|
|
44
44
|
"@types/bun": "^1.3.5",
|
|
45
45
|
"@types/node": "^20.0.0",
|
|
46
46
|
"drizzle-kit": "^0.31.10",
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isSecretSchema } from "@checkstack/backend-api";
|
|
3
|
+
import { isSecretClearSentinel } from "@checkstack/common";
|
|
4
|
+
import {
|
|
5
|
+
isSecretReference,
|
|
6
|
+
configSecretMarker,
|
|
7
|
+
isConfigSecretMarker,
|
|
8
|
+
readConfigSecretMarkerId,
|
|
9
|
+
} from "@checkstack/secrets-common";
|
|
10
|
+
import { walkSecretFields } from "./walk-secret-fields";
|
|
11
|
+
import type { InternalSecretsService } from "./internal-secrets-service";
|
|
12
|
+
import type { SecretResolverService } from "./resolver-service";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The ONE config-secret extraction channel, shared by every plugin that accepts
|
|
16
|
+
* INLINE secrets in a schema-described config (health-check strategy/collector
|
|
17
|
+
* configs, integration connection configs).
|
|
18
|
+
*
|
|
19
|
+
* A field marked with {@link configSecret} holds exactly one of:
|
|
20
|
+
* - an INLINE operator-typed value → extracted into an internal secret on write,
|
|
21
|
+
* leaving an opaque MARKER (`${markerPrefix}${secretId}`) in the stored config;
|
|
22
|
+
* - a `${{ secrets.NAME }}` REFERENCE → kept verbatim, resolved through the
|
|
23
|
+
* active backend at run time;
|
|
24
|
+
* - a legacy bare literal (pre-extraction rows) → passed through.
|
|
25
|
+
*
|
|
26
|
+
* The internal secret is keyed by the field's STABLE `x-secret-id`, never by its
|
|
27
|
+
* name or position - so renaming/moving a field never strands its secret, and a
|
|
28
|
+
* forged marker (one whose embedded id points elsewhere) can only ever resolve
|
|
29
|
+
* the field's OWN slot (extract/inflate key by the schema's id at that leaf).
|
|
30
|
+
*
|
|
31
|
+
* Do NOT confuse this with ConfigService's `configString({ "x-secret": true })`,
|
|
32
|
+
* which encrypts a secret in place for singleton/admin config. See the
|
|
33
|
+
* "secret handling" architecture doc for which mechanism to use.
|
|
34
|
+
*/
|
|
35
|
+
export interface ConfigSecretChannel {
|
|
36
|
+
/**
|
|
37
|
+
* Marker prefix for this channel. Stored markers are
|
|
38
|
+
* `${markerPrefix}${secretId}`. Choose a channel-unique prefix and NEVER
|
|
39
|
+
* change a released one (existing stored markers would stop resolving).
|
|
40
|
+
*/
|
|
41
|
+
markerPrefix: string;
|
|
42
|
+
/**
|
|
43
|
+
* Map a field's stable secret id to the internal-secret name parts. The
|
|
44
|
+
* caller binds the surrounding scope (e.g. config id, provider/connection,
|
|
45
|
+
* collector entry) so the returned parts are globally unique.
|
|
46
|
+
*/
|
|
47
|
+
keyParts: (secretId: string) => string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Thrown when an extraction-channel secret field has no `x-secret-id`. */
|
|
51
|
+
export class MissingSecretIdError extends Error {
|
|
52
|
+
constructor(path: string) {
|
|
53
|
+
super(
|
|
54
|
+
`Config-secret field "${path}" has no x-secret-id. Declare it with ` +
|
|
55
|
+
`configSecret({ id }) instead of configString({ "x-secret": true }).`,
|
|
56
|
+
);
|
|
57
|
+
this.name = "MissingSecretIdError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// EXTRACT (write path)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract inline secrets from ONE config object into internal secrets, replacing
|
|
67
|
+
* each with a marker. References and this field's OWN marker pass through
|
|
68
|
+
* (idempotent). Returns the rewritten config and how many values moved.
|
|
69
|
+
*/
|
|
70
|
+
export async function extractScopeSecrets({
|
|
71
|
+
channel,
|
|
72
|
+
schema,
|
|
73
|
+
config,
|
|
74
|
+
internalSecrets,
|
|
75
|
+
}: {
|
|
76
|
+
channel: ConfigSecretChannel;
|
|
77
|
+
schema: z.ZodTypeAny;
|
|
78
|
+
config: Record<string, unknown>;
|
|
79
|
+
internalSecrets: InternalSecretsService;
|
|
80
|
+
}): Promise<{ config: Record<string, unknown>; extracted: number }> {
|
|
81
|
+
let extracted = 0;
|
|
82
|
+
const rewritten = await walkSecretFields({
|
|
83
|
+
value: config,
|
|
84
|
+
schema,
|
|
85
|
+
visit: async ({ path, secretId, value }) => {
|
|
86
|
+
if (secretId === undefined) throw new MissingSecretIdError(path);
|
|
87
|
+
const ownMarker = configSecretMarker(channel.markerPrefix, secretId);
|
|
88
|
+
// Pass through ONLY this field's own marker (a merge/backfill round-trip)
|
|
89
|
+
// or a `${{ secrets.* }}` reference. A marker-shaped value whose embedded
|
|
90
|
+
// id is NOT this field's id is a FORGED marker; extract it into THIS
|
|
91
|
+
// field's slot so it can never resolve another field's secret.
|
|
92
|
+
if (value === ownMarker || isSecretReference(value)) return value;
|
|
93
|
+
if (value.length === 0) return value;
|
|
94
|
+
await internalSecrets.set({ parts: channel.keyParts(secretId), value });
|
|
95
|
+
extracted++;
|
|
96
|
+
return ownMarker;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
return { config: rewritten as Record<string, unknown>, extracted };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// INFLATE (run path)
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Inflate ONE stored config's secret fields to real values: a marker resolves
|
|
108
|
+
* from the internal store (keyed by the SCHEMA's id at that leaf, never the
|
|
109
|
+
* marker's embedded id), a reference through the active backend, a bare literal
|
|
110
|
+
* passes through. Returns the inflated config plus every resolved value (for
|
|
111
|
+
* output masking). Fail-closed: a marker whose internal secret is missing throws.
|
|
112
|
+
*/
|
|
113
|
+
export async function inflateScopeSecrets({
|
|
114
|
+
channel,
|
|
115
|
+
schema,
|
|
116
|
+
config,
|
|
117
|
+
internalSecrets,
|
|
118
|
+
secretResolver,
|
|
119
|
+
}: {
|
|
120
|
+
channel: ConfigSecretChannel;
|
|
121
|
+
schema: z.ZodTypeAny;
|
|
122
|
+
config: Record<string, unknown>;
|
|
123
|
+
internalSecrets: InternalSecretsService;
|
|
124
|
+
secretResolver: Pick<SecretResolverService, "resolveForRun">;
|
|
125
|
+
}): Promise<{ config: Record<string, unknown>; values: string[] }> {
|
|
126
|
+
const values: string[] = [];
|
|
127
|
+
const inflated = await walkSecretFields({
|
|
128
|
+
value: config,
|
|
129
|
+
schema,
|
|
130
|
+
visit: async ({ path, secretId, value }) => {
|
|
131
|
+
let resolved = value;
|
|
132
|
+
if (isConfigSecretMarker(channel.markerPrefix, value)) {
|
|
133
|
+
if (secretId === undefined) throw new MissingSecretIdError(path);
|
|
134
|
+
const got = await internalSecrets.get({ parts: channel.keyParts(secretId) });
|
|
135
|
+
if (got === undefined) {
|
|
136
|
+
throw new Error(`Internal secret for "${secretId}" not found.`);
|
|
137
|
+
}
|
|
138
|
+
resolved = got;
|
|
139
|
+
} else if (isSecretReference(value)) {
|
|
140
|
+
const { env } = await secretResolver.resolveForRun({
|
|
141
|
+
secretEnv: { CRED: value },
|
|
142
|
+
});
|
|
143
|
+
resolved = env.CRED;
|
|
144
|
+
}
|
|
145
|
+
if (resolved.length > 0) values.push(resolved);
|
|
146
|
+
return resolved;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return { config: inflated as Record<string, unknown>, values };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a config's secret fields to a `path -> value` map WITHOUT rewriting
|
|
154
|
+
* the config - the shape a just-in-time channel (e.g. the satellite reply) ships
|
|
155
|
+
* so the remote side can apply values by field position. Only markers and
|
|
156
|
+
* references produce entries. Fail-closed like inflation.
|
|
157
|
+
*/
|
|
158
|
+
export async function collectScopeSecretValues({
|
|
159
|
+
channel,
|
|
160
|
+
schema,
|
|
161
|
+
config,
|
|
162
|
+
internalSecrets,
|
|
163
|
+
secretResolver,
|
|
164
|
+
}: {
|
|
165
|
+
channel: ConfigSecretChannel;
|
|
166
|
+
schema: z.ZodTypeAny;
|
|
167
|
+
config: Record<string, unknown>;
|
|
168
|
+
internalSecrets: InternalSecretsService;
|
|
169
|
+
secretResolver: Pick<SecretResolverService, "resolveForRun">;
|
|
170
|
+
}): Promise<Record<string, string>> {
|
|
171
|
+
const values: Record<string, string> = {};
|
|
172
|
+
await walkSecretFields({
|
|
173
|
+
value: config,
|
|
174
|
+
schema,
|
|
175
|
+
visit: async ({ path, secretId, value }) => {
|
|
176
|
+
if (isConfigSecretMarker(channel.markerPrefix, value)) {
|
|
177
|
+
if (secretId === undefined) throw new MissingSecretIdError(path);
|
|
178
|
+
const got = await internalSecrets.get({ parts: channel.keyParts(secretId) });
|
|
179
|
+
if (got === undefined) {
|
|
180
|
+
throw new Error(`Internal secret for "${secretId}" not found.`);
|
|
181
|
+
}
|
|
182
|
+
values[path] = got;
|
|
183
|
+
} else if (isSecretReference(value)) {
|
|
184
|
+
const { env } = await secretResolver.resolveForRun({
|
|
185
|
+
secretEnv: { CRED: value },
|
|
186
|
+
});
|
|
187
|
+
values[path] = env.CRED;
|
|
188
|
+
}
|
|
189
|
+
return value;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return values;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============================================================================
|
|
196
|
+
// REDACT / MERGE / SIGNAL (schema-only, channel-agnostic)
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
200
|
+
let current = schema;
|
|
201
|
+
for (;;) {
|
|
202
|
+
if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
|
|
203
|
+
current = current.unwrap() as z.ZodTypeAny;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (current instanceof z.ZodDefault) {
|
|
207
|
+
current = current.def.innerType as z.ZodTypeAny;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
return current;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve the concrete union branch a value inhabits, mirroring
|
|
216
|
+
* `walkSecretFields`: a discriminated union picks by discriminator literal, a
|
|
217
|
+
* plain union by the first option that parses. Undefined for a non-union schema.
|
|
218
|
+
*/
|
|
219
|
+
function matchUnionOption(
|
|
220
|
+
schema: z.ZodTypeAny,
|
|
221
|
+
value: unknown,
|
|
222
|
+
): z.ZodTypeAny | undefined {
|
|
223
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
224
|
+
if (schema instanceof z.ZodDiscriminatedUnion) {
|
|
225
|
+
const discriminator = (schema.def as { discriminator: string }).discriminator;
|
|
226
|
+
const discriminatorValue = (value as Record<string, unknown>)[discriminator];
|
|
227
|
+
const options = schema.options as z.ZodObject<z.ZodRawShape>[];
|
|
228
|
+
return options.find((option) => {
|
|
229
|
+
const discField = option.shape[discriminator];
|
|
230
|
+
return discField instanceof z.ZodLiteral
|
|
231
|
+
? discField.value === discriminatorValue
|
|
232
|
+
: false;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (schema instanceof z.ZodUnion) {
|
|
236
|
+
const options = schema.options as z.ZodTypeAny[];
|
|
237
|
+
return options.find((option) => option.safeParse(value).success);
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Remove every `x-secret` field from a config (recursing objects, arrays, and
|
|
244
|
+
* unions), KEEPING a `${{ secrets.NAME }}` reference verbatim (a pointer, not a
|
|
245
|
+
* value). The redacted shape is what UI/AI reads receive; the editor treats a
|
|
246
|
+
* blank secret as "keep existing". Neither a value nor an internal marker leaks.
|
|
247
|
+
*/
|
|
248
|
+
export function redactSecretFields({
|
|
249
|
+
schema,
|
|
250
|
+
config,
|
|
251
|
+
}: {
|
|
252
|
+
schema: z.ZodTypeAny;
|
|
253
|
+
config: Record<string, unknown>;
|
|
254
|
+
}): Record<string, unknown> {
|
|
255
|
+
const walk = (nodeSchema: z.ZodTypeAny, value: unknown): unknown => {
|
|
256
|
+
if (value === null || value === undefined) return value;
|
|
257
|
+
const unwrapped = unwrapZod(nodeSchema);
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
unwrapped instanceof z.ZodObject &&
|
|
261
|
+
typeof value === "object" &&
|
|
262
|
+
!Array.isArray(value)
|
|
263
|
+
) {
|
|
264
|
+
const shape = unwrapped.shape as Record<string, z.ZodTypeAny>;
|
|
265
|
+
const result: Record<string, unknown> = {
|
|
266
|
+
...(value as Record<string, unknown>),
|
|
267
|
+
};
|
|
268
|
+
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
269
|
+
if (!(key in result)) continue;
|
|
270
|
+
if (isSecretSchema(fieldSchema)) {
|
|
271
|
+
const fieldValue = result[key];
|
|
272
|
+
const isReference =
|
|
273
|
+
typeof fieldValue === "string" && isSecretReference(fieldValue);
|
|
274
|
+
if (!isReference) delete result[key];
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
result[key] = walk(fieldSchema, result[key]);
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (unwrapped instanceof z.ZodArray && Array.isArray(value)) {
|
|
283
|
+
const elementSchema = unwrapped.element as z.ZodTypeAny;
|
|
284
|
+
return value.map((item) => walk(elementSchema, item));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const option = matchUnionOption(unwrapped, value);
|
|
288
|
+
if (option) return walk(option, value);
|
|
289
|
+
|
|
290
|
+
return value;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
return walk(schema, config) as Record<string, unknown>;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Restore stored secret values into an incoming (edited) config. A blank/absent
|
|
298
|
+
* secret means "keep existing"; a non-empty value wins; the CLEAR sentinel drops
|
|
299
|
+
* the field (positively remove). Runs before validation and extraction. Descends
|
|
300
|
+
* objects, index-paired arrays, and unions.
|
|
301
|
+
*/
|
|
302
|
+
export function mergeSecretFields({
|
|
303
|
+
schema,
|
|
304
|
+
incoming,
|
|
305
|
+
stored,
|
|
306
|
+
}: {
|
|
307
|
+
schema: z.ZodTypeAny;
|
|
308
|
+
incoming: Record<string, unknown>;
|
|
309
|
+
stored: Record<string, unknown> | undefined;
|
|
310
|
+
}): Record<string, unknown> {
|
|
311
|
+
const walk = (
|
|
312
|
+
nodeSchema: z.ZodTypeAny,
|
|
313
|
+
incomingValue: unknown,
|
|
314
|
+
storedValue: unknown,
|
|
315
|
+
): unknown => {
|
|
316
|
+
const unwrapped = unwrapZod(nodeSchema);
|
|
317
|
+
|
|
318
|
+
if (isSecretSchema(nodeSchema)) {
|
|
319
|
+
if (isSecretClearSentinel(incomingValue)) return undefined;
|
|
320
|
+
const incomingIsSet =
|
|
321
|
+
typeof incomingValue === "string" && incomingValue.trim() !== "";
|
|
322
|
+
if (incomingIsSet) return incomingValue;
|
|
323
|
+
const storedIsSet =
|
|
324
|
+
typeof storedValue === "string" && storedValue.trim() !== "";
|
|
325
|
+
return storedIsSet ? storedValue : incomingValue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (
|
|
329
|
+
unwrapped instanceof z.ZodObject &&
|
|
330
|
+
typeof incomingValue === "object" &&
|
|
331
|
+
incomingValue !== null &&
|
|
332
|
+
!Array.isArray(incomingValue)
|
|
333
|
+
) {
|
|
334
|
+
const storedRecord =
|
|
335
|
+
typeof storedValue === "object" &&
|
|
336
|
+
storedValue !== null &&
|
|
337
|
+
!Array.isArray(storedValue)
|
|
338
|
+
? (storedValue as Record<string, unknown>)
|
|
339
|
+
: undefined;
|
|
340
|
+
const shape = unwrapped.shape as Record<string, z.ZodTypeAny>;
|
|
341
|
+
const result: Record<string, unknown> = {
|
|
342
|
+
...(incomingValue as Record<string, unknown>),
|
|
343
|
+
};
|
|
344
|
+
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
345
|
+
const merged = walk(fieldSchema, result[key], storedRecord?.[key]);
|
|
346
|
+
if (merged === undefined && !(key in result)) continue;
|
|
347
|
+
result[key] = merged;
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (unwrapped instanceof z.ZodArray && Array.isArray(incomingValue)) {
|
|
353
|
+
const storedArray = Array.isArray(storedValue) ? storedValue : [];
|
|
354
|
+
const elementSchema = unwrapped.element as z.ZodTypeAny;
|
|
355
|
+
return incomingValue.map((item, index) =>
|
|
356
|
+
walk(elementSchema, item, storedArray[index]),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const option = matchUnionOption(unwrapped, incomingValue);
|
|
361
|
+
if (option) return walk(option, incomingValue, storedValue);
|
|
362
|
+
|
|
363
|
+
return incomingValue;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
return walk(schema, incoming, stored) as Record<string, unknown>;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The TOP-LEVEL `x-secret` field keys of a config that actually hold a stored
|
|
371
|
+
* value (marker, reference, or legacy literal - any non-empty string). Lets a
|
|
372
|
+
* UI tell a stored secret from a never-set optional one. Keys only, never values.
|
|
373
|
+
*/
|
|
374
|
+
export function listPopulatedSecretKeys({
|
|
375
|
+
schema,
|
|
376
|
+
config,
|
|
377
|
+
}: {
|
|
378
|
+
schema: z.ZodTypeAny;
|
|
379
|
+
config: Record<string, unknown>;
|
|
380
|
+
}): string[] {
|
|
381
|
+
const obj = unwrapZod(schema);
|
|
382
|
+
if (!(obj instanceof z.ZodObject)) return [];
|
|
383
|
+
const shape = obj.shape as Record<string, z.ZodTypeAny>;
|
|
384
|
+
return Object.entries(shape)
|
|
385
|
+
.filter(([key, fieldSchema]) => {
|
|
386
|
+
if (!isSecretSchema(fieldSchema)) return false;
|
|
387
|
+
const value = config[key];
|
|
388
|
+
return typeof value === "string" && value.length > 0;
|
|
389
|
+
})
|
|
390
|
+
.map(([key]) => key);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ============================================================================
|
|
394
|
+
// CLEANUP (schema-free: delete + orphan prune)
|
|
395
|
+
// ============================================================================
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Scan ONE config for markers WITHOUT a schema, keying each by the STABLE id
|
|
399
|
+
* embedded in the marker (via the channel). Schema-free is load-bearing for
|
|
400
|
+
* cleanup: an unregistered plugin has no schema to walk, but its stored markers
|
|
401
|
+
* still index live internal secrets that must be deleted, never orphaned.
|
|
402
|
+
*/
|
|
403
|
+
function scanScopeMarkers({
|
|
404
|
+
channel,
|
|
405
|
+
value,
|
|
406
|
+
}: {
|
|
407
|
+
channel: ConfigSecretChannel;
|
|
408
|
+
value: unknown;
|
|
409
|
+
}): string[][] {
|
|
410
|
+
const parts: string[][] = [];
|
|
411
|
+
const walk = (node: unknown): void => {
|
|
412
|
+
if (typeof node === "string") {
|
|
413
|
+
if (isConfigSecretMarker(channel.markerPrefix, node)) {
|
|
414
|
+
parts.push(
|
|
415
|
+
channel.keyParts(readConfigSecretMarkerId(channel.markerPrefix, node)),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (Array.isArray(node)) {
|
|
421
|
+
for (const item of node) walk(item);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (node && typeof node === "object") {
|
|
425
|
+
for (const child of Object.values(node)) walk(child);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
walk(value);
|
|
429
|
+
return parts;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const partsKey = (parts: string[]): string => JSON.stringify(parts);
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Delete every internal secret ONE config's markers point at. Schema-free, so it
|
|
436
|
+
* cleans up even when the owning plugin is uninstalled. Idempotent.
|
|
437
|
+
*/
|
|
438
|
+
export async function deleteScopeSecrets({
|
|
439
|
+
channel,
|
|
440
|
+
config,
|
|
441
|
+
internalSecrets,
|
|
442
|
+
}: {
|
|
443
|
+
channel: ConfigSecretChannel;
|
|
444
|
+
config: Record<string, unknown>;
|
|
445
|
+
internalSecrets: InternalSecretsService;
|
|
446
|
+
}): Promise<void> {
|
|
447
|
+
for (const parts of scanScopeMarkers({ channel, value: config })) {
|
|
448
|
+
await internalSecrets.delete({ parts });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Delete internal secrets ORPHANED by an update to ONE config scope: an old
|
|
454
|
+
* marker whose exact internal-secret coordinates are absent from the new config.
|
|
455
|
+
* Compares by exact `parts` (injective in the secret id), so two fields whose
|
|
456
|
+
* ids are in a prefix relationship never collide. Schema-free on both sides, so
|
|
457
|
+
* a marker preserved under an uninstalled plugin is correctly kept. Returns the
|
|
458
|
+
* number deleted.
|
|
459
|
+
*/
|
|
460
|
+
export async function pruneScopeSecrets({
|
|
461
|
+
channel,
|
|
462
|
+
oldConfig,
|
|
463
|
+
newConfig,
|
|
464
|
+
internalSecrets,
|
|
465
|
+
}: {
|
|
466
|
+
channel: ConfigSecretChannel;
|
|
467
|
+
oldConfig: Record<string, unknown>;
|
|
468
|
+
newConfig: Record<string, unknown>;
|
|
469
|
+
internalSecrets: InternalSecretsService;
|
|
470
|
+
}): Promise<number> {
|
|
471
|
+
const newKeys = new Set(
|
|
472
|
+
scanScopeMarkers({ channel, value: newConfig }).map((parts) =>
|
|
473
|
+
partsKey(parts),
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
let deleted = 0;
|
|
477
|
+
for (const parts of scanScopeMarkers({ channel, value: oldConfig })) {
|
|
478
|
+
if (newKeys.has(partsKey(parts))) continue;
|
|
479
|
+
await internalSecrets.delete({ parts });
|
|
480
|
+
deleted++;
|
|
481
|
+
}
|
|
482
|
+
return deleted;
|
|
483
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -235,7 +235,22 @@ export {
|
|
|
235
235
|
type SecretStore,
|
|
236
236
|
type SecretResolutionResult,
|
|
237
237
|
} from "./secret-resolver";
|
|
238
|
-
export {
|
|
238
|
+
export {
|
|
239
|
+
walkSecretFields,
|
|
240
|
+
type SecretFieldVisit,
|
|
241
|
+
} from "./walk-secret-fields";
|
|
242
|
+
export {
|
|
243
|
+
extractScopeSecrets,
|
|
244
|
+
inflateScopeSecrets,
|
|
245
|
+
collectScopeSecretValues,
|
|
246
|
+
redactSecretFields,
|
|
247
|
+
mergeSecretFields,
|
|
248
|
+
listPopulatedSecretKeys,
|
|
249
|
+
deleteScopeSecrets,
|
|
250
|
+
pruneScopeSecrets,
|
|
251
|
+
MissingSecretIdError,
|
|
252
|
+
type ConfigSecretChannel,
|
|
253
|
+
} from "./config-secret-channel";
|
|
239
254
|
export {
|
|
240
255
|
createSecretResolverService,
|
|
241
256
|
type SecretResolverService,
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { isSecretSchema } from "@checkstack/backend-api";
|
|
2
|
+
import { isSecretSchema, getSecretId } from "@checkstack/backend-api";
|
|
3
|
+
|
|
4
|
+
/** The information the walk hands a visitor for each `x-secret` string leaf. */
|
|
5
|
+
export interface SecretFieldVisit {
|
|
6
|
+
/** Dot/bracket walk path, for diagnostics (`"auth.password"`, `"hosts[0]"`). */
|
|
7
|
+
path: string;
|
|
8
|
+
/**
|
|
9
|
+
* The field's stable extraction id (`x-secret-id`), or `undefined` for a
|
|
10
|
+
* plain `configString({ "x-secret": true })`. Extraction-channel callers key
|
|
11
|
+
* the internal secret by this - never by `path`.
|
|
12
|
+
*/
|
|
13
|
+
secretId: string | undefined;
|
|
14
|
+
/** The current string value at this leaf. */
|
|
15
|
+
value: string;
|
|
16
|
+
}
|
|
3
17
|
|
|
4
18
|
/**
|
|
5
19
|
* Generic schema-driven walk over `x-secret`-annotated string fields.
|
|
@@ -20,7 +34,7 @@ import { isSecretSchema } from "@checkstack/backend-api";
|
|
|
20
34
|
export async function walkSecretFields(params: {
|
|
21
35
|
value: unknown;
|
|
22
36
|
schema: z.ZodTypeAny;
|
|
23
|
-
visit: (input:
|
|
37
|
+
visit: (input: SecretFieldVisit) => Promise<string>;
|
|
24
38
|
}): Promise<unknown> {
|
|
25
39
|
return walk({
|
|
26
40
|
value: params.value,
|
|
@@ -33,7 +47,7 @@ export async function walkSecretFields(params: {
|
|
|
33
47
|
async function walk(params: {
|
|
34
48
|
value: unknown;
|
|
35
49
|
schema: z.ZodTypeAny;
|
|
36
|
-
visit: (input:
|
|
50
|
+
visit: (input: SecretFieldVisit) => Promise<string>;
|
|
37
51
|
path: string;
|
|
38
52
|
}): Promise<unknown> {
|
|
39
53
|
const { value, visit, path } = params;
|
|
@@ -46,7 +60,11 @@ async function walk(params: {
|
|
|
46
60
|
// x-secret string leaf: hand it to the visitor.
|
|
47
61
|
if (isSecretSchema(schema)) {
|
|
48
62
|
if (typeof value === "string") {
|
|
49
|
-
return visit({
|
|
63
|
+
return visit({
|
|
64
|
+
path: path || "(root)",
|
|
65
|
+
secretId: getSecretId(schema),
|
|
66
|
+
value,
|
|
67
|
+
});
|
|
50
68
|
}
|
|
51
69
|
return value;
|
|
52
70
|
}
|
|
@@ -127,14 +145,21 @@ async function walk(params: {
|
|
|
127
145
|
|
|
128
146
|
function unwrapZod(schema: z.ZodTypeAny): z.ZodTypeAny {
|
|
129
147
|
let unwrapped = schema;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
unwrapped
|
|
148
|
+
// Loop so multi-level wrappers (e.g. `.optional().default({})`,
|
|
149
|
+
// `.default().nullable()`) are fully peeled. A single fixed-order pass leaves
|
|
150
|
+
// an inner wrapper on some combos, which would make a secret-bearing CONTAINER
|
|
151
|
+
// (object/array/union) miss its `instanceof` check and skip extraction -
|
|
152
|
+
// leaving the inline secret plaintext at rest. Mirrors the looping unwrap in
|
|
153
|
+
// zod-config / config-secret-channel so all walkers agree on field identity.
|
|
154
|
+
for (;;) {
|
|
155
|
+
if (unwrapped instanceof z.ZodOptional || unwrapped instanceof z.ZodNullable) {
|
|
156
|
+
unwrapped = unwrapped.unwrap() as z.ZodTypeAny;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (unwrapped instanceof z.ZodDefault) {
|
|
160
|
+
unwrapped = unwrapped.def.innerType as z.ZodTypeAny;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
return unwrapped;
|
|
138
164
|
}
|
|
139
|
-
return unwrapped;
|
|
140
165
|
}
|