@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.30",
3
+ "version": "2.0.0-beta.31",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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({ tmpDir: o.paths.tmp, signal: o.signal });
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
  );
@@ -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
- return await finish(exec, io);
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
+ }