128 lines
5.0 KiB
TypeScript
128 lines
5.0 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { basename, join, relative, resolve } from "node:path";
|
|
import { diagnose } from "@wrnexus/compiler";
|
|
import { currentCliVersion } from "./update-notifier.ts";
|
|
|
|
const SENSITIVE_KEY =
|
|
/(?:secret|token|password|passwd|credential|api[-_]?key|private[-_]?key|cookie|authorization|session|dsn|database[-_]?url)/i;
|
|
function sanitizeText(value: string): string {
|
|
return value
|
|
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]")
|
|
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED_IP]")
|
|
.replace(/https?:\/\/[^\s"'`/]+/gi, (origin) =>
|
|
/localhost|127\.0\.0\.1|example\.(?:com|test|org)/i.test(origin)
|
|
? origin
|
|
: "https://[REDACTED_DOMAIN]",
|
|
)
|
|
.replace(/\b(?:sk|pk|wrn|ghp|xox[baprs])[_-][A-Za-z0-9_-]{12,}\b/g, "[REDACTED_TOKEN]")
|
|
.replace(
|
|
/((?:secret|token|password|apiKey|authorization|cookie)\s*[:=]\s*)(["'`])[^"'`]*\2/gi,
|
|
"$1$2[REDACTED]$2",
|
|
)
|
|
.slice(0, 512 * 1024);
|
|
}
|
|
function sanitizeValue(value: unknown, key = ""): unknown {
|
|
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
|
if (typeof value === "string") return sanitizeText(value);
|
|
if (Array.isArray(value)) return value.slice(0, 200).map((item) => sanitizeValue(item));
|
|
if (value && typeof value === "object")
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.slice(0, 500)
|
|
.map(([name, item]) => [name, sanitizeValue(item, name)]),
|
|
);
|
|
return value;
|
|
}
|
|
function packageVersions(root: string) {
|
|
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
};
|
|
return Object.fromEntries(
|
|
Object.entries({ ...manifest.dependencies, ...manifest.devDependencies }).sort(
|
|
([left], [right]) => left.localeCompare(right),
|
|
),
|
|
);
|
|
}
|
|
export interface ReproductionReportOptions {
|
|
file?: string;
|
|
error?: string;
|
|
output?: string;
|
|
command?: string[];
|
|
}
|
|
export function generateReproductionReport(
|
|
appRoot: string,
|
|
options: ReproductionReportOptions = {},
|
|
) {
|
|
const root = resolve(appRoot);
|
|
const selected = options.file ? resolve(root, options.file) : undefined;
|
|
if (
|
|
selected &&
|
|
(!relative(root, selected) ||
|
|
relative(root, selected).startsWith("..") ||
|
|
!existsSync(selected))
|
|
)
|
|
throw new Error("WRN-REPORT-FILE: selected source must exist inside the application.");
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const output = resolve(root, options.output ?? join(".wrnexus", "reports", stamp));
|
|
if (!relative(root, output) || relative(root, output).startsWith(".."))
|
|
throw new Error("WRN-REPORT-OUTPUT: output must stay inside the application.");
|
|
mkdirSync(output, { recursive: true });
|
|
let source: string | undefined;
|
|
let diagnostics: unknown[] = [];
|
|
if (selected) {
|
|
source = readFileSync(selected, "utf8");
|
|
diagnostics = selected.endsWith(".wrn")
|
|
? diagnose(source, { file: basename(selected), accessibility: true })
|
|
: [];
|
|
writeFileSync(
|
|
join(output, `sanitized-${basename(selected)}`),
|
|
`${sanitizeText(source)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
const configNames = ["wrnexus.config.ts", "wrnexus.config.js", "wrnexus.config.mjs"];
|
|
const configFile = configNames.map((name) => join(root, name)).find(existsSync);
|
|
const commands = options.command?.length
|
|
? options.command
|
|
: [
|
|
"bun install --frozen-lockfile",
|
|
selected ? "bunx wrnexus typecheck ." : "bunx wrnexus doctor .",
|
|
"bunx wrnexus build .",
|
|
];
|
|
const report = sanitizeValue({
|
|
schemaVersion: 1,
|
|
frameworkVersion: currentCliVersion(),
|
|
runtime: { bun: Bun.version, platform: process.platform, architecture: process.arch },
|
|
source: selected ? relative(root, selected).replace(/\\/g, "/") : undefined,
|
|
diagnostics,
|
|
dependencies: packageVersions(root),
|
|
config: configFile ? sanitizeText(readFileSync(configFile, "utf8")) : undefined,
|
|
error: options.error,
|
|
commands,
|
|
});
|
|
const reportFile = join(output, "report.json");
|
|
writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
writeFileSync(
|
|
join(output, "README.md"),
|
|
`# Sanitized WRNexus reproduction\n\nGenerated by WRNexus ${currentCliVersion()}. Review the bundle before sharing. Values matching secrets, credentials, emails, IPs and non-public domains are redacted.\n\n## Reproduce\n\n${commands.map((command) => `- \`${command}\``).join("\n")}\n`,
|
|
"utf8",
|
|
);
|
|
return {
|
|
directory: output,
|
|
reportFile,
|
|
sourceFile: selected ? join(output, `sanitized-${basename(selected)}`) : undefined,
|
|
};
|
|
}
|
|
export function runReport(appRoot: string, args: string[]) {
|
|
const option = (name: string) =>
|
|
args.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
const result = generateReproductionReport(appRoot, {
|
|
file: option("file"),
|
|
error: option("error"),
|
|
output: option("output"),
|
|
});
|
|
console.log(`✓ Sanitized reproduction bundle: ${result.directory}`);
|
|
return result;
|
|
}
|