#!/usr/bin/env node // Support bundle: one archive that answers "please attach a trace" without asking the // reporter to hand-collect files. // // WHAT GOES IN: the newest N trace directories (`WINDSURFAPI_TRACE_DIR`, default // `/.trace`), plus `environment.json` — the commit/version `GET /health` reports, the // package version, and a REDACTED switch snapshot (WINDSURFAPI_*/DEVIN_*/CASCADE_* names // whose values are booleans or short lowercase enums only). Token-shaped names are dropped // by name, and path/model-shaped values are dropped by value, so a switch snapshot cannot // become a credential leak. // // THE SECRET GATE: every staged file is scanned with `findRuleMatches()` from // scripts/secret-rules.mjs — the SAME rule engine behind scripts/secret-scan.mjs (working // tree) and scripts/history-secret-scan.mjs (git history). The two CLIs cannot be pointed at // a staging directory: the tree scanner refuses paths outside the repository, and the // history scanner reads git objects rather than files. Calling the shared matcher is what // keeps this gate from becoming a third, drifting rule set. A hit means NOTHING is written. // // REDACTION: text legs have credential field values, account emails and presigned-URL query // credentials replaced with `[redacted]` before the scan. Binary legs are NOT preserved // verbatim. `03-upstream-req.bin` is a GetChatMessageRequest whose ClientMetadata carries // the account session token (#3) and optionally a user JWT (#21) — credentials whose shape // the shared rules cannot recognise when they are opaque. The bundler rewrites exactly those // fields, copies every other byte, and then searches the whole staging tree for the removed // credential values; a hit refuses the bundle. A binary leg that is not a recognisable // GetChatMessageRequest is never shipped: the manifest records its path, byte count and // sha256 so a maintainer can match it against a file sent separately. That is defence in // depth, not the gate — the gate still refuses anything the redactor did not recognise. // // EXIT: 0 bundle written; 1 secret scan found a candidate (no zip, no manifest); // 2 usage / I/O / environment failure. A partial bundle is never reported as success. import { createHash } from 'node:crypto'; import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { crc32 } from 'node:zlib'; import { findRuleMatches, lineForOffset } from './secret-rules.mjs'; import { decodeVarint, writeMessageField, writeStringField } from '../src/proto.js'; import { getVersionInfo } from '../src/version.js'; const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url))); const DEFAULT_TRACE_COUNT = 5; const DEFAULT_MAX_FILE_MB = 128; // `GET /health` serves exactly these fields from `getVersionInfo()` (src/server.js). Reading // the same function means the bundle cannot claim a different commit than the running // server would have reported. const HEALTH_SOURCE = 'src/version.js getVersionInfo() — the values GET /health serves'; const USAGE = `Usage: node scripts/support-bundle.mjs [options] Pack the newest trace directories plus a redacted environment snapshot into support-bundle-.zip (with manifest.json listing every file and its sha256). Options: --trace-dir trace root (default: $WINDSURFAPI_TRACE_DIR or /.trace) --traces number of newest trace directories to include (default: ${DEFAULT_TRACE_COUNT}) --out output directory (default: cwd) --max-file-mb skip a single trace file larger than n MiB (default: ${DEFAULT_MAX_FILE_MB}) --json print a machine-readable summary -h, --help show this help The bundle is refused (exit 1, nothing written) when the shared secret rules match any staged file, or when a credential removed from the upstream request is still present elsewhere in the staging tree. Trace legs contain prompt fragments but no API keys: text legs are redacted before the scan (field values, account emails, presigned-URL query credentials), the upstream request protobuf has its ClientMetadata session token / user JWT replaced with [redacted], and any other binary leg is omitted — the manifest records its path, byte count and sha256 instead.`; // ── option parsing ──────────────────────────────────────────────────────────────────────── function parseOptions(argv) { const { values } = parseArgs({ args: argv, strict: true, allowPositionals: false, options: { 'trace-dir': { type: 'string' }, traces: { type: 'string' }, out: { type: 'string' }, 'max-file-mb': { type: 'string' }, json: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false }, }, }); if (values.help) return { help: true }; const count = values.traces === undefined ? DEFAULT_TRACE_COUNT : Number(values.traces); if (!Number.isInteger(count) || count < 0 || count > 100) { throw new Error(`--traces must be an integer between 0 and 100 (got ${values.traces})`); } const maxFileMb = values['max-file-mb'] === undefined ? DEFAULT_MAX_FILE_MB : Number(values['max-file-mb']); if (!Number.isFinite(maxFileMb) || maxFileMb <= 0 || maxFileMb > 4096) { throw new Error(`--max-file-mb must be a number between 0 and 4096 (got ${values['max-file-mb']})`); } const fromFlag = values['trace-dir'] !== undefined; const fromEnv = !fromFlag && Boolean(process.env.WINDSURFAPI_TRACE_DIR); return { help: false, traceDir: resolve(values['trace-dir'] || process.env.WINDSURFAPI_TRACE_DIR || join(process.cwd(), '.trace')), traceDirSource: fromFlag ? '--trace-dir' : (fromEnv ? 'WINDSURFAPI_TRACE_DIR' : 'default'), traceCount: count, outDir: resolve(values.out || process.cwd()), maxFileBytes: Math.floor(maxFileMb * 1024 * 1024), json: values.json, }; } // ── trace selection ────────────────────────────────────────────────────────────────────── // Trace ids are sortable by design (`-`, src/trace.js), but mtime is // what "newest" means to an operator copying directories around, so sort by mtime and use // the id only to break ties deterministically. function listTraceDirs(traceDir) { if (!existsSync(traceDir)) return null; const dirs = []; for (const entry of readdirSync(traceDir, { withFileTypes: true })) { // Dirent.isDirectory() is false for a symlink, so a link out of the trace root is never // followed into the bundle. if (!entry.isDirectory()) continue; const abs = join(traceDir, entry.name); let mtimeMs; try { mtimeMs = lstatSync(abs).mtimeMs; } catch { continue; // removed between readdir and lstat: not a trace we can read } dirs.push({ id: entry.name, abs, mtimeMs }); } dirs.sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id)); return dirs; } function collectFiles(rootAbs, maxFileBytes) { const files = []; const skipped = []; const walk = (dir, rel) => { let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch (error) { throw new Error(`cannot read trace directory ${rel || '.'} (${error?.code || error?.message})`); } for (const entry of entries) { const abs = join(dir, entry.name); const relPath = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isSymbolicLink()) { skipped.push({ path: relPath, reason: 'symlink (not followed)' }); continue; } if (entry.isDirectory()) { walk(abs, relPath); continue; } if (!entry.isFile()) { skipped.push({ path: relPath, reason: 'not a regular file' }); continue; } const size = statSync(abs).size; if (size > maxFileBytes) { skipped.push({ path: relPath, reason: `larger than ${maxFileBytes} bytes` }); continue; } files.push({ abs, rel: relPath, size }); } }; walk(rootAbs, ''); files.sort((a, b) => a.rel.localeCompare(b.rel)); return { files, skipped }; } // ── redaction + switch snapshot ───────────────────────────────────────────────────────── // Every replacement value. A residual check below depends on this exact spelling: a value // that is not `[redacted]` is treated as an unredacted leak. const REDACTED = '[redacted]'; // Sensitive field names, shared by SECRET_FIELD / RESIDUAL_FIELD and the query // pair below so the four patterns cannot drift apart. // // Two coverage gaps used to leak: (a) a bare `token`/`secret`/`key` tail after // a `_` was invisible to `\b` (`_` is a word char, so `\btoken\b` never fired // inside `csrf_token` — only the explicitly enumerated *_token spellings were // covered); (b) jwt / bearer / credential / the *_key and *_secret families // were missing outright. The three catch-all alternatives below claim any // [-_], or spelling — // access_token, session_token, csrf_token, client_secret, private-key, // sort_key, __RequestVerificationToken, and the camel forms OAuth payloads use // (accessToken, refreshToken) — without an allowlist to keep in sync. Bare // `key`/`jwt`/`bearer` tails stay absent from the standalone list so // `sort_keys`, `tokenizer` and `keystore` still don't match; the trailing \b // in the consuming regexes keeps `xtokenizer` out as well. Deliberate // over-redaction: a `monkey:`-shaped field loses its value — a wrong // [redacted] beats a live credential in a public attachment. const SECRET_NAME_SRC = 'authorization|proxy-authorization|x-api-key|api[-_]?key|caller[-_]?key' + '|access[-_]?token|refresh[-_]?token|id[-_]?token|session[-_]?token|auth[-_]?token|csrf[-_]?token|xsrf[-_]?token' + '|token|jwt|bearer|secret|client[-_]?secret|password|passwd|cookie|set-cookie|credentials?' + '|private[-_]?key|secret[-_]?key|session[-_]?key|signing[-_]?key' + '|(?:[a-z0-9]+[-_])+(?:token|jwt|key|secret|password|passwd|cookie|credentials?|bearer)' + '|[-_]+[a-z0-9]*(?:token|jwt|key|secret|password|passwd|cookie|credentials?|bearer)' + '|[a-z0-9]+(?:token|jwt|key|secret|password|passwd|cookie|credentials?|bearer)'; // A field NAME followed by `:` or `=` and a scalar value. The value class excludes quotes, // braces, brackets, commas and newlines, so JSON structure survives; spaces are allowed // after the first character because an Authorization value is `Bearer `. The first // character must be non-whitespace: otherwise the optional whitespace before the value can // backtrack and turn `"token": ""` into a match on the space alone, corrupting the JSON. // The leading boundary is (? { count += 1; return `${prefix}${REDACTED}`; }) .replace(SECRET_FIELD, (_match, prefix) => { count += 1; return `${prefix}${REDACTED}`; }) .replace(EMAIL_ADDRESS, () => { count += 1; return REDACTED; }); return { text: redacted, count }; } // Findings for text that still looks sensitive after redaction. `matchAll` clones nothing: // each regex is global, so lastIndex is reset before use. // // An empty value is not a leak: `"token": ""` and `?token=&x=1` are harmless, and refusing // them would turn ordinary prompt text into an unusable bundle. The prefix regex consumes // an opening quote, so an empty quoted value leaves the closing quote at the front. const EMPTY_VALUE = /^(?:$|[&#'"\s}\],])/; function findResidualSensitive(text) { const findings = []; const afterName = (regex, rule) => { regex.lastIndex = 0; for (const match of text.matchAll(regex)) { const rest = text.slice(match.index + match[0].length); if (rest.startsWith(REDACTED) || EMPTY_VALUE.test(rest)) continue; findings.push({ rule, index: match.index + match[0].length }); } }; afterName(RESIDUAL_FIELD, 'secret-field'); afterName(RESIDUAL_QUERY, 'presigned-url-credential'); for (const match of text.matchAll(RESIDUAL_EMAIL)) findings.push({ rule: 'email-address', index: match.index }); return findings; } const SWITCH_NAME = /^(?:WINDSURFAPI|DEVIN|CASCADE)_/; // Name-level exclusions: a token can be short and lowercase, so value shape alone is not // enough. `CRED` covers DEVIN_CONNECT_CRED_KEY; `SESSION` covers session-id switches. const SECRET_NAME = /(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|(?:^|_)KEY(?:_|$)|COOKIE|CRED|EMAIL|PRIVATE|SESSION|AUTHORIZATION)/i; const BOOLEAN_VALUE = /^(?:0|1|true|false|on|off|yes|no)$/i; // Enums only: lowercase tokens such as `debug`, `native`, `legacy`, `auto`. Paths, model // ids, versions and mixed-case/random strings all fail this shape. const ENUM_VALUE = /^[a-z][a-z0-9_-]{0,23}$/; function snapshotSwitches(env) { const switches = {}; let excluded = 0; for (const name of Object.keys(env).sort()) { if (!SWITCH_NAME.test(name)) continue; if (SECRET_NAME.test(name)) { excluded += 1; continue; } const value = String(env[name] ?? ''); if (!BOOLEAN_VALUE.test(value) && !ENUM_VALUE.test(value)) { excluded += 1; continue; } switches[name] = value; } return { switches, excluded }; } // ── text/binary split ──────────────────────────────────────────────────────────────────── // A trace leg is text if it has no NUL byte AND its UTF-8 decode round-trips to the same // bytes. Everything else is a binary leg and goes through `redactConnectRequest` below. function isText(buffer) { if (buffer.includes(0)) return false; return Buffer.from(buffer.toString('utf8'), 'utf8').equals(buffer); } // ── binary legs: redact the request, omit what cannot be proved safe ──────────────────── // // The only binary leg that ships is a GetChatMessageRequest the bundler can rewrite. Its // field #1 is ClientMetadata (src/devin-connect.js buildClientMetadata): #1.#3 is the // account session token and #1.#21 is the optional user JWT. Both are replaced with // `[redacted]`; every other byte — system prompt, messages, model, tools, config — is // copied verbatim. The removed values are returned so `main` can search the entire staging // tree for them; a hit means the credential survives somewhere else and the bundle is // refused. A buffer that is not recognisably this request is never shipped. // // The parser is schema-less and span-based on purpose: re-encoding untouched fields would // change bytes the issue is about, and the repository has no protobuf dependency. const CLIENT_NAME = 'chisel'; // src/devin-connect.js CLIENT_NAME, the #1.#1 marker const CLIENT_NAME_BYTES = Buffer.from(CLIENT_NAME, 'utf8'); const METADATA_TOKEN_FIELD = 3; const METADATA_USER_JWT_FIELD = 21; // [{ field, wireType, start, valueStart, end }] with start/end covering the whole field // (tag + length + value) so callers can copy untouched fields byte-for-byte. function readFieldSpans(buffer) { const fields = []; let pos = 0; while (pos < buffer.length) { const start = pos; const tag = decodeVarint(buffer, pos); pos += tag.length; const field = Number(tag.value) >>> 3; const wireType = Number(tag.value) & 0x07; if (field === 0) throw new Error('protobuf field number 0'); let valueStart; if (wireType === 0) { valueStart = pos; pos += decodeVarint(buffer, pos).length; } else if (wireType === 1) { valueStart = pos; pos += 8; } else if (wireType === 2) { const length = decodeVarint(buffer, pos); pos += length.length; valueStart = pos; pos += Number(length.value); } else if (wireType === 5) { valueStart = pos; pos += 4; } else { throw new Error(`unsupported protobuf wire type ${wireType}`); } if (pos > buffer.length) throw new Error('truncated protobuf field'); fields.push({ field, wireType, start, valueStart, end: pos }); } return fields; } function copyWithReplacements(buffer, fields, replacements) { const parts = []; for (let i = 0; i < fields.length; i += 1) { const field = fields[i]; parts.push(replacements.has(i) ? replacements.get(i) : buffer.subarray(field.start, field.end)); } return Buffer.concat(parts); } // Returns { ok, buffer?, credentials, reason? }. `credentials` is always populated when a // token field was found, including on the failure path, so the caller can still search for // those bytes in text legs. function redactConnectRequest(buffer) { let top; try { top = readFieldSpans(buffer); } catch { return { ok: false, credentials: [], reason: 'not a redactable GetChatMessageRequest: unparsable protobuf' }; } const metadataFields = top.filter((field) => field.field === 1 && field.wireType === 2); if (metadataFields.length !== 1) { return { ok: false, credentials: [], reason: 'not a redactable GetChatMessageRequest: no single ClientMetadata' }; } const metadataField = metadataFields[0]; const metadata = buffer.subarray(metadataField.valueStart, metadataField.end); let metadataSpans; try { metadataSpans = readFieldSpans(metadata); } catch { return { ok: false, credentials: [], reason: 'not a redactable GetChatMessageRequest: unparsable ClientMetadata' }; } const clientName = metadataSpans.find((field) => field.field === 1 && field.wireType === 2); if (!clientName || metadata.toString('utf8', clientName.valueStart, clientName.end) !== CLIENT_NAME) { return { ok: false, credentials: [], reason: 'not a redactable GetChatMessageRequest: ClientMetadata #1 != chisel' }; } const tokenSpans = metadataSpans.filter( (field) => field.field === METADATA_TOKEN_FIELD || field.field === METADATA_USER_JWT_FIELD, ); if (tokenSpans.some((field) => field.wireType !== 2)) { return { ok: false, credentials: [], reason: 'not a redactable GetChatMessageRequest: unexpected token wire type' }; } const credentials = []; const metadataReplacements = new Map(); for (let i = 0; i < metadataSpans.length; i += 1) { const field = metadataSpans[i]; if (field.field !== METADATA_TOKEN_FIELD && field.field !== METADATA_USER_JWT_FIELD) continue; const value = metadata.subarray(field.valueStart, field.end); if (value.length > 0) credentials.push(Buffer.from(value)); metadataReplacements.set(i, writeStringField(field.field, REDACTED)); } if (credentials.length === 0) { return { ok: false, credentials, reason: 'not a redactable GetChatMessageRequest: ClientMetadata carries no token' }; } const redactedMetadata = copyWithReplacements(metadata, metadataSpans, metadataReplacements); const topReplacements = new Map([ [top.indexOf(metadataField), writeMessageField(1, redactedMetadata)], ]); const redacted = copyWithReplacements(buffer, top, topReplacements); // The proof: a removed credential must not survive anywhere in the rewritten leg. If the // same value also rides in a message/system prompt, the leg is not safe to ship. if (credentials.some((credential) => redacted.includes(credential))) { return { ok: false, credentials, reason: 'credential bytes also appear outside ClientMetadata' }; } return { ok: true, buffer: redacted, credentials }; } // ── staging + secret scan ──────────────────────────────────────────────────────────────── function writeStaged(staging, rel, bytes) { const abs = join(staging, ...rel.split('/')); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, bytes); } function listStagedFiles(dir) { const files = []; const walk = (abs, rel) => { for (const entry of readdirSync(abs, { withFileTypes: true })) { const childAbs = join(abs, entry.name); const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) walk(childAbs, childRel); else if (entry.isFile()) files.push(childRel); else throw new Error(`unexpected non-regular file in staging: ${childRel}`); } }; walk(dir, ''); return files.sort(); } // The rule matcher is shared, but its fixture exemption is path-based (`test/` only). The // staged path is what a reviewer would open in the zip, so that is the path it gets. function scanStaged(staging) { const files = listStagedFiles(staging); const findings = []; for (const rel of files) { const buffer = readFileSync(join(staging, ...rel.split('/'))); const textual = isText(buffer); const text = textual ? buffer.toString('utf8') : buffer.toString('latin1'); for (const match of findRuleMatches(text, [rel])) { findings.push({ path: rel, line: lineForOffset(text, match.index), rule: match.rule }); } // Binary legs are already redacted or omitted; only text can still carry a field value, // an email or a URL query credential after `redactText`. if (textual) { for (const residual of findResidualSensitive(text)) { findings.push({ path: rel, line: lineForOffset(text, residual.index), rule: residual.rule }); } } } findings.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.rule.localeCompare(b.rule)); return { files: files.length, findings }; } // Every credential value removed from a request leg, searched across everything staged. // This is what makes the opaque-token case provable: the shared rules cannot recognise the // shape, so the exact bytes taken out of ClientMetadata are the ground truth. A hit means // the redactor missed an occurrence elsewhere and the bundle must not be written. function findCredentialBytes(staging, credentials) { const findings = []; if (credentials.length === 0) return findings; for (const rel of listStagedFiles(staging)) { const buffer = readFileSync(join(staging, ...rel.split('/'))); for (const credential of credentials) { if (credential.length === 0) continue; const index = buffer.indexOf(credential); if (index === -1) continue; // latin1 keeps the byte offset 1:1 with the character offset `lineForOffset` counts. findings.push({ path: rel, line: lineForOffset(buffer.toString('latin1'), index), rule: 'credential-value' }); break; } } return findings; } // ── zip (STORE, no compression) ───────────────────────────────────────────────────────── // // A store-only writer keeps the archive dependency-free and makes every stored entry // byte-identical to what the manifest hashed (the redacted request included; omitted binary // legs are simply absent). The repository has zero npm runtime dependencies by design; // adding one for a debug bundle would be a poor trade. Node's built-in zlib supplies CRC-32. function dosDateTime(date) { const time = ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() >> 1) & 0x1f); const day = (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0x0f) << 5) | (date.getDate() & 0x1f); return { time, day }; } function buildZip(entries) { if (entries.length > 0xffff) throw new Error('too many files for a zip archive (max 65535)'); const { time, day } = dosDateTime(new Date()); const chunks = []; const central = []; let offset = 0; for (const { name, data } of entries) { const nameBytes = Buffer.from(name, 'utf8'); const checksum = crc32(data) >>> 0; const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); // local file header local.writeUInt16LE(20, 4); // version needed local.writeUInt16LE(0x0800, 6); // UTF-8 file name local.writeUInt16LE(0, 8); // method: store local.writeUInt16LE(time, 10); local.writeUInt16LE(day, 12); local.writeUInt32LE(checksum, 14); local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(nameBytes.length, 26); local.writeUInt16LE(0, 28); // extra field length chunks.push(local, nameBytes, data); const header = Buffer.alloc(46); header.writeUInt32LE(0x02014b50, 0); // central directory header header.writeUInt16LE(20, 4); // version made by header.writeUInt16LE(20, 6); // version needed header.writeUInt16LE(0x0800, 8); // UTF-8 file name header.writeUInt16LE(0, 10); // method: store header.writeUInt16LE(time, 12); header.writeUInt16LE(day, 14); header.writeUInt32LE(checksum, 16); header.writeUInt32LE(data.length, 20); header.writeUInt32LE(data.length, 24); header.writeUInt16LE(nameBytes.length, 28); header.writeUInt16LE(0, 30); // extra field length header.writeUInt16LE(0, 32); // comment length header.writeUInt16LE(0, 34); // disk number header.writeUInt16LE(0, 36); // internal attributes header.writeUInt32LE(0, 38); // external attributes header.writeUInt32LE(offset, 42); // local header offset central.push(header, nameBytes); offset += local.length + nameBytes.length + data.length; } const centralBytes = Buffer.concat(central); if (offset + centralBytes.length > 0xffffffff) { throw new Error('bundle exceeds the 4 GiB zip limit'); } const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); // end of central directory end.writeUInt16LE(0, 4); // disk number end.writeUInt16LE(0, 6); // central directory start disk end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(centralBytes.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20); // comment length return Buffer.concat([...chunks, centralBytes, end]); } const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex'); function utcStamp(date) { return date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, ''); } // ── main ──────────────────────────────────────────────────────────────────────────────── function main(argv) { let options; try { options = parseOptions(argv); } catch (error) { console.error(`support-bundle: ${error.message}`); console.error(USAGE); return 2; } if (options.help) { console.log(USAGE); return 0; } const packageJson = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); const versionInfo = getVersionInfo(); const allTraceDirs = listTraceDirs(options.traceDir); if (allTraceDirs === null) { if (options.traceDirSource !== 'default') { console.error(`support-bundle: trace directory ${options.traceDir} does not exist — refusing to build a bundle that silently has no traces`); return 2; } console.error(`support-bundle: ${options.traceDir} does not exist; packaging the environment snapshot only`); } const traceDirs = allTraceDirs || []; const selected = traceDirs.slice(Math.max(0, traceDirs.length - options.traceCount)); mkdirSync(options.outDir, { recursive: true }); const staging = mkdtempSync(join(tmpdir(), 'windsurfapi-support-')); try { const redactions = []; const skippedFiles = []; const binaryRedactions = []; const binaryOmitted = []; const credentials = []; let traceFileCount = 0; for (const trace of selected) { const { files, skipped } = collectFiles(trace.abs, options.maxFileBytes); for (const item of skipped) skippedFiles.push({ trace: trace.id, ...item }); traceFileCount += files.length; for (const file of files) { const raw = readFileSync(file.abs); const rel = `traces/${trace.id}/${file.rel}`; // Content first: a GetChatMessageRequest is redacted even if it would pass the // text heuristic, so a future builder that emits only printable bytes cannot turn // the request leg into a "text" leg that skips protobuf redaction. The `chisel` // marker is a cheap precondition; only matching buffers pay for the parser. const request = raw.includes(CLIENT_NAME_BYTES) ? redactConnectRequest(raw) : null; if (request && (request.ok || request.credentials.length > 0)) { for (const credential of request.credentials) credentials.push(credential); if (request.ok) { writeStaged(staging, rel, request.buffer); binaryRedactions.push({ path: rel, sourceBytes: raw.length, sourceSha256: sha256(raw), credentialsRedacted: request.credentials.length, }); } else { binaryOmitted.push({ path: rel, bytes: raw.length, sha256: sha256(raw), reason: request.reason }); } } else if (isText(raw)) { const { text, count } = redactText(raw.toString('utf8')); if (count > 0) redactions.push({ path: rel, count }); writeStaged(staging, rel, Buffer.from(text, 'utf8')); } else { // Any other binary leg is omitted with a size+sha256 record: the bundler cannot // prove an opaque credential is absent from bytes it does not understand. binaryOmitted.push({ path: rel, bytes: raw.length, sha256: sha256(raw), reason: request?.reason || 'binary leg: not a GetChatMessageRequest (no ClientMetadata marker)', }); } } } const { switches, excluded: switchesExcluded } = snapshotSwitches(process.env); const createdAt = new Date().toISOString(); const environment = { createdAt, version: packageJson.version, health: { source: HEALTH_SOURCE, status: 'ok', version: versionInfo.version, commit: versionInfo.commit, commitMessage: versionInfo.commitMessage, commitDate: versionInfo.commitDate, branch: versionInfo.branch, buildSource: versionInfo.source, packageVersion: packageJson.version, node: process.version, platform: process.platform, arch: process.arch, }, switches, switchesExcluded, traces: { source: options.traceDirSource, requested: options.traceCount, found: traceDirs.length, included: selected.map((trace) => trace.id), omitted: Math.max(0, traceDirs.length - selected.length), files: traceFileCount, skipped: skippedFiles, redactions, binaryLegs: { redacted: binaryRedactions, omitted: binaryOmitted }, }, }; // environment.json is generated, but `health.commitMessage` is free text from git and // can carry an address. Redact it with the same rules as a trace leg so the residual // gate has nothing to refuse on our own metadata. const environmentText = `${JSON.stringify(environment, null, 2)}\n`; const environmentRedaction = redactText(environmentText); let stagedEnvironment = environmentRedaction.text; if (environmentRedaction.count > 0) { const parsed = JSON.parse(stagedEnvironment); parsed.environmentRedactions = environmentRedaction.count; stagedEnvironment = `${JSON.stringify(parsed, null, 2)}\n`; } writeStaged(staging, 'environment.json', Buffer.from(stagedEnvironment, 'utf8')); // THE GATE: scan everything that would enter the archive, including environment.json, // then prove the exact bytes removed from any request leg are nowhere in the staging // tree. The second check is the one that covers opaque tokens. const scan = scanStaged(staging); const findings = [...scan.findings, ...findCredentialBytes(staging, credentials)] .sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.rule.localeCompare(b.rule)); if (findings.length > 0) { if (options.json) { console.log(JSON.stringify({ ok: false, error: 'secret-scan', findings }, null, 2)); } else { for (const finding of findings) { console.error(`${finding.path}:${finding.line} ${finding.rule}`); } console.error(`support-bundle: refusing to package — ${findings.length} credential candidate(s) found; nothing was written`); } return 1; } const redactedByPath = new Map(binaryRedactions.map((item) => [item.path, item])); const manifestFiles = listStagedFiles(staging).map((rel) => { const data = readFileSync(join(staging, ...rel.split('/'))); const entry = { path: rel, bytes: data.length, sha256: sha256(data) }; const redaction = redactedByPath.get(rel); if (redaction) { entry.sourceBytes = redaction.sourceBytes; entry.sourceSha256 = redaction.sourceSha256; entry.transforms = ['client-metadata-credentials']; } return entry; }); const redactedValues = redactions.reduce((sum, item) => sum + item.count, 0); const manifest = { createdAt, tool: 'scripts/support-bundle.mjs', version: packageJson.version, commit: versionInfo.commit, scan: { engine: 'scripts/secret-rules.mjs', files: scan.files, findings: 0 }, redactions: { files: redactions.length, values: redactedValues, environment: environmentRedaction.count }, binaryLegs: { redacted: binaryRedactions, omitted: binaryOmitted }, files: manifestFiles, note: 'manifest.json is not listed: it is this file. sha256 is over the exact bytes stored in the zip. A redacted binary leg carries sourceBytes/sourceSha256 for the original; an omitted binary leg is not in the zip at all.', }; const manifestText = `${JSON.stringify(manifest, null, 2)}\n`; writeStaged(staging, 'manifest.json', Buffer.from(manifestText, 'utf8')); const zipName = `support-bundle-${utcStamp(new Date())}.zip`; const zipPath = join(options.outDir, zipName); const manifestPath = join(options.outDir, zipName.replace(/\.zip$/, '.manifest.json')); const entries = listStagedFiles(staging).map((rel) => ({ name: rel, data: readFileSync(join(staging, ...rel.split('/'))), })); const zip = buildZip(entries); writeFileSync(zipPath, zip); writeFileSync(manifestPath, manifestText); if (options.json) { console.log(JSON.stringify({ ok: true, zip: zipPath, manifest: manifestPath, bytes: zip.length, files: entries.length, traces: selected.map((trace) => trace.id), redactions: redactedValues, binaryLegs: { redacted: binaryRedactions.length, omitted: binaryOmitted.length }, scan: { files: scan.files, findings: 0 }, }, null, 2)); } else { console.log(`support-bundle: wrote ${zipPath} (${entries.length} files, ${zip.length} bytes, ${selected.length} trace dir(s))`); console.log(`support-bundle: manifest ${manifestPath}`); console.log(`support-bundle: secret scan clean (${scan.files} files, 0 findings); ${redactedValues} text value(s) redacted; ${binaryRedactions.length} request leg(s) redacted; ${binaryOmitted.length} binary leg(s) omitted`); if (binaryOmitted.length) { console.error(`support-bundle: ${binaryOmitted.length} binary leg(s) omitted (cannot prove credential-free); size + sha256 are in manifest.json`); } if (skippedFiles.length) { console.error(`support-bundle: ${skippedFiles.length} trace file(s) skipped (symlink/oversize); listed in environment.json`); } } return 0; } finally { rmSync(staging, { recursive: true, force: true }); } } try { process.exitCode = main(process.argv.slice(2)); } catch (error) { console.error(`support-bundle: ${error?.stack || error}`); process.exitCode = 2; }