Skip to content

Commit 4b7709b

Browse files
committed
feat: enhance workflow runner status, cleanup step execution, and shell injection detection, while optimizing context cloning and improving secret decryption error handling.
1 parent fe9253a commit 4b7709b

File tree

5 files changed

+136
-74
lines changed

5 files changed

+136
-74
lines changed

src/adapters/secrets.ts

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -82,20 +82,20 @@ export interface EncryptedData {
8282
*/
8383
export function isSecretExpression(expression: string, secretInputs: string[]): boolean {
8484
const trimmed = expression.trim();
85-
85+
8686
// All environment variables are treated as secrets
8787
if (trimmed.startsWith("env.")) {
8888
return true;
8989
}
90-
90+
9191
// Check if it's a secret input
9292
if (trimmed.startsWith("inputs.")) {
9393
const inputPath = trimmed.slice("inputs.".length);
9494
// Get the root input name (before any dots for nested access)
9595
const inputName = inputPath.split(".")[0];
9696
return secretInputs.includes(inputName);
9797
}
98-
98+
9999
return false;
100100
}
101101

@@ -109,14 +109,14 @@ export function isSecretExpression(expression: string, secretInputs: string[]):
109109
export function extractSecretExpressions(template: string, secretInputs: string[]): string[] {
110110
const pattern = /\{\{([^}]+)\}\}/g;
111111
const secrets: string[] = [];
112-
112+
113113
for (const match of template.matchAll(pattern)) {
114114
const expression = match[1].trim();
115115
if (isSecretExpression(expression, secretInputs)) {
116116
secrets.push(expression);
117117
}
118118
}
119-
119+
120120
return secrets;
121121
}
122122

@@ -148,12 +148,12 @@ export function maskSecretValue(value: string): string {
148148
if (!value || value.length === 0) {
149149
return SECRET_MASK;
150150
}
151-
151+
152152
// For short values, completely mask
153153
if (value.length <= 4) {
154154
return SECRET_MASK;
155155
}
156-
156+
157157
// For longer values, show first character + mask
158158
return value[0] + SECRET_MASK;
159159
}
@@ -172,18 +172,18 @@ export function maskInterpolatedString(
172172
secretValues: Map<string, string>
173173
): string {
174174
let masked = interpolated;
175-
175+
176176
// Sort by value length (longest first) to handle overlapping values correctly
177177
const sortedSecrets = Array.from(secretValues.entries())
178178
.sort(([, a], [, b]) => b.length - a.length);
179-
179+
180180
for (const [, value] of sortedSecrets) {
181181
if (value && value.length > 0) {
182182
// Replace all occurrences of the secret value with the mask
183183
masked = masked.split(value).join(SECRET_MASK);
184184
}
185185
}
186-
186+
187187
return masked;
188188
}
189189

@@ -201,7 +201,7 @@ function validateEncryptionKey(encryptionKey: string): void {
201201
if (!encryptionKey || encryptionKey.length < MIN_KEY_LENGTH) {
202202
throw new Error(
203203
`Encryption key must be at least ${MIN_KEY_LENGTH} characters long for security. ` +
204-
`Provided key is ${encryptionKey?.length || 0} characters.`
204+
`Provided key does not meet security requirements.`
205205
);
206206
}
207207
}
@@ -229,22 +229,22 @@ function deriveKey(password: string, salt: Buffer): Buffer {
229229
*/
230230
export function encryptForStorage(data: string, encryptionKey: string): EncryptedData {
231231
validateEncryptionKey(encryptionKey);
232-
232+
233233
const salt = randomBytes(SALT_LENGTH);
234234
const key = deriveKey(encryptionKey, salt);
235235
const iv = randomBytes(IV_LENGTH);
236-
236+
237237
const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
238238
const encrypted = Buffer.concat([
239239
cipher.update(data, "utf8"),
240240
cipher.final(),
241241
]);
242-
242+
243243
const authTag = cipher.getAuthTag();
244-
244+
245245
// Combine salt + iv + authTag + encrypted data
246246
const combined = Buffer.concat([salt, iv, authTag, encrypted]);
247-
247+
248248
return {
249249
encrypted: true,
250250
data: combined.toString("base64"),
@@ -261,25 +261,25 @@ export function encryptForStorage(data: string, encryptionKey: string): Encrypte
261261
*/
262262
export function decryptFromStorage(encryptedData: EncryptedData, encryptionKey: string): string {
263263
validateEncryptionKey(encryptionKey);
264-
264+
265265
const combined = Buffer.from(encryptedData.data, "base64");
266-
266+
267267
// Extract components
268268
const salt = combined.subarray(0, SALT_LENGTH);
269269
const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
270270
const authTag = combined.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
271271
const encrypted = combined.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH);
272-
272+
273273
const key = deriveKey(encryptionKey, salt);
274-
274+
275275
const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
276276
decipher.setAuthTag(authTag);
277-
277+
278278
const decrypted = Buffer.concat([
279279
decipher.update(encrypted),
280280
decipher.final(),
281281
]);
282-
282+
283283
return decrypted.toString("utf8");
284284
}
285285

@@ -315,7 +315,7 @@ export function encryptSecretInputs(
315315
encryptionKey: string
316316
): Record<string, unknown> {
317317
const result: Record<string, unknown> = {};
318-
318+
319319
for (const [key, value] of Object.entries(inputs)) {
320320
if (secretKeys.includes(key) && value !== undefined && value !== null) {
321321
// Encrypt the secret value
@@ -325,7 +325,7 @@ export function encryptSecretInputs(
325325
result[key] = value;
326326
}
327327
}
328-
328+
329329
return result;
330330
}
331331

@@ -341,7 +341,7 @@ export function decryptSecretInputs(
341341
encryptionKey: string
342342
): Record<string, unknown> {
343343
const result: Record<string, unknown> = {};
344-
344+
345345
for (const [key, value] of Object.entries(inputs)) {
346346
if (isEncryptedData(value)) {
347347
try {
@@ -352,14 +352,16 @@ export function decryptSecretInputs(
352352
} catch {
353353
result[key] = decrypted;
354354
}
355-
} catch {
356-
// If decryption fails, keep the encrypted value
355+
} catch (error) {
356+
// Log warning for decryption failure - helps debug key rotation issues
357+
console.warn(`[secrets] Failed to decrypt input '${key}': ${error instanceof Error ? error.message : 'Unknown error'}`);
358+
// Keep the encrypted value to avoid data loss
357359
result[key] = value;
358360
}
359361
} else {
360362
result[key] = value;
361363
}
362364
}
363-
365+
364366
return result;
365367
}

src/adapters/steps.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,21 @@ export async function cleanupAllProcesses(): Promise<void> {
216216
* These are logged as warnings but not blocked to maintain flexibility.
217217
*/
218218
const SHELL_DANGEROUS_PATTERNS = [
219-
/;\s*rm\s/i, // rm after semicolon
220-
/\|\s*sh\b/i, // piping to shell
221-
/\|\s*bash\b/i, // piping to bash
222-
/`[^`]+`/, // backtick command substitution
223-
/\$\([^)]+\)/, // $() command substitution
224-
/>\s*\/etc\//i, // writing to /etc
225-
/>\s*\/bin\//i, // writing to /bin
219+
/;\s*rm\s/i, // rm after semicolon
220+
/&&\s*rm\s/i, // rm after &&
221+
/\|\|\s*rm\s/i, // rm after ||
222+
/&\s*rm\s/i, // rm after background operator
223+
/\|\s*sh\b/i, // piping to shell
224+
/\|\s*bash\b/i, // piping to bash
225+
/\|\s*zsh\b/i, // piping to zsh
226+
/`[^`]+`/, // backtick command substitution
227+
/\$\([^)]+\)/, // $() command substitution
228+
/\$\{[^}]+\}/, // ${} parameter expansion with commands
229+
/>\s*\/etc\//i, // writing to /etc
230+
/>\s*\/bin\//i, // writing to /bin
231+
/>\s*\/usr\/bin\//i, // writing to /usr/bin
232+
/curl.*\|.*sh/i, // curl piped to shell
233+
/wget.*\|.*sh/i, // wget piped to shell
226234
];
227235

228236
/**
@@ -1596,8 +1604,9 @@ function createSandboxContext(
15961604

15971605
return {
15981606
// Workflow context (read-only via frozen copies)
1599-
inputs: Object.freeze(JSON.parse(JSON.stringify(inputs))),
1600-
steps: Object.freeze(JSON.parse(JSON.stringify(steps))),
1607+
// Use structuredClone for better performance than JSON.parse(JSON.stringify())
1608+
inputs: Object.freeze(structuredClone(inputs)),
1609+
steps: Object.freeze(structuredClone(steps)),
16011610
env: frozenEnv,
16021611

16031612
// Console mapped to plugin logger (or silent if no logger provided)

src/commands/runner.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,15 @@ export class WorkflowRunner {
201201
this.stopStatusInterval(runId);
202202
return;
203203
}
204-
const totalSteps = Object.keys(run.stepResults || {}).length;
204+
const completedSteps = Object.keys(run.stepResults || {}).length;
205205
const current = run.currentStepId ? `current: ${run.currentStepId}` : "current: n/a";
206206
const summary = this.progress?.getStepSummary(runId);
207-
const msg = `Status: ${run.status} (${current}, completed steps: ${totalSteps})${summary ? ` • last: ${summary}` : ""}`;
207+
// Get total steps from workflow definition if available
208+
const compiled = this.factory.get(run.workflowId);
209+
const totalSteps = compiled?.stepCount ?? 0;
210+
const progressPct = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
211+
const progressStr = totalSteps > 0 ? ` (${completedSteps}/${totalSteps} steps, ${progressPct}%)` : ` (${completedSteps} steps completed)`;
212+
const msg = `Status: ${run.status}${progressStr}${current}${summary ? ` • last: ${summary}` : ""}`;
208213
void this.progress?.emit(msg, { runId, level: "info", force: true });
209214
}, this.statusIntervalMs);
210215
this.statusIntervals.set(runId, interval);
@@ -258,8 +263,8 @@ export class WorkflowRunner {
258263
});
259264

260265
// Load recent completed runs in background with pagination (non-blocking)
261-
// Only load the most recent 100 runs to avoid memory issues with large histories
262-
const RECENT_RUNS_LIMIT = 100;
266+
// Only load the most recent 30 runs to reduce memory usage and startup time
267+
const RECENT_RUNS_LIMIT = 30;
263268
this.storage
264269
.loadAllRuns(undefined, RECENT_RUNS_LIMIT, 0)
265270
.then((recentRuns) => {
@@ -367,12 +372,14 @@ export class WorkflowRunner {
367372
* @param steps - The cleanup step definitions to execute
368373
* @param run - The current workflow run (for context)
369374
* @param compiled - The compiled workflow result
375+
* @param prefix - Prefix for step result keys ('onFailure' or 'finally')
370376
* @param errorInfo - Optional error information if this is an onFailure block
371377
*/
372378
private async executeCleanupSteps(
373379
steps: StepDefinition[],
374380
run: WorkflowRun,
375381
compiled: WorkflowFactoryResult,
382+
prefix: 'onFailure' | 'finally',
376383
errorInfo?: { message: string; stepId?: string }
377384
): Promise<void> {
378385
const client = this.factory.getClient();
@@ -394,8 +401,9 @@ export class WorkflowRunner {
394401
};
395402

396403
for (const stepDef of steps) {
404+
const prefixedId = `${prefix}:${stepDef.id}`;
397405
try {
398-
this.log.info(`Executing cleanup step: ${stepDef.id}`);
406+
this.log.info(`Executing ${prefix} step: ${stepDef.id}`);
399407

400408
const result = await executeInnerStep(
401409
stepDef,
@@ -407,22 +415,22 @@ export class WorkflowRunner {
407415
// Store the result for subsequent cleanup steps to reference
408416
ctx.steps[stepDef.id] = result;
409417

410-
// Save to run results with a "cleanup:" prefix to distinguish from main steps
411-
run.stepResults[`cleanup:${stepDef.id}`] = {
412-
stepId: `cleanup:${stepDef.id}`,
418+
// Save to run results with prefix to distinguish cleanup type
419+
run.stepResults[prefixedId] = {
420+
stepId: prefixedId,
413421
status: "success",
414422
output: result as StepOutput,
415423
startedAt: new Date(),
416424
completedAt: new Date(),
417425
};
418426

419-
this.log.info(`Cleanup step ${stepDef.id} completed`);
427+
this.log.info(`${prefix} step ${stepDef.id} completed`);
420428
} catch (error) {
421429
// Log but don't throw - cleanup steps should not mask the original error
422-
this.log.error(`Cleanup step ${stepDef.id} failed: ${error}`);
430+
this.log.error(`${prefix} step ${stepDef.id} failed: ${error}`);
423431

424-
run.stepResults[`cleanup:${stepDef.id}`] = {
425-
stepId: `cleanup:${stepDef.id}`,
432+
run.stepResults[prefixedId] = {
433+
stepId: prefixedId,
426434
status: "failed",
427435
error: String(error),
428436
startedAt: new Date(),
@@ -724,6 +732,7 @@ export class WorkflowRunner {
724732
compiled.onFailureSteps,
725733
run,
726734
compiled,
735+
'onFailure',
727736
{ message: workflowError.message, stepId: failedStepId }
728737
);
729738
}
@@ -735,6 +744,7 @@ export class WorkflowRunner {
735744
compiled.finallySteps,
736745
run,
737746
compiled,
747+
'finally',
738748
workflowError ? { message: workflowError.message, stepId: failedStepId } : undefined
739749
);
740750
}
@@ -953,6 +963,7 @@ export class WorkflowRunner {
953963
compiled.onFailureSteps,
954964
run,
955965
compiled,
966+
'onFailure',
956967
{ message: workflowError.message, stepId: failedStepId }
957968
);
958969
}
@@ -964,6 +975,7 @@ export class WorkflowRunner {
964975
compiled.finallySteps,
965976
run,
966977
compiled,
978+
'finally',
967979
workflowError ? { message: workflowError.message, stepId: failedStepId } : undefined
968980
);
969981
}

0 commit comments

Comments
 (0)