@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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { ServiceRef } from "@checkstack/backend-api";
|
|
2
|
+
import {
|
|
3
|
+
maskSecrets,
|
|
4
|
+
maskSecretsDeep,
|
|
5
|
+
} from "@checkstack/secrets-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run-scoped accumulation of every secret VALUE resolved during a dispatch
|
|
9
|
+
* run, so the run-state persistence layer can mask it out of step / run
|
|
10
|
+
* output before it is written (and therefore before any DTO / run-detail
|
|
11
|
+
* page can read it). Jenkins-style, by-value, least-privilege: a run's set
|
|
12
|
+
* holds ONLY the values that run actually resolved.
|
|
13
|
+
*
|
|
14
|
+
* Capture happens by wrapping the run's `getService` (see
|
|
15
|
+
* {@link wrapGetServiceForRun}) so that resolving the secret resolver or
|
|
16
|
+
* connection store during the run registers the resolved values here. The
|
|
17
|
+
* resolved values themselves stay in memory only — this registry is never
|
|
18
|
+
* persisted, and a run's entry is dropped when the run reaches a terminal
|
|
19
|
+
* state.
|
|
20
|
+
*/
|
|
21
|
+
export interface RunSecretRegistry {
|
|
22
|
+
/** Register resolved secret values for a run (deduped, in memory). */
|
|
23
|
+
register(runId: string, values: Iterable<string>): void;
|
|
24
|
+
/**
|
|
25
|
+
* Associate a step with its run, so step writes (which carry only a
|
|
26
|
+
* stepId) can find the run's mask set without threading runId through
|
|
27
|
+
* every `updateStep` caller.
|
|
28
|
+
*/
|
|
29
|
+
linkStep(stepId: string, runId: string): void;
|
|
30
|
+
/** Mask every registered value out of `text` for a run. No-op if none. */
|
|
31
|
+
maskText(runId: string, text: string): string;
|
|
32
|
+
/** Mask every registered value out of a JSON-like payload for a run. */
|
|
33
|
+
maskDeep(runId: string, value: unknown): unknown;
|
|
34
|
+
/** Mask `text` using the run that owns `stepId`. */
|
|
35
|
+
maskTextForStep(stepId: string, text: string): string;
|
|
36
|
+
/** Mask a payload using the run that owns `stepId`. */
|
|
37
|
+
maskDeepForStep(stepId: string, value: unknown): unknown;
|
|
38
|
+
/** Drop a run's accumulated values + its step links (terminal status). */
|
|
39
|
+
drop(runId: string): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createRunSecretRegistry(): RunSecretRegistry {
|
|
43
|
+
const byRun = new Map<string, Set<string>>();
|
|
44
|
+
const stepToRun = new Map<string, string>();
|
|
45
|
+
|
|
46
|
+
const maskTextForRun = (runId: string, text: string): string => {
|
|
47
|
+
const set = byRun.get(runId);
|
|
48
|
+
if (!set || set.size === 0) return text;
|
|
49
|
+
return maskSecrets({ text, values: set });
|
|
50
|
+
};
|
|
51
|
+
const maskDeepForRun = (runId: string, value: unknown): unknown => {
|
|
52
|
+
const set = byRun.get(runId);
|
|
53
|
+
if (!set || set.size === 0) return value;
|
|
54
|
+
return maskSecretsDeep({ value, values: set });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
register(runId, values) {
|
|
59
|
+
let set = byRun.get(runId);
|
|
60
|
+
if (!set) {
|
|
61
|
+
set = new Set<string>();
|
|
62
|
+
byRun.set(runId, set);
|
|
63
|
+
}
|
|
64
|
+
for (const v of values) {
|
|
65
|
+
if (typeof v === "string" && v.length > 0) set.add(v);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
linkStep(stepId, runId) {
|
|
70
|
+
stepToRun.set(stepId, runId);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
maskText: maskTextForRun,
|
|
74
|
+
maskDeep: maskDeepForRun,
|
|
75
|
+
|
|
76
|
+
maskTextForStep(stepId, text) {
|
|
77
|
+
const runId = stepToRun.get(stepId);
|
|
78
|
+
return runId ? maskTextForRun(runId, text) : text;
|
|
79
|
+
},
|
|
80
|
+
maskDeepForStep(stepId, value) {
|
|
81
|
+
const runId = stepToRun.get(stepId);
|
|
82
|
+
return runId ? maskDeepForRun(runId, value) : value;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
drop(runId) {
|
|
86
|
+
byRun.delete(runId);
|
|
87
|
+
for (const [stepId, rid] of stepToRun) {
|
|
88
|
+
if (rid === runId) stepToRun.delete(stepId);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wrap a run's `getService` so resolving the secret resolver or the
|
|
96
|
+
* connection store registers every resolved value into the
|
|
97
|
+
* {@link RunSecretRegistry} for this run. This is the single capture point
|
|
98
|
+
* for run-wide masking: any action that resolves secrets (script
|
|
99
|
+
* `secretEnv`, a `${{ secrets.NAME }}` template, or a provider connection
|
|
100
|
+
* credential) contributes its resolved values to the run's mask set,
|
|
101
|
+
* least-privilege.
|
|
102
|
+
*
|
|
103
|
+
* `refs` carries the ids to intercept (the resolver + connection-store
|
|
104
|
+
* refs); other services pass through untouched. Ids are passed in (rather
|
|
105
|
+
* than imported) to avoid a hard dependency cycle on the secrets / integration
|
|
106
|
+
* packages from the dispatch core.
|
|
107
|
+
*/
|
|
108
|
+
export function wrapGetServiceForRun(input: {
|
|
109
|
+
getService: <T>(ref: ServiceRef<T>) => Promise<T>;
|
|
110
|
+
runId: string;
|
|
111
|
+
registry: RunSecretRegistry;
|
|
112
|
+
/** Service-ref id of the secret resolver (`secretResolverRef`). */
|
|
113
|
+
resolverRefId: string;
|
|
114
|
+
/** Service-ref id of the connection store (`connectionStoreRef`). */
|
|
115
|
+
connectionStoreRefId: string;
|
|
116
|
+
}): <T>(ref: ServiceRef<T>) => Promise<T> {
|
|
117
|
+
const { getService, runId, registry, resolverRefId, connectionStoreRefId } =
|
|
118
|
+
input;
|
|
119
|
+
|
|
120
|
+
return async <T>(ref: ServiceRef<T>): Promise<T> => {
|
|
121
|
+
const service = await getService(ref);
|
|
122
|
+
if (ref.id === resolverRefId) {
|
|
123
|
+
return wrapResolver(service, runId, registry);
|
|
124
|
+
}
|
|
125
|
+
if (ref.id === connectionStoreRefId) {
|
|
126
|
+
return wrapConnectionStore(service, runId, registry);
|
|
127
|
+
}
|
|
128
|
+
return service;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Service proxies that register resolved values ─────────────────────────
|
|
133
|
+
|
|
134
|
+
// Minimal structural shapes of the methods that return secret values. We
|
|
135
|
+
// avoid importing the concrete service types to keep the dispatch core free
|
|
136
|
+
// of a dependency on the secrets / integration packages.
|
|
137
|
+
|
|
138
|
+
interface ResolverLike {
|
|
139
|
+
resolveSecret(input: { name: string }): Promise<string>;
|
|
140
|
+
resolveForRun(input: { secretEnv: Record<string, string> }): Promise<{
|
|
141
|
+
env: Record<string, string>;
|
|
142
|
+
masking: unknown;
|
|
143
|
+
}>;
|
|
144
|
+
resolveBySchema(input: {
|
|
145
|
+
value: unknown;
|
|
146
|
+
schema: unknown;
|
|
147
|
+
}): Promise<{ resolved: unknown; warnings: string[] }>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface ConnectionStoreLike {
|
|
151
|
+
getConnectionWithCredentials(
|
|
152
|
+
connectionId: string,
|
|
153
|
+
): Promise<{ config: Record<string, unknown> } | undefined>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hasResolverShape(s: unknown): s is ResolverLike {
|
|
157
|
+
return (
|
|
158
|
+
typeof s === "object" &&
|
|
159
|
+
s !== null &&
|
|
160
|
+
typeof (s as ResolverLike).resolveForRun === "function" &&
|
|
161
|
+
typeof (s as ResolverLike).resolveSecret === "function"
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasConnectionStoreShape(s: unknown): s is ConnectionStoreLike {
|
|
166
|
+
return (
|
|
167
|
+
typeof s === "object" &&
|
|
168
|
+
s !== null &&
|
|
169
|
+
typeof (s as ConnectionStoreLike).getConnectionWithCredentials ===
|
|
170
|
+
"function"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Collect every string leaf in a JSON-like value (resolved cred values). */
|
|
175
|
+
function collectStrings(value: unknown, out: string[]): void {
|
|
176
|
+
if (typeof value === "string") {
|
|
177
|
+
out.push(value);
|
|
178
|
+
} else if (Array.isArray(value)) {
|
|
179
|
+
for (const v of value) collectStrings(v, out);
|
|
180
|
+
} else if (value !== null && typeof value === "object") {
|
|
181
|
+
for (const v of Object.values(value)) collectStrings(v, out);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function wrapResolver<T>(service: T, runId: string, registry: RunSecretRegistry): T {
|
|
186
|
+
if (!hasResolverShape(service)) return service;
|
|
187
|
+
const inner = service as ResolverLike & Record<string, unknown>;
|
|
188
|
+
// Proxy that intercepts ONLY the three value-returning methods (to
|
|
189
|
+
// register resolved secrets) and forwards every other method / property
|
|
190
|
+
// untouched via Reflect.get. Mirrors `wrapConnectionStore` — a hand-built
|
|
191
|
+
// literal would silently drop any resolver method we didn't re-declare.
|
|
192
|
+
return new Proxy(inner, {
|
|
193
|
+
get(target, prop, receiver) {
|
|
194
|
+
if (prop === "resolveSecret") {
|
|
195
|
+
return async (input: { name: string }) => {
|
|
196
|
+
const value = await inner.resolveSecret(input);
|
|
197
|
+
registry.register(runId, [value]);
|
|
198
|
+
return value;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (prop === "resolveForRun") {
|
|
202
|
+
return async (input: { secretEnv: Record<string, string> }) => {
|
|
203
|
+
const result = await inner.resolveForRun(input);
|
|
204
|
+
registry.register(runId, Object.values(result.env));
|
|
205
|
+
return result;
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (prop === "resolveBySchema") {
|
|
209
|
+
return async (input: { value: unknown; schema: unknown }) => {
|
|
210
|
+
const result = await inner.resolveBySchema(input);
|
|
211
|
+
const strings: string[] = [];
|
|
212
|
+
collectStrings(result.resolved, strings);
|
|
213
|
+
registry.register(runId, strings);
|
|
214
|
+
return result;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return Reflect.get(target, prop, receiver);
|
|
218
|
+
},
|
|
219
|
+
}) as unknown as T;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function wrapConnectionStore<T>(
|
|
223
|
+
service: T,
|
|
224
|
+
runId: string,
|
|
225
|
+
registry: RunSecretRegistry,
|
|
226
|
+
): T {
|
|
227
|
+
if (!hasConnectionStoreShape(service)) return service;
|
|
228
|
+
const inner = service as ConnectionStoreLike & Record<string, unknown>;
|
|
229
|
+
// Preserve all the store's other methods; only the credential resolver
|
|
230
|
+
// registers values.
|
|
231
|
+
return new Proxy(inner, {
|
|
232
|
+
get(target, prop, receiver) {
|
|
233
|
+
if (prop === "getConnectionWithCredentials") {
|
|
234
|
+
return async (connectionId: string) => {
|
|
235
|
+
const conn = await inner.getConnectionWithCredentials(connectionId);
|
|
236
|
+
if (conn) {
|
|
237
|
+
const strings: string[] = [];
|
|
238
|
+
collectStrings(conn.config, strings);
|
|
239
|
+
registry.register(runId, strings);
|
|
240
|
+
}
|
|
241
|
+
return conn;
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return Reflect.get(target, prop, receiver);
|
|
245
|
+
},
|
|
246
|
+
}) as unknown as T;
|
|
247
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import type { ServiceRef } from "@checkstack/backend-api";
|
|
3
|
+
import { AutomationDefinitionSchema } from "@checkstack/automation-common";
|
|
4
|
+
import { createRunStore } from "./run-state";
|
|
5
|
+
import { createRunStateStore } from "./run-state-store";
|
|
6
|
+
import { createArtifactStore } from "../artifact-store";
|
|
7
|
+
import {
|
|
8
|
+
createRunSecretRegistry,
|
|
9
|
+
wrapGetServiceForRun,
|
|
10
|
+
} from "./run-secret-registry";
|
|
11
|
+
import { reseedRunSecretRegistry } from "./reseed-run-secrets";
|
|
12
|
+
import type { AdvisoryLockService } from "@checkstack/backend-api";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Asserts the run-state persistence CHOKE POINT masks resolved secret
|
|
16
|
+
* values out of step / run output before they are written — so any
|
|
17
|
+
* downstream read / DTO / run-detail page is safe by construction.
|
|
18
|
+
*
|
|
19
|
+
* Uses a capturing fake `db` (the established boundary avoids real-DB
|
|
20
|
+
* tests); we assert on the values the store hands to the DB layer.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface Captured {
|
|
24
|
+
inserts: Array<{ table: string; values: Record<string, unknown> }>;
|
|
25
|
+
updates: Array<Record<string, unknown>>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function capturingDb(captured: Captured) {
|
|
29
|
+
// The store calls: insert(table).values(v).returning() and
|
|
30
|
+
// update(table).set(v).where(). We capture v and synthesize ids.
|
|
31
|
+
let lastInsertTable = "";
|
|
32
|
+
const db = {
|
|
33
|
+
insert(table: { [k: string]: unknown }) {
|
|
34
|
+
lastInsertTable = String(
|
|
35
|
+
(table as { _?: { name?: string } })._?.name ?? "table",
|
|
36
|
+
);
|
|
37
|
+
return {
|
|
38
|
+
values(v: Record<string, unknown>) {
|
|
39
|
+
captured.inserts.push({ table: lastInsertTable, values: v });
|
|
40
|
+
return {
|
|
41
|
+
returning() {
|
|
42
|
+
return Promise.resolve([{ id: "generated-id", ...v }]);
|
|
43
|
+
},
|
|
44
|
+
onConflictDoUpdate() {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
update() {
|
|
52
|
+
return {
|
|
53
|
+
set(v: Record<string, unknown>) {
|
|
54
|
+
captured.updates.push(v);
|
|
55
|
+
return {
|
|
56
|
+
where() {
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
// The store's typed signature wants a SafeDatabase; this fake implements
|
|
65
|
+
// only the chains exercised here.
|
|
66
|
+
return db as unknown as Parameters<typeof createRunStore>[0];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("run-state masking choke point", () => {
|
|
70
|
+
it("masks a step resultPayload + errorMessage that contain a resolved secret before persist", async () => {
|
|
71
|
+
const captured: Captured = { inserts: [], updates: [] };
|
|
72
|
+
const registry = createRunSecretRegistry();
|
|
73
|
+
const store = createRunStore(capturingDb(captured), undefined, registry);
|
|
74
|
+
|
|
75
|
+
// A run resolved this credential during execution.
|
|
76
|
+
registry.register("run-1", ["resolved-cred-XYZ"]);
|
|
77
|
+
|
|
78
|
+
const stepId = await store.createStep({
|
|
79
|
+
runId: "run-1",
|
|
80
|
+
actionPath: "actions[0]",
|
|
81
|
+
actionId: "a1",
|
|
82
|
+
actionKind: "action",
|
|
83
|
+
providerActionId: "integration-jira.create_issue",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// A provider HTTP error embedding the credential + a payload echoing it.
|
|
87
|
+
await store.updateStep(stepId, {
|
|
88
|
+
status: "failed",
|
|
89
|
+
errorMessage: "401 from Jira using token resolved-cred-XYZ",
|
|
90
|
+
resultPayload: { detail: { auth: "Bearer resolved-cred-XYZ" } },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const stepUpdate = captured.updates.at(-1)!;
|
|
94
|
+
expect(stepUpdate.errorMessage).toBe("401 from Jira using token ****");
|
|
95
|
+
expect(stepUpdate.resultPayload).toEqual({
|
|
96
|
+
detail: { auth: "Bearer ****" },
|
|
97
|
+
});
|
|
98
|
+
expect(JSON.stringify(stepUpdate)).not.toContain("resolved-cred-XYZ");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("masks the run-level errorMessage before persist", async () => {
|
|
102
|
+
const captured: Captured = { inserts: [], updates: [] };
|
|
103
|
+
const registry = createRunSecretRegistry();
|
|
104
|
+
const store = createRunStore(capturingDb(captured), undefined, registry);
|
|
105
|
+
registry.register("run-2", ["run-cred-999"]);
|
|
106
|
+
|
|
107
|
+
await store.updateRunStatus(
|
|
108
|
+
"run-2",
|
|
109
|
+
"failed",
|
|
110
|
+
"run failed: leaked run-cred-999 in error",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const runUpdate = captured.updates.at(-1)!;
|
|
114
|
+
expect(runUpdate.errorMessage).toBe("run failed: leaked **** in error");
|
|
115
|
+
expect(JSON.stringify(runUpdate)).not.toContain("run-cred-999");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("least-privilege: a value not resolved in the run is left intact", async () => {
|
|
119
|
+
const captured: Captured = { inserts: [], updates: [] };
|
|
120
|
+
const registry = createRunSecretRegistry();
|
|
121
|
+
const store = createRunStore(capturingDb(captured), undefined, registry);
|
|
122
|
+
// run-3 resolved nothing.
|
|
123
|
+
const stepId = await store.createStep({
|
|
124
|
+
runId: "run-3",
|
|
125
|
+
actionPath: "actions[0]",
|
|
126
|
+
actionId: "a1",
|
|
127
|
+
actionKind: "log",
|
|
128
|
+
providerActionId: null,
|
|
129
|
+
});
|
|
130
|
+
await store.updateStep(stepId, {
|
|
131
|
+
status: "success",
|
|
132
|
+
resultPayload: { message: "not-a-secret-value" },
|
|
133
|
+
});
|
|
134
|
+
const stepUpdate = captured.updates.at(-1)!;
|
|
135
|
+
expect(stepUpdate.resultPayload).toEqual({ message: "not-a-secret-value" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("drops the run's mask set once the run reaches a terminal status", async () => {
|
|
139
|
+
const captured: Captured = { inserts: [], updates: [] };
|
|
140
|
+
const registry = createRunSecretRegistry();
|
|
141
|
+
const store = createRunStore(capturingDb(captured), undefined, registry);
|
|
142
|
+
registry.register("run-4", ["transient-cred"]);
|
|
143
|
+
await store.updateRunStatus("run-4", "success");
|
|
144
|
+
// After terminal, the value is no longer in the registry (memory-only).
|
|
145
|
+
expect(registry.maskText("run-4", "x=transient-cred")).toBe(
|
|
146
|
+
"x=transient-cred",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ─── L2 cross-pod masking ───────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const RESOLVER_REF_ID = "secrets.resolver";
|
|
154
|
+
const CONNECTION_REF_ID = "integration.connectionStore";
|
|
155
|
+
const RUN_ID = "run-suspended";
|
|
156
|
+
const POD_A_CRED = "podA-resolved-cred-XYZ";
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A SHARED secret backend both pods read through — the value lives in the
|
|
160
|
+
* cluster's secret/connection store, not on any one pod. Pod A and pod B get
|
|
161
|
+
* their own (per-process) mask registry, but resolve against this same store.
|
|
162
|
+
*/
|
|
163
|
+
function sharedSecretBackend() {
|
|
164
|
+
return {
|
|
165
|
+
resolver: {
|
|
166
|
+
resolveSecret: async () => POD_A_CRED,
|
|
167
|
+
resolveForRun: async () => ({
|
|
168
|
+
env: { API_TOKEN: POD_A_CRED },
|
|
169
|
+
masking: {},
|
|
170
|
+
}),
|
|
171
|
+
resolveBySchema: async () => ({ resolved: {}, warnings: [] }),
|
|
172
|
+
},
|
|
173
|
+
connectionStore: {
|
|
174
|
+
getConnectionWithCredentials: async () => ({
|
|
175
|
+
config: { baseUrl: "https://jira.example", apiToken: POD_A_CRED },
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A getService over the shared backend, keyed by the two intercepted refs. */
|
|
182
|
+
function sharedGetService(backend: ReturnType<typeof sharedSecretBackend>) {
|
|
183
|
+
return async <T>(refArg: ServiceRef<T>): Promise<T> => {
|
|
184
|
+
if (refArg.id === RESOLVER_REF_ID) {
|
|
185
|
+
return backend.resolver as unknown as T;
|
|
186
|
+
}
|
|
187
|
+
if (refArg.id === CONNECTION_REF_ID) {
|
|
188
|
+
return backend.connectionStore as unknown as T;
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`unexpected ref ${refArg.id}`);
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Definition whose action uses BOTH a `secretEnv` mapping and a connection
|
|
196
|
+
* — the declared secret refs the resume path must re-resolve.
|
|
197
|
+
*/
|
|
198
|
+
function definitionUsingSecrets() {
|
|
199
|
+
return AutomationDefinitionSchema.parse({
|
|
200
|
+
name: "Cross-pod masking",
|
|
201
|
+
triggers: [{ event: "incident.created" }],
|
|
202
|
+
conditions: [],
|
|
203
|
+
actions: [
|
|
204
|
+
{
|
|
205
|
+
action: "integration-jira.create_issue",
|
|
206
|
+
config: {
|
|
207
|
+
connectionId: "conn-1",
|
|
208
|
+
secretEnv: { API_TOKEN: "${{ secrets.jira_token }}" },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{ wait_for_trigger: { event: "incident.resolved" } },
|
|
212
|
+
{
|
|
213
|
+
action: "integration-script.run",
|
|
214
|
+
config: { script: "echo done" },
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
mode: "single",
|
|
218
|
+
max_runs: 1,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
describe("L2 cross-pod masking — resume on a fresh pod re-seeds the mask set", () => {
|
|
223
|
+
it("WITHOUT re-seed: a fresh pod-B registry persists pod-A's secret UNMASKED (the leak)", async () => {
|
|
224
|
+
// Pod B comes up cold: it never resolved this run's secrets, so its mask
|
|
225
|
+
// set is empty.
|
|
226
|
+
const podBRegistry = createRunSecretRegistry();
|
|
227
|
+
const captured: Captured = { inserts: [], updates: [] };
|
|
228
|
+
const store = createRunStore(capturingDb(captured), undefined, podBRegistry);
|
|
229
|
+
|
|
230
|
+
// Pod B persists a step whose output still carries pod-A's credential
|
|
231
|
+
// (e.g. a carried-over value surfaced post-resume).
|
|
232
|
+
const stepId = await store.createStep({
|
|
233
|
+
runId: RUN_ID,
|
|
234
|
+
actionPath: "actions[2]",
|
|
235
|
+
actionId: "a3",
|
|
236
|
+
actionKind: "action",
|
|
237
|
+
providerActionId: "integration-script.run",
|
|
238
|
+
});
|
|
239
|
+
await store.updateStep(stepId, {
|
|
240
|
+
status: "success",
|
|
241
|
+
resultPayload: { echoed: `token=${POD_A_CRED}` },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const stepUpdate = captured.updates.at(-1)!;
|
|
245
|
+
// The leak: an empty mask set leaves the value intact in the persisted row.
|
|
246
|
+
expect(JSON.stringify(stepUpdate)).toContain(POD_A_CRED);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("WITH re-seed: pod B re-resolves declared refs, so the same persist is masked", async () => {
|
|
250
|
+
const backend = sharedSecretBackend();
|
|
251
|
+
const podBRegistry = createRunSecretRegistry();
|
|
252
|
+
const wrappedGetService = wrapGetServiceForRun({
|
|
253
|
+
getService: sharedGetService(backend),
|
|
254
|
+
runId: RUN_ID,
|
|
255
|
+
registry: podBRegistry,
|
|
256
|
+
resolverRefId: RESOLVER_REF_ID,
|
|
257
|
+
connectionStoreRefId: CONNECTION_REF_ID,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// The fix: before walking/persisting, the resuming pod re-seeds its mask
|
|
261
|
+
// set from the automation's declared secret refs.
|
|
262
|
+
await reseedRunSecretRegistry({
|
|
263
|
+
getService: wrappedGetService,
|
|
264
|
+
registry: podBRegistry,
|
|
265
|
+
runId: RUN_ID,
|
|
266
|
+
definition: definitionUsingSecrets(),
|
|
267
|
+
resolverRefId: RESOLVER_REF_ID,
|
|
268
|
+
connectionStoreRefId: CONNECTION_REF_ID,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Now every choke point on pod B masks pod-A's value.
|
|
272
|
+
const captured: Captured = { inserts: [], updates: [] };
|
|
273
|
+
const runStore = createRunStore(
|
|
274
|
+
capturingDb(captured),
|
|
275
|
+
undefined,
|
|
276
|
+
podBRegistry,
|
|
277
|
+
);
|
|
278
|
+
const stepId = await runStore.createStep({
|
|
279
|
+
runId: RUN_ID,
|
|
280
|
+
actionPath: "actions[2]",
|
|
281
|
+
actionId: "a3",
|
|
282
|
+
actionKind: "action",
|
|
283
|
+
providerActionId: "integration-script.run",
|
|
284
|
+
});
|
|
285
|
+
await runStore.updateStep(stepId, {
|
|
286
|
+
status: "success",
|
|
287
|
+
resultPayload: { echoed: `token=${POD_A_CRED}` },
|
|
288
|
+
errorMessage: undefined,
|
|
289
|
+
});
|
|
290
|
+
const stepUpdate = captured.updates.at(-1)!;
|
|
291
|
+
expect(stepUpdate.resultPayload).toEqual({ echoed: "token=****" });
|
|
292
|
+
expect(JSON.stringify(stepUpdate)).not.toContain(POD_A_CRED);
|
|
293
|
+
|
|
294
|
+
// The scope-snapshot choke point (run-state) is masked too.
|
|
295
|
+
const noopAdvisoryLock: AdvisoryLockService = {
|
|
296
|
+
tryAcquire: async () => ({ release: async () => {} }),
|
|
297
|
+
};
|
|
298
|
+
const stateCaptured: Captured = { inserts: [], updates: [] };
|
|
299
|
+
const stateStore = createRunStateStore(
|
|
300
|
+
capturingDb(stateCaptured) as unknown as Parameters<
|
|
301
|
+
typeof createRunStateStore
|
|
302
|
+
>[0],
|
|
303
|
+
noopAdvisoryLock,
|
|
304
|
+
podBRegistry,
|
|
305
|
+
);
|
|
306
|
+
await stateStore.upsert({
|
|
307
|
+
runId: RUN_ID,
|
|
308
|
+
scopeSnapshot: { variables: { carried: POD_A_CRED }, artifacts: {} },
|
|
309
|
+
lastActionPath: "actions[2]",
|
|
310
|
+
});
|
|
311
|
+
const persistedScope = stateCaptured.inserts.at(-1)!.values;
|
|
312
|
+
expect(JSON.stringify(persistedScope.scopeSnapshot)).not.toContain(
|
|
313
|
+
POD_A_CRED,
|
|
314
|
+
);
|
|
315
|
+
expect(persistedScope.scopeSnapshot).toEqual({
|
|
316
|
+
variables: { carried: "****" },
|
|
317
|
+
artifacts: {},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// And a produced artifact echoing the credential is masked.
|
|
321
|
+
const artCaptured: Captured = { inserts: [], updates: [] };
|
|
322
|
+
const artifactStore = createArtifactStore(
|
|
323
|
+
capturingDb(artCaptured) as unknown as Parameters<
|
|
324
|
+
typeof createArtifactStore
|
|
325
|
+
>[0],
|
|
326
|
+
podBRegistry,
|
|
327
|
+
);
|
|
328
|
+
await artifactStore.record({
|
|
329
|
+
automationId: "auto-1",
|
|
330
|
+
runId: RUN_ID,
|
|
331
|
+
stepId,
|
|
332
|
+
actionId: "a3",
|
|
333
|
+
artifactType: "integration-jira.issue",
|
|
334
|
+
data: { url: "https://x", auth: `Bearer ${POD_A_CRED}` },
|
|
335
|
+
contextKey: "incident-1",
|
|
336
|
+
});
|
|
337
|
+
const persistedArtifact = artCaptured.inserts.at(-1)!.values;
|
|
338
|
+
expect(JSON.stringify(persistedArtifact.data)).not.toContain(POD_A_CRED);
|
|
339
|
+
expect(persistedArtifact.data).toEqual({
|
|
340
|
+
url: "https://x",
|
|
341
|
+
auth: "Bearer ****",
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("re-seed is a no-op for a definition that declares no secret refs", async () => {
|
|
346
|
+
const backend = sharedSecretBackend();
|
|
347
|
+
const registry = createRunSecretRegistry();
|
|
348
|
+
const wrapped = wrapGetServiceForRun({
|
|
349
|
+
getService: sharedGetService(backend),
|
|
350
|
+
runId: "run-clean",
|
|
351
|
+
registry,
|
|
352
|
+
resolverRefId: RESOLVER_REF_ID,
|
|
353
|
+
connectionStoreRefId: CONNECTION_REF_ID,
|
|
354
|
+
});
|
|
355
|
+
const definition = AutomationDefinitionSchema.parse({
|
|
356
|
+
name: "No secrets",
|
|
357
|
+
triggers: [{ event: "x.y" }],
|
|
358
|
+
conditions: [],
|
|
359
|
+
actions: [{ variables: { greeting: "hi" } }],
|
|
360
|
+
mode: "single",
|
|
361
|
+
max_runs: 1,
|
|
362
|
+
});
|
|
363
|
+
await reseedRunSecretRegistry({
|
|
364
|
+
getService: wrapped,
|
|
365
|
+
registry,
|
|
366
|
+
runId: "run-clean",
|
|
367
|
+
definition,
|
|
368
|
+
resolverRefId: RESOLVER_REF_ID,
|
|
369
|
+
connectionStoreRefId: CONNECTION_REF_ID,
|
|
370
|
+
});
|
|
371
|
+
// Nothing registered, so an unrelated value is left intact.
|
|
372
|
+
expect(registry.maskText("run-clean", `x=${POD_A_CRED}`)).toBe(
|
|
373
|
+
`x=${POD_A_CRED}`,
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
});
|