import { MARKER_PREFIX } from "./script"; import type { JobExecution } from "./jobs"; const MIN_MASK_LENGTH = 4; /** Single lines of multi-line secrets are only masked when long enough not to redact ordinary log text. */ const MIN_LINE_MASK_LENGTH = 8; const BASE64_GROUP_BYTES = 3; type Marker = | { type: "step-start"; index: number } | { type: "step-end"; index: number; code: number } | { type: "step-skip"; index: number } | { type: "artifact"; name: string } | { type: "output"; name: string; value: string } | { type: "job-end"; failed: boolean } | { type: "setup-start" } | { type: "setup-end"; code: number }; function decodeBase64(value: string): string { try { return new TextDecoder().decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0))); } catch { return ""; } } /** Markers share stdout with user steps, so only lines carrying the job's secret nonce count. */ export function parseMarker(line: string, nonce: string | null): Marker | null { const prefix = nonce === null ? MARKER_PREFIX : `${MARKER_PREFIX}${nonce} `; if (!line.startsWith(prefix)) return null; const [type, ...args] = line.slice(prefix.length).trim().split(" "); const number = (value: string | undefined) => Number(value ?? "0"); switch (type) { case "step-start": return { type, index: number(args[0]) }; case "step-end": return { type, index: number(args[0]), code: number(args[1]) }; case "step-skip": return { type, index: number(args[0]) }; case "artifact": return { type, name: args[0] ?? "artifact" }; case "output": return { type, name: args[0] ?? "", value: decodeBase64(args[1] ?? "") }; case "job-end": return { type, failed: args[0] !== "0" }; case "setup-start": return { type }; case "setup-end": return { type, code: number(args[0]) }; default: return null; } } const MAX_JOB_OUTPUTS = 50; const MAX_OUTPUT_BYTES = 64 * 1024; /** Job outputs flow into other jobs' scripts, so their count and total size are bounded. */ export function withOutput(outputs: Record, name: string, value: string): Record { if (!(name in outputs) && Object.keys(outputs).length >= MAX_JOB_OUTPUTS) return outputs; const used = Object.entries(outputs).reduce((sum, [key, item]) => sum + (key === name ? 0 : item.length), 0); if (used + value.length > MAX_OUTPUT_BYTES) return outputs; return { ...outputs, [name]: value }; } const ADD_MASK = "::add-mask::"; /** Masks a step adds through `::add-mask::`; any step can add them, so they are bounded. */ export const MAX_ADDED_MASKS = 200; /** * The value of a `::add-mask::` workflow command. Values shorter than the secret minimum are ignored, * so a step can't blank the log by masking a single character. */ export function addMaskValue(line: string): string | null { const start = line.indexOf(ADD_MASK); if (start === -1 || line.slice(0, start).trim() !== "") return null; const value = line.slice(start + ADD_MASK.length).trim(); return value.length >= MIN_MASK_LENGTH ? value : null; } /** Adds a step-requested mask unless it is already masked or the job hit MAX_ADDED_MASKS; null means no change. */ export function withAddedMask(masks: string[], added: number, value: string): string[] | null { if (masks.includes(value) || added >= MAX_ADDED_MASKS) return null; return [...masks, value]; } /** Base64 of `bytes`, done by hand so it works on any byte length without spreading huge arrays. */ function toBase64(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary); } /** * Base64 and percent-encoded forms a step might print a secret in (CI-P8). Base64 depends on the secret's * offset in the encoded stream, so the stable core is taken at each of the three alignments. */ function encodedForms(value: string): string[] { const bytes = new TextEncoder().encode(value); const forms = [toBase64(bytes), encodeURIComponent(value)]; for (let shift = 0; shift < BASE64_GROUP_BYTES; shift++) { const shifted = new Uint8Array(shift + bytes.byteLength); shifted.set(bytes, shift); const whole = Math.floor(shifted.byteLength / BASE64_GROUP_BYTES) * BASE64_GROUP_BYTES; const core = toBase64(shifted.subarray(0, whole)).slice(shift > 0 ? 4 : 0); forms.push(core, core.replaceAll("+", "-").replaceAll("/", "_")); } return forms.filter((form) => form !== value && form.length >= MIN_LINE_MASK_LENGTH); } /** Multi-line secrets are printed line by line, so every line is masked on its own too. */ export function maskValues(values: string[]): string[] { const out = new Set(); for (const value of values) { if (value.length >= MIN_MASK_LENGTH) out.add(value); if (value.length >= MIN_LINE_MASK_LENGTH) for (const form of encodedForms(value)) out.add(form); for (const line of value.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.length >= MIN_LINE_MASK_LENGTH && !trimmed.startsWith("-----")) out.add(trimmed); } } return [...out].sort((left, right) => right.length - left.length); } /** Names a job may report through markers: its upload-artifact steps and declared outputs. */ export function declaredNames(execution: JobExecution): { allowedArtifacts: string[]; allowedOutputs: string[] } { return { allowedArtifacts: execution.spec.steps.flatMap((step) => (step.artifact ? [step.artifact.name] : [])), allowedOutputs: execution.spec.outputs.map(([name]) => name), }; }