@olivervea/pipelines 2.0.0-beta.30 → 2.0.0-beta.31
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/README.md +2 -0
- package/package.json +1 -1
- package/src/ctx/index.ts +7 -1
- package/src/ctx/sh.ts +6 -4
- package/src/harness/main.ts +11 -1
- package/src/harness/mask.ts +98 -0
package/README.md
CHANGED
|
@@ -344,6 +344,8 @@ This section is everything the C# side needs in order to run the binary.
|
|
|
344
344
|
|
|
345
345
|
`OLVE_RESULT_URL`, `OLVE_FILE_URLS`, `OLVE_FILES_TOKEN` and `OLVE_CALLBACK_URLS` are removed from the environment of child processes. URLs are never logged past `?`.
|
|
346
346
|
|
|
347
|
+
**Secret masking** (`run`): every value of the step's declared secrets is replaced with `***` in what the step writes: `ctx.log`, `console`, anything written to stdout or stderr, the output of `ctx.exec` and `ctx.sh` commands (piped through the harness when the step has a secret; inherited otherwise), and the envelope's failure message or skip reason. Output is masked a line at a time; a multi-line value is masked line by line, and values (or lines) shorter than 4 characters are left alone. Outputs and files are data and are not masked. A process that writes to the pod's stdout around the harness (a file descriptor it opened itself) is not masked.
|
|
348
|
+
|
|
347
349
|
`ctx.files.fetch` looks for a file in this order:
|
|
348
350
|
|
|
349
351
|
1. the attempt's own `work/files`;
|
package/package.json
CHANGED
package/src/ctx/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface CtxOptions {
|
|
|
24
24
|
callbackUrls?: Readonly<Record<string, string>>;
|
|
25
25
|
/** A matrix step's cell (`ctx.cell`). */
|
|
26
26
|
cell?: Cell;
|
|
27
|
+
/** Commands' output goes through this process (the harness masks secrets there). */
|
|
28
|
+
pipeOutput?: boolean;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function formatLog(message: string, fields?: Record<string, unknown>): string {
|
|
@@ -144,7 +146,11 @@ export function createCtx(o: CtxOptions): Ctx {
|
|
|
144
146
|
const sink = o.logSink ?? ((line: string) => process.stderr.write(line + '\n'));
|
|
145
147
|
const log = (message: string, fields?: Record<string, unknown>) =>
|
|
146
148
|
sink(formatLog(message, fields));
|
|
147
|
-
const { sh, exec } = createSh({
|
|
149
|
+
const { sh, exec } = createSh({
|
|
150
|
+
tmpDir: o.paths.tmp,
|
|
151
|
+
signal: o.signal,
|
|
152
|
+
pipeOutput: o.pipeOutput,
|
|
153
|
+
});
|
|
148
154
|
const filesDeps = {
|
|
149
155
|
run: o.run,
|
|
150
156
|
workFiles: o.paths.workFiles,
|
package/src/ctx/sh.ts
CHANGED
|
@@ -79,6 +79,8 @@ export function childEnv(extra?: Record<string, string>): Record<string, string>
|
|
|
79
79
|
export interface ShDeps {
|
|
80
80
|
tmpDir: string;
|
|
81
81
|
signal: AbortSignal;
|
|
82
|
+
/** Pipe commands' output through this process's stdout/stderr (where secrets are masked). */
|
|
83
|
+
pipeOutput?: boolean;
|
|
82
84
|
shellCandidates?: readonly string[];
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -150,8 +152,8 @@ export function createSh(deps: ShDeps): {
|
|
|
150
152
|
cwd: opts.cwd,
|
|
151
153
|
env: childEnv(opts.env),
|
|
152
154
|
stdin: 'ignore',
|
|
153
|
-
stdout: capture || idleSeconds ? 'pipe' : 'inherit',
|
|
154
|
-
stderr: idleSeconds ? 'pipe' : 'inherit',
|
|
155
|
+
stdout: capture || idleSeconds || deps.pipeOutput ? 'pipe' : 'inherit',
|
|
156
|
+
stderr: idleSeconds || deps.pipeOutput ? 'pipe' : 'inherit',
|
|
155
157
|
});
|
|
156
158
|
} catch (e) {
|
|
157
159
|
throw new Error(`cannot start ${what} (${argv[0]}): ${(e as Error).message}`);
|
|
@@ -196,11 +198,11 @@ export function createSh(deps: ShDeps): {
|
|
|
196
198
|
pumps.push(
|
|
197
199
|
pump(proc.stdout as ReadableStream<Uint8Array>, (c) => captured.push(c)),
|
|
198
200
|
);
|
|
199
|
-
else if (idleSeconds)
|
|
201
|
+
else if (idleSeconds || deps.pipeOutput)
|
|
200
202
|
pumps.push(
|
|
201
203
|
pump(proc.stdout as ReadableStream<Uint8Array>, (c) => process.stdout.write(c)),
|
|
202
204
|
);
|
|
203
|
-
if (idleSeconds)
|
|
205
|
+
if (idleSeconds || deps.pipeOutput)
|
|
204
206
|
pumps.push(
|
|
205
207
|
pump(proc.stderr as ReadableStream<Uint8Array>, (c) => process.stderr.write(c)),
|
|
206
208
|
);
|
package/src/harness/main.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { isImplementation } from '../core/handlers.ts';
|
|
|
8
8
|
import { configRefPath, isConfigRef, unionAt } from '../core/config.ts';
|
|
9
9
|
import type { AnyStep } from '../core/steps.ts';
|
|
10
10
|
import { cellName, parseCell } from '../core/matrix.ts';
|
|
11
|
+
import { installMasking, maskEnvelope, maskPatterns, maskText } from './mask.ts';
|
|
11
12
|
import type { WebhookEvent } from '../core/webhooks.ts';
|
|
12
13
|
import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
|
|
13
14
|
import { createCtx, formatLog } from '../ctx/index.ts';
|
|
@@ -198,6 +199,12 @@ async function runMode(
|
|
|
198
199
|
? parseCell(step.name, step.matrix, process.env[ENV.cell])
|
|
199
200
|
: undefined;
|
|
200
201
|
const cancel = cancellation();
|
|
202
|
+
const masks = maskPatterns(step.secrets.map((s) => process.env[s.name]));
|
|
203
|
+
const unmask = masks.length > 0 ? installMasking(masks) : () => {};
|
|
204
|
+
if (masks.length > 0) {
|
|
205
|
+
const stderr = io.stderr;
|
|
206
|
+
io = { ...io, stderr: (line) => stderr(maskText(line, masks)) };
|
|
207
|
+
}
|
|
201
208
|
try {
|
|
202
209
|
let exec: Execution;
|
|
203
210
|
if (!raw.ok || (cell && !cell.ok)) {
|
|
@@ -217,6 +224,7 @@ async function runMode(
|
|
|
217
224
|
logSink: io.stderr,
|
|
218
225
|
callbackUrls: callbackUrlsFromEnv(),
|
|
219
226
|
...(cell?.ok ? { cell: cell.cell } : {}),
|
|
227
|
+
pipeOutput: masks.length > 0,
|
|
220
228
|
});
|
|
221
229
|
exec = await executeStep({
|
|
222
230
|
step,
|
|
@@ -227,8 +235,10 @@ async function runMode(
|
|
|
227
235
|
log: io.stderr,
|
|
228
236
|
});
|
|
229
237
|
}
|
|
230
|
-
|
|
238
|
+
unmask();
|
|
239
|
+
return await finish({ ...exec, envelope: maskEnvelope(exec.envelope, masks) }, io);
|
|
231
240
|
} finally {
|
|
241
|
+
unmask();
|
|
232
242
|
cancel.dispose();
|
|
233
243
|
}
|
|
234
244
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { format } from 'node:util';
|
|
2
|
+
import type { Envelope } from './execute.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Masks the step's declared secret values in what it writes to stdout and stderr. Output is masked a
|
|
6
|
+
* line at a time, so a value split across two writes is still found; a multi-line value (a key file)
|
|
7
|
+
* is masked line by line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const MASK = '***';
|
|
11
|
+
/** Shorter values (and lines of multi-line values) are left alone: they would mask ordinary text. */
|
|
12
|
+
export const MIN_MASKED_LENGTH = 4;
|
|
13
|
+
/** A line longer than this is masked and written without waiting for its end. */
|
|
14
|
+
const MAX_PENDING = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
/** The strings to mask for these secret values, longest first. */
|
|
17
|
+
export function maskPatterns(values: readonly (string | undefined)[]): string[] {
|
|
18
|
+
const patterns = new Set<string>();
|
|
19
|
+
for (const value of values)
|
|
20
|
+
for (const line of (value ?? '').split(/\r?\n/))
|
|
21
|
+
if (line.trim().length >= MIN_MASKED_LENGTH) patterns.add(line.trim());
|
|
22
|
+
return [...patterns].sort((a, b) => b.length - a.length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function maskText(text: string, patterns: readonly string[]): string {
|
|
26
|
+
let out = text;
|
|
27
|
+
for (const p of patterns) if (out.includes(p)) out = out.split(p).join(MASK);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A writer that masks complete lines and holds back the unfinished one until it ends or `flush`. */
|
|
32
|
+
export function lineMasker(patterns: readonly string[], write: (text: string) => void) {
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
let pending = '';
|
|
35
|
+
return {
|
|
36
|
+
write(chunk: string | Uint8Array) {
|
|
37
|
+
pending +=
|
|
38
|
+
typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
39
|
+
const end = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
|
|
40
|
+
if (end >= 0) {
|
|
41
|
+
write(maskText(pending.slice(0, end + 1), patterns));
|
|
42
|
+
pending = pending.slice(end + 1);
|
|
43
|
+
}
|
|
44
|
+
if (pending.length > MAX_PENDING) this.flush();
|
|
45
|
+
},
|
|
46
|
+
flush() {
|
|
47
|
+
pending += decoder.decode();
|
|
48
|
+
if (pending) write(maskText(pending, patterns));
|
|
49
|
+
pending = '';
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The envelope with its failure message or skip reason masked; outputs are data and stay as they are. */
|
|
55
|
+
export function maskEnvelope(envelope: Envelope, patterns: readonly string[]): Envelope {
|
|
56
|
+
if (envelope.status === 'failed')
|
|
57
|
+
return {
|
|
58
|
+
...envelope,
|
|
59
|
+
error: { ...envelope.error, message: maskText(envelope.error.message, patterns) },
|
|
60
|
+
};
|
|
61
|
+
if (envelope.status === 'skipped')
|
|
62
|
+
return { ...envelope, reason: maskText(envelope.reason, patterns) };
|
|
63
|
+
return envelope;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Write = typeof process.stdout.write;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Routes process.stdout and process.stderr through maskers until the returned function is called,
|
|
70
|
+
* which flushes them and restores the streams.
|
|
71
|
+
*/
|
|
72
|
+
export function installMasking(patterns: readonly string[]): () => void {
|
|
73
|
+
const streams = [process.stdout, process.stderr];
|
|
74
|
+
const originals = streams.map((s) => s.write.bind(s) as Write);
|
|
75
|
+
const maskers = originals.map((original) =>
|
|
76
|
+
lineMasker(patterns, (text) => void original(text)),
|
|
77
|
+
);
|
|
78
|
+
streams.forEach((s, i) => {
|
|
79
|
+
s.write = ((chunk: string | Uint8Array, a?: unknown, b?: unknown) => {
|
|
80
|
+
maskers[i]!.write(chunk);
|
|
81
|
+
const callback = typeof a === 'function' ? a : typeof b === 'function' ? b : null;
|
|
82
|
+
if (callback) queueMicrotask(() => (callback as () => void)());
|
|
83
|
+
return true;
|
|
84
|
+
}) as Write;
|
|
85
|
+
});
|
|
86
|
+
// Bun's console writes natively, past process.stdout.write.
|
|
87
|
+
const methods = ['log', 'info', 'debug', 'warn', 'error'] as const;
|
|
88
|
+
const consoleOriginals = methods.map((m) => console[m]);
|
|
89
|
+
methods.forEach((m) => {
|
|
90
|
+
const stream = m === 'warn' || m === 'error' ? process.stderr : process.stdout;
|
|
91
|
+
console[m] = (...args: unknown[]) => void stream.write(format(...args) + '\n');
|
|
92
|
+
});
|
|
93
|
+
return () => {
|
|
94
|
+
maskers.forEach((m) => m.flush());
|
|
95
|
+
streams.forEach((s, i) => (s.write = originals[i]!));
|
|
96
|
+
methods.forEach((m, i) => (console[m] = consoleOriginals[i]!));
|
|
97
|
+
};
|
|
98
|
+
}
|