scripts/support-bundle.mjs
Rama master · réplica de lectura del repositorio original.
#!/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
// `<cwd>/.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-<utc>.zip (with manifest.json listing every file and its sha256).
Options:
--trace-dir <dir> trace root (default: $WINDSURFAPI_TRACE_DIR or <cwd>/.trace)
--traces <n> number of newest trace directories to include (default: ${DEFAULT_TRACE_COUNT})
--out <dir> output directory (default: cwd)
--max-file-mb <n> 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 (`<epoch-ms base36>-<rand>`, 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
// <name>[-_]<tail>, <sep-prefixed><tail> or <alnum><tail> 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 <token>`. 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 (?<![A-Za-z0-9]), not \b: \b treats _ as a word char and would
// keep the catch-alls from ever starting inside `foo_token`.
const SECRET_FIELD = new RegExp(
`((?<![A-Za-z0-9])(?:${SECRET_NAME_SRC})\\b["']?\\s*[:=]\\s*["']?)([^"'{}[\\],\\r\\n\\s][^"'{}[\\],\\r\\n]*)`,
'gi',
);
// A URL query credential. Presigned object URLs (S3 and compatible stores) put the whole
// grant in the query string; `X-Amz-*` covers SigV4's Credential/Signature/Security-Token
// and any later parameter, and the shared name list covers the other spellings in the
// wild (including `_`-joined forms like `?csrf_token=` that the same \b gap hid here).
// The value class stops at URL/JSON delimiters and at `[`/`]` so `[redacted]` is idempotent.
const QUERY_CREDENTIAL = new RegExp(
`([?&](?:x-amz-[a-z0-9-]+|signature|key|${SECRET_NAME_SRC})=)([^&#"'\\s\\\\[\\]]+)`,
'gi',
);
// Account identity rather than a credential, but the routing leg stores `ccAcct.email` and
// an issue attachment is public. Any address shape is removed, wherever it appears.
const EMAIL_ADDRESS = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/g;
// Independent residual patterns: they look for a sensitive NAME and then assert the value
// that follows is exactly `[redacted]`. Deliberately not the redaction regexes — a future
// edit that changes a replacement must fail here instead of shipping the value. The NAME
// list itself is shared (SECRET_NAME_SRC): a name the redactor knows but the residual
// check cannot find is precisely the gap this pattern exists to catch.
const RESIDUAL_FIELD = new RegExp(`(?<![A-Za-z0-9])(?:${SECRET_NAME_SRC})\\b["']?\\s*[:=]\\s*["']?`, 'gi');
const RESIDUAL_QUERY = new RegExp(`[?&](?:x-amz-[a-z0-9-]+|signature|key|${SECRET_NAME_SRC})=`, 'gi');
const RESIDUAL_EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/g;
function redactText(text) {
let count = 0;
const redacted = text
// The query pattern runs FIRST: its value class stops at `&`, so each
// `?name=` parameter is redacted individually. SECRET_FIELD's value class
// tolerates `&`, which used to be harmless — the shared name list now
// includes `credential`, and `X-Amz-Credential=AKIA…&X-Amz-Signature=…`
// would let the field regex swallow the signature (and every later
// parameter) into one redaction. After QUERY_CREDENTIAL has run, the
// remaining `name=[redacted]` text is idempotent for SECRET_FIELD: `[`
// cannot start its value class.
.replace(QUERY_CREDENTIAL, (_match, prefix) => {
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;
}