@checkstack/automation-backend 0.2.0 → 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 +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- package/tsconfig.json +24 -0
|
@@ -53,6 +53,40 @@ describe("renderConfig", () => {
|
|
|
53
53
|
expect(out).toEqual({ ts: "const x = {{ trigger.payload.title }};" });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it("leaves x-secret-env fields verbatim so ${{ secrets.NAME }} survives", () => {
|
|
57
|
+
// The secret syntax embeds `{{ secrets.NAME }}`; without the skip the
|
|
58
|
+
// engine would evaluate it (no `secrets` in scope) and collapse the value
|
|
59
|
+
// to "$", failing config validation. See the secretEnv dispatch bug.
|
|
60
|
+
const out = renderConfig({
|
|
61
|
+
config: {
|
|
62
|
+
secretEnv: { secret: "${{ secrets.SECRET }}" },
|
|
63
|
+
message: "hi {{ trigger.payload.title }}",
|
|
64
|
+
},
|
|
65
|
+
jsonSchema: {
|
|
66
|
+
properties: {
|
|
67
|
+
secretEnv: { "x-secret-env": true },
|
|
68
|
+
message: { type: "string" },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
context,
|
|
72
|
+
filters,
|
|
73
|
+
});
|
|
74
|
+
expect(out).toEqual({
|
|
75
|
+
secretEnv: { secret: "${{ secrets.SECRET }}" },
|
|
76
|
+
message: "hi Outage",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("leaves a single x-secret field verbatim", () => {
|
|
81
|
+
const out = renderConfig({
|
|
82
|
+
config: { token: "${{ secrets.API_TOKEN }}" },
|
|
83
|
+
jsonSchema: { properties: { token: { "x-secret": true } } },
|
|
84
|
+
context,
|
|
85
|
+
filters,
|
|
86
|
+
});
|
|
87
|
+
expect(out).toEqual({ token: "${{ secrets.API_TOKEN }}" });
|
|
88
|
+
});
|
|
89
|
+
|
|
56
90
|
it("falls back to plain rendering when no schema is supplied", () => {
|
|
57
91
|
const out = renderConfig({
|
|
58
92
|
config: { a: "{{ trigger.payload.title }}" },
|
package/src/dispatch/render.ts
CHANGED
|
@@ -47,15 +47,33 @@ function hasCodeEditorType(propSchema: unknown): boolean {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
50
|
+
* True for a secret-bearing field: a single `${{ secrets.NAME }}` reference
|
|
51
|
+
* (`x-secret`) or a secret→env mapping whose values are such references
|
|
52
|
+
* (`x-secret-env`). These MUST NOT be template-rendered: the secret syntax
|
|
53
|
+
* `${{ secrets.NAME }}` embeds `{{ … }}`, so the `{{ secrets.NAME }}` inside
|
|
54
|
+
* collides with automation interpolation and the engine would evaluate it
|
|
55
|
+
* (against a scope with no `secrets`), collapsing the value to `$` before the
|
|
56
|
+
* secret resolver ever runs. Passing these fields through verbatim keeps the
|
|
57
|
+
* reference intact for resolution at run/test time.
|
|
58
|
+
*/
|
|
59
|
+
function hasSecretType(propSchema: unknown): boolean {
|
|
60
|
+
const rec = asRecord(propSchema);
|
|
61
|
+
return rec["x-secret"] === true || rec["x-secret-env"] === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Render an action's top-level `config`, skipping native-code and secret
|
|
66
|
+
* fields.
|
|
51
67
|
*
|
|
52
|
-
* Identical to {@link renderValue} for every field EXCEPT those whose
|
|
53
|
-
*
|
|
54
|
-
* `javascript
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
68
|
+
* Identical to {@link renderValue} for every field EXCEPT those whose JSON
|
|
69
|
+
* schema declares an `x-editor-types` of `shell` / `typescript` /
|
|
70
|
+
* `javascript` (code fields), or `x-secret` / `x-secret-env` (secret fields):
|
|
71
|
+
* those are returned verbatim. Code fields keep any `{{ }}` literal (they get
|
|
72
|
+
* run data via `context` / env vars). Secret fields keep their
|
|
73
|
+
* `${{ secrets.NAME }}` reference intact for the secret resolver — rendering
|
|
74
|
+
* them would evaluate the embedded `{{ secrets.NAME }}` and destroy the
|
|
75
|
+
* reference. Falls back to {@link renderValue} when the config isn't a plain
|
|
76
|
+
* object or no schema is available.
|
|
59
77
|
*/
|
|
60
78
|
export function renderConfig(args: {
|
|
61
79
|
config: unknown;
|
|
@@ -70,9 +88,11 @@ export function renderConfig(args: {
|
|
|
70
88
|
const properties = asRecord(jsonSchema?.["properties"]);
|
|
71
89
|
const out: Record<string, unknown> = {};
|
|
72
90
|
for (const [key, value] of Object.entries(asRecord(config))) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
const propSchema = properties[key];
|
|
92
|
+
out[key] =
|
|
93
|
+
hasCodeEditorType(propSchema) || hasSecretType(propSchema)
|
|
94
|
+
? value
|
|
95
|
+
: renderValue(value, context, filters);
|
|
76
96
|
}
|
|
77
97
|
return out;
|
|
78
98
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createServiceRef, type ServiceRef } from "@checkstack/backend-api";
|
|
2
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
3
|
+
import type { AutomationDefinition, Action } from "@checkstack/automation-common";
|
|
4
|
+
|
|
5
|
+
import type { RunSecretRegistry } from "./run-secret-registry";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cross-pod mask re-seeding for resume / stalled-recovery.
|
|
9
|
+
*
|
|
10
|
+
* The run-wide masking registry (see {@link RunSecretRegistry}) is
|
|
11
|
+
* IN-MEMORY and per-process: it only holds the secret values a run resolved
|
|
12
|
+
* on THIS pod. Capture happens lazily, as actions resolve secrets through
|
|
13
|
+
* the wrapped `getService`.
|
|
14
|
+
*
|
|
15
|
+
* That breaks across pods. When pod A resolves a connection credential and
|
|
16
|
+
* the run SUSPENDS (`wait_for_trigger` / `delay` / `wait_until`), and pod B
|
|
17
|
+
* later resumes that run with a FRESH, empty registry, every masking choke
|
|
18
|
+
* point on pod B (step output, run error, scope snapshot, artifact data)
|
|
19
|
+
* runs against an EMPTY mask set. Any persisted value that still contains
|
|
20
|
+
* pod-A's resolved secret — e.g. an error string, a scope variable carried
|
|
21
|
+
* over the suspension, or an artifact echoing a credential — would be
|
|
22
|
+
* written UNMASKED, leaking it to `getRunScopeForReplay` and the run-detail
|
|
23
|
+
* UI. This is the deferred "L2 cross-pod masking" gap.
|
|
24
|
+
*
|
|
25
|
+
* The fix: BEFORE pod B walks/persists anything, RE-SEED its registry by
|
|
26
|
+
* re-resolving the automation's DECLARED secret refs — the `secretEnv`
|
|
27
|
+
* mappings and the `connectionId` references the definition uses — through
|
|
28
|
+
* the run's already-wrapped `getService`. The wrapper auto-registers every
|
|
29
|
+
* resolved value, so pod B re-populates exactly the least-privilege mask
|
|
30
|
+
* set the run is allowed to see (Jenkins-style, by-value). Re-resolving is
|
|
31
|
+
* the SAME set the run resolves during normal execution, so it grants no
|
|
32
|
+
* extra access.
|
|
33
|
+
*
|
|
34
|
+
* Best-effort by construction: a declared secret that no longer resolves
|
|
35
|
+
* (rotated / deleted) simply isn't added to the mask set — the run would
|
|
36
|
+
* have failed re-resolving it during the walk anyway. A resolution failure
|
|
37
|
+
* here must never abort the resume, so each ref is resolved independently
|
|
38
|
+
* and failures are swallowed (the engine surfaces a genuinely-missing
|
|
39
|
+
* secret when the action re-runs).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Shapes of the two value-returning services we re-resolve through. */
|
|
43
|
+
interface ResolverLike {
|
|
44
|
+
resolveForRun(input: { secretEnv: Record<string, string> }): Promise<{
|
|
45
|
+
env: Record<string, string>;
|
|
46
|
+
masking: unknown;
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
interface ConnectionStoreLike {
|
|
50
|
+
getConnectionWithCredentials(
|
|
51
|
+
connectionId: string,
|
|
52
|
+
): Promise<{ config: Record<string, unknown> } | undefined>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasResolveForRun(s: unknown): s is ResolverLike {
|
|
56
|
+
return (
|
|
57
|
+
typeof s === "object" &&
|
|
58
|
+
s !== null &&
|
|
59
|
+
typeof (s as ResolverLike).resolveForRun === "function"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
function hasConnectionCredentials(s: unknown): s is ConnectionStoreLike {
|
|
63
|
+
return (
|
|
64
|
+
typeof s === "object" &&
|
|
65
|
+
s !== null &&
|
|
66
|
+
typeof (s as ConnectionStoreLike).getConnectionWithCredentials ===
|
|
67
|
+
"function"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The declared secret references found by walking a definition's actions. */
|
|
72
|
+
interface DeclaredSecretRefs {
|
|
73
|
+
/** Distinct `secretEnv` mappings (`{ ENV: "${{ secrets.NAME }}" }`). */
|
|
74
|
+
secretEnvMaps: Record<string, string>[];
|
|
75
|
+
/** Distinct, literal `connectionId` values referenced by action configs. */
|
|
76
|
+
connectionIds: Set<string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Collect every `secretEnv` map and literal `connectionId` declared in an
|
|
81
|
+
* automation's action configs, walking the full nested action tree
|
|
82
|
+
* (choose / parallel / repeat / sequence). Templated connection ids (those
|
|
83
|
+
* containing a `${{ … }}` expression) are skipped — they can only be
|
|
84
|
+
* resolved against live scope, and the action that uses them re-registers
|
|
85
|
+
* its credential when it re-runs.
|
|
86
|
+
*/
|
|
87
|
+
export function collectDeclaredSecretRefs(
|
|
88
|
+
definition: AutomationDefinition,
|
|
89
|
+
): DeclaredSecretRefs {
|
|
90
|
+
const secretEnvMaps: Record<string, string>[] = [];
|
|
91
|
+
const connectionIds = new Set<string>();
|
|
92
|
+
|
|
93
|
+
const visitConfig = (config: Record<string, unknown>): void => {
|
|
94
|
+
const secretEnv = config.secretEnv;
|
|
95
|
+
if (isStringRecord(secretEnv) && Object.keys(secretEnv).length > 0) {
|
|
96
|
+
secretEnvMaps.push(secretEnv);
|
|
97
|
+
}
|
|
98
|
+
const connectionId = config.connectionId;
|
|
99
|
+
if (
|
|
100
|
+
typeof connectionId === "string" &&
|
|
101
|
+
connectionId.length > 0 &&
|
|
102
|
+
!connectionId.includes("${{")
|
|
103
|
+
) {
|
|
104
|
+
connectionIds.add(connectionId);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const visit = (action: Action): void => {
|
|
109
|
+
if ("action" in action && isPlainObject(action.config)) {
|
|
110
|
+
visitConfig(action.config);
|
|
111
|
+
}
|
|
112
|
+
if ("choose" in action) {
|
|
113
|
+
for (const branch of action.choose) visitAll(branch.sequence);
|
|
114
|
+
if (action.else) visitAll(action.else);
|
|
115
|
+
}
|
|
116
|
+
if ("parallel" in action) visitAll(action.parallel);
|
|
117
|
+
if ("repeat" in action) visitAll(action.repeat.sequence);
|
|
118
|
+
if ("sequence" in action) visitAll(action.sequence);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const visitAll = (actions: ReadonlyArray<Action>): void => {
|
|
122
|
+
for (const action of actions) visit(action);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
visitAll(definition.actions);
|
|
126
|
+
return { secretEnvMaps, connectionIds };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
130
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
134
|
+
return (
|
|
135
|
+
isPlainObject(value) &&
|
|
136
|
+
Object.values(value).every((v) => typeof v === "string")
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ReseedRunSecretRegistryArgs {
|
|
141
|
+
/**
|
|
142
|
+
* The run's ALREADY-WRAPPED `getService` (the same one the engine threads
|
|
143
|
+
* through dispatch). Resolving the secret resolver / connection store
|
|
144
|
+
* through it auto-registers every resolved value into the run's mask set.
|
|
145
|
+
*/
|
|
146
|
+
getService: <T>(ref: ServiceRef<T>) => Promise<T>;
|
|
147
|
+
registry: RunSecretRegistry;
|
|
148
|
+
runId: string;
|
|
149
|
+
definition: AutomationDefinition;
|
|
150
|
+
/** Service-ref id of the secret resolver (`secretResolverRef.id`). */
|
|
151
|
+
resolverRefId: string;
|
|
152
|
+
/** Service-ref id of the connection store (`connectionStoreRef.id`). */
|
|
153
|
+
connectionStoreRefId: string;
|
|
154
|
+
logger?: { debug(msg: string): void; warn(msg: string): void };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Re-resolve an automation's declared secret refs through the run's wrapped
|
|
159
|
+
* `getService`, re-populating the run's in-memory mask set on the resuming
|
|
160
|
+
* pod. Call this on `resumeRun` / `recoverStalledRun` BEFORE walking the
|
|
161
|
+
* tree, so the masking choke points have the full mask set on the new pod.
|
|
162
|
+
*/
|
|
163
|
+
export async function reseedRunSecretRegistry(
|
|
164
|
+
args: ReseedRunSecretRegistryArgs,
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
const { secretEnvMaps, connectionIds } = collectDeclaredSecretRefs(
|
|
167
|
+
args.definition,
|
|
168
|
+
);
|
|
169
|
+
if (secretEnvMaps.length === 0 && connectionIds.size === 0) return;
|
|
170
|
+
|
|
171
|
+
// Resolve the two services once through the wrapped getService. The
|
|
172
|
+
// wrapper returns the value-registering proxies, so the calls below feed
|
|
173
|
+
// the run's mask set.
|
|
174
|
+
let resolver: ResolverLike | undefined;
|
|
175
|
+
let connectionStore: ConnectionStoreLike | undefined;
|
|
176
|
+
|
|
177
|
+
if (secretEnvMaps.length > 0) {
|
|
178
|
+
try {
|
|
179
|
+
const svc = await args.getService(
|
|
180
|
+
createServiceRef<unknown>(args.resolverRefId),
|
|
181
|
+
);
|
|
182
|
+
if (hasResolveForRun(svc)) resolver = svc;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
args.logger?.warn(
|
|
185
|
+
`reseed: could not resolve secret resolver for run ${args.runId}: ${extractErrorMessage(error)}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (connectionIds.size > 0) {
|
|
190
|
+
try {
|
|
191
|
+
const svc = await args.getService(
|
|
192
|
+
createServiceRef<unknown>(args.connectionStoreRefId),
|
|
193
|
+
);
|
|
194
|
+
if (hasConnectionCredentials(svc)) connectionStore = svc;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
args.logger?.warn(
|
|
197
|
+
`reseed: could not resolve connection store for run ${args.runId}: ${extractErrorMessage(error)}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (resolver) {
|
|
203
|
+
for (const secretEnv of secretEnvMaps) {
|
|
204
|
+
try {
|
|
205
|
+
// The wrapped resolver registers every resolved value as a side
|
|
206
|
+
// effect; we don't need the returned env.
|
|
207
|
+
await resolver.resolveForRun({ secretEnv });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// A rotated/missing secret would fail the action's own re-run; here
|
|
210
|
+
// we just skip adding it to the mask set.
|
|
211
|
+
args.logger?.debug(
|
|
212
|
+
`reseed: secretEnv re-resolve skipped for run ${args.runId}: ${extractErrorMessage(error)}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (connectionStore) {
|
|
219
|
+
for (const connectionId of connectionIds) {
|
|
220
|
+
try {
|
|
221
|
+
await connectionStore.getConnectionWithCredentials(connectionId);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
args.logger?.debug(
|
|
224
|
+
`reseed: connection re-resolve skipped for run ${args.runId} (${connectionId}): ${extractErrorMessage(error)}`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import type { ServiceRef } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
createRunSecretRegistry,
|
|
5
|
+
wrapGetServiceForRun,
|
|
6
|
+
} from "./run-secret-registry";
|
|
7
|
+
|
|
8
|
+
const RUN = "run-1";
|
|
9
|
+
|
|
10
|
+
describe("RunSecretRegistry", () => {
|
|
11
|
+
it("masks registered values out of text + deep payloads for a run", () => {
|
|
12
|
+
const reg = createRunSecretRegistry();
|
|
13
|
+
reg.register(RUN, ["sk-live-9999", "db-pass-7777"]);
|
|
14
|
+
expect(reg.maskText(RUN, "auth=sk-live-9999")).toBe("auth=****");
|
|
15
|
+
expect(reg.maskDeep(RUN, { token: "db-pass-7777", n: 1 })).toEqual({
|
|
16
|
+
token: "****",
|
|
17
|
+
n: 1,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("is least-privilege: a value NOT registered for the run is not masked", () => {
|
|
22
|
+
const reg = createRunSecretRegistry();
|
|
23
|
+
reg.register(RUN, ["resolved-in-run"]);
|
|
24
|
+
// A different secret that this run never resolved must pass through.
|
|
25
|
+
expect(reg.maskText(RUN, "x=some-other-secret")).toBe("x=some-other-secret");
|
|
26
|
+
// And a value registered for a DIFFERENT run doesn't leak across.
|
|
27
|
+
reg.register("run-2", ["run-2-secret"]);
|
|
28
|
+
expect(reg.maskText(RUN, "y=run-2-secret")).toBe("y=run-2-secret");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("masks step output via the stepId link", () => {
|
|
32
|
+
const reg = createRunSecretRegistry();
|
|
33
|
+
reg.register(RUN, ["step-secret-abc"]);
|
|
34
|
+
reg.linkStep("step-1", RUN);
|
|
35
|
+
expect(reg.maskTextForStep("step-1", "v=step-secret-abc")).toBe("v=****");
|
|
36
|
+
expect(reg.maskDeepForStep("step-1", { v: "step-secret-abc" })).toEqual({
|
|
37
|
+
v: "****",
|
|
38
|
+
});
|
|
39
|
+
// An unknown step is a no-op (no run link).
|
|
40
|
+
expect(reg.maskTextForStep("unknown", "v=step-secret-abc")).toBe(
|
|
41
|
+
"v=step-secret-abc",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("drop() clears a run's values + step links (memory-only)", () => {
|
|
46
|
+
const reg = createRunSecretRegistry();
|
|
47
|
+
reg.register(RUN, ["gone-after-drop"]);
|
|
48
|
+
reg.linkStep("step-1", RUN);
|
|
49
|
+
reg.drop(RUN);
|
|
50
|
+
expect(reg.maskText(RUN, "x=gone-after-drop")).toBe("x=gone-after-drop");
|
|
51
|
+
expect(reg.maskTextForStep("step-1", "x=gone-after-drop")).toBe(
|
|
52
|
+
"x=gone-after-drop",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("wrapGetServiceForRun", () => {
|
|
58
|
+
function ref(id: string): ServiceRef<unknown> {
|
|
59
|
+
return { id, T: undefined, toString: () => id };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ResolverShape {
|
|
63
|
+
resolveSecret(input: { name: string }): Promise<string>;
|
|
64
|
+
resolveForRun(input: {
|
|
65
|
+
secretEnv: Record<string, string>;
|
|
66
|
+
}): Promise<{ env: Record<string, string>; masking: unknown }>;
|
|
67
|
+
resolveBySchema(input: {
|
|
68
|
+
value: unknown;
|
|
69
|
+
schema: unknown;
|
|
70
|
+
}): Promise<{ resolved: unknown; warnings: string[] }>;
|
|
71
|
+
}
|
|
72
|
+
interface StoreShape {
|
|
73
|
+
getConnectionWithCredentials(
|
|
74
|
+
id: string,
|
|
75
|
+
): Promise<{ config: Record<string, unknown> } | undefined>;
|
|
76
|
+
listConnections(): Promise<unknown[]>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A getService that returns `svc` for any ref. */
|
|
80
|
+
function constGetService<S>(svc: S): <T>(r: ServiceRef<T>) => Promise<T> {
|
|
81
|
+
return <T>(_r: ServiceRef<T>) => Promise.resolve(svc as unknown as T);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it("registers values resolved via the resolver service (resolveForRun + resolveSecret)", async () => {
|
|
85
|
+
const reg = createRunSecretRegistry();
|
|
86
|
+
const resolver: ResolverShape = {
|
|
87
|
+
resolveSecret: async () => "single-secret",
|
|
88
|
+
resolveForRun: async () => ({
|
|
89
|
+
env: { A: "env-secret-1", B: "env-secret-2" },
|
|
90
|
+
masking: {},
|
|
91
|
+
}),
|
|
92
|
+
resolveBySchema: async () => ({
|
|
93
|
+
resolved: { password: "schema-secret" },
|
|
94
|
+
warnings: [],
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
const wrapped = wrapGetServiceForRun({
|
|
98
|
+
getService: constGetService(resolver),
|
|
99
|
+
runId: RUN,
|
|
100
|
+
registry: reg,
|
|
101
|
+
resolverRefId: "secrets.resolver",
|
|
102
|
+
connectionStoreRefId: "integration.connectionStore",
|
|
103
|
+
});
|
|
104
|
+
const svc = await wrapped(ref("secrets.resolver") as ServiceRef<ResolverShape>);
|
|
105
|
+
await svc.resolveForRun({ secretEnv: {} });
|
|
106
|
+
await svc.resolveSecret({ name: "x" });
|
|
107
|
+
await svc.resolveBySchema({ value: {}, schema: {} });
|
|
108
|
+
// Every resolved value is now in the run's mask set.
|
|
109
|
+
expect(reg.maskText(RUN, "env-secret-1 env-secret-2 single-secret schema-secret")).toBe(
|
|
110
|
+
"**** **** **** ****",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("L3: forwards a non-intercepted resolver method untouched (Proxy + Reflect.get)", async () => {
|
|
115
|
+
const reg = createRunSecretRegistry();
|
|
116
|
+
// Resolver carries an extra method the wrapper does NOT intercept; a
|
|
117
|
+
// hand-built literal would have dropped it.
|
|
118
|
+
const resolver: ResolverShape & {
|
|
119
|
+
describeNamedSecret: (name: string) => string;
|
|
120
|
+
} = {
|
|
121
|
+
resolveSecret: async (_input: { name: string }) =>
|
|
122
|
+
"intercepted-secret-value",
|
|
123
|
+
resolveForRun: async (_input: { secretEnv: Record<string, string> }) => ({
|
|
124
|
+
env: {},
|
|
125
|
+
masking: {},
|
|
126
|
+
}),
|
|
127
|
+
resolveBySchema: async (_input: { value: unknown; schema: unknown }) => ({
|
|
128
|
+
resolved: {},
|
|
129
|
+
warnings: [],
|
|
130
|
+
}),
|
|
131
|
+
// Not one of the three value-returning methods — must survive.
|
|
132
|
+
describeNamedSecret: (name: string) => `meta:${name}`,
|
|
133
|
+
};
|
|
134
|
+
const wrapped = wrapGetServiceForRun({
|
|
135
|
+
getService: constGetService(resolver),
|
|
136
|
+
runId: RUN,
|
|
137
|
+
registry: reg,
|
|
138
|
+
resolverRefId: "secrets.resolver",
|
|
139
|
+
connectionStoreRefId: "integration.connectionStore",
|
|
140
|
+
});
|
|
141
|
+
const svc = (await wrapped(
|
|
142
|
+
ref("secrets.resolver") as ServiceRef<typeof resolver>,
|
|
143
|
+
)) as typeof resolver;
|
|
144
|
+
// The non-intercepted method is forwarded via Reflect.get, not dropped.
|
|
145
|
+
expect(typeof svc.describeNamedSecret).toBe("function");
|
|
146
|
+
expect(svc.describeNamedSecret("API")).toBe("meta:API");
|
|
147
|
+
// And the intercepted method still registers its resolved value.
|
|
148
|
+
await svc.resolveSecret({ name: "x" });
|
|
149
|
+
expect(reg.maskText(RUN, "v=intercepted-secret-value")).toBe("v=****");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("registers values resolved via the connection store credentials", async () => {
|
|
153
|
+
const reg = createRunSecretRegistry();
|
|
154
|
+
const store: StoreShape = {
|
|
155
|
+
getConnectionWithCredentials: async () => ({
|
|
156
|
+
config: { baseUrl: "https://x", apiToken: "conn-cred-secret" },
|
|
157
|
+
}),
|
|
158
|
+
// an unrelated passthrough method
|
|
159
|
+
listConnections: async () => [],
|
|
160
|
+
};
|
|
161
|
+
const wrapped = wrapGetServiceForRun({
|
|
162
|
+
getService: constGetService(store),
|
|
163
|
+
runId: RUN,
|
|
164
|
+
registry: reg,
|
|
165
|
+
resolverRefId: "secrets.resolver",
|
|
166
|
+
connectionStoreRefId: "integration.connectionStore",
|
|
167
|
+
});
|
|
168
|
+
const svc = await wrapped(
|
|
169
|
+
ref("integration.connectionStore") as ServiceRef<StoreShape>,
|
|
170
|
+
);
|
|
171
|
+
await svc.getConnectionWithCredentials("c1");
|
|
172
|
+
expect(reg.maskText(RUN, "token=conn-cred-secret")).toBe("token=****");
|
|
173
|
+
// Passthrough methods still work.
|
|
174
|
+
expect(await svc.listConnections()).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("passes other services through untouched", async () => {
|
|
178
|
+
const reg = createRunSecretRegistry();
|
|
179
|
+
const other = { foo: () => "bar" };
|
|
180
|
+
const wrapped = wrapGetServiceForRun({
|
|
181
|
+
getService: constGetService(other),
|
|
182
|
+
runId: RUN,
|
|
183
|
+
registry: reg,
|
|
184
|
+
resolverRefId: "secrets.resolver",
|
|
185
|
+
connectionStoreRefId: "integration.connectionStore",
|
|
186
|
+
});
|
|
187
|
+
expect(await wrapped(ref("some.other.service"))).toBe(other);
|
|
188
|
+
});
|
|
189
|
+
});
|