mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 07:10:12 +02:00
fix(redact): reject malformed --max-bytes instead of silently disabling the size guard (#1824)
The oversize check is designed to fail CLOSED, but a malformed --max-bytes turned
it fail-OPEN. bin/gstack-redact did parseInt(maxBytes,10) and passed it straight
through; parseInt("foo") is NaN. The engine guarded with `opts.maxBytes ?? DEFAULT`,
and ?? does not catch NaN, so `byteLen > NaN` was always false and the fail-closed
block never fired. A negative value made `byteLen > -5` always true, blocking
everything.
Two layers:
- bin/gstack-redact validates the RAW string (parseInt accepts "123abc"->123,
"1.5"->1): require /^\d+$/ and > 0, else exit 1 with a clear message.
- lib/redact-engine.ts hardens the fallback to Number.isFinite && > 0 else the
default cap — a guardrail so the engine never silently runs uncapped even if a
bad value reaches it directly.
Tests: NaN and negative both fall back to the default cap (oversize still blocks);
CLI rejects garbage/negative with exit 1.
Reported by @jbetala7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+14
-1
@@ -161,12 +161,25 @@ function readLines(path: string | undefined): string[] | undefined {
|
||||
function buildOpts(): ScanOptions {
|
||||
const vis = (arg("--repo-visibility") as RepoVisibility) || "unknown";
|
||||
const maxBytes = arg("--max-bytes");
|
||||
// #1824: validate the RAW string, not the parse result. parseInt("123abc")
|
||||
// is 123 and parseInt("foo") is NaN — both silently corrupt the fail-closed
|
||||
// oversize guard. Require a clean positive integer or reject before scanning.
|
||||
let maxBytesOpt: number | undefined;
|
||||
if (maxBytes !== undefined) {
|
||||
if (!/^\d+$/.test(maxBytes) || Number(maxBytes) <= 0) {
|
||||
process.stderr.write(
|
||||
`gstack-redact: --max-bytes must be a positive integer (got "${maxBytes}")\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
maxBytesOpt = Number(maxBytes);
|
||||
}
|
||||
return {
|
||||
repoVisibility: ["public", "private", "unknown"].includes(vis) ? vis : "unknown",
|
||||
allowlist: readLines(arg("--allowlist")),
|
||||
selfEmail: arg("--self-email"),
|
||||
repoPublicEmails: readLines(arg("--repo-public-emails")),
|
||||
...(maxBytes ? { maxBytes: parseInt(maxBytes, 10) } : {}),
|
||||
...(maxBytesOpt !== undefined ? { maxBytes: maxBytesOpt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -253,7 +253,16 @@ function emailAllowed(email: string, opts: ScanOptions): boolean {
|
||||
|
||||
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
|
||||
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
|
||||
// (NaN from a malformed --max-bytes, or a negative) would make `byteLen >
|
||||
// maxBytes` always false and silently disable the fail-closed oversize guard.
|
||||
// Guardrail: any non-finite or non-positive value falls back to the default
|
||||
// cap. The CLI is the layer that rejects bad args; this is belt-and-suspenders
|
||||
// so the engine never silently runs uncapped.
|
||||
const maxBytes =
|
||||
Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0
|
||||
? (opts.maxBytes as number)
|
||||
: DEFAULT_MAX_BYTES;
|
||||
|
||||
// Fail CLOSED on oversize input. Check byte length BEFORE heavy work.
|
||||
const byteLen = Buffer.byteLength(input, "utf8");
|
||||
|
||||
@@ -269,6 +269,27 @@ describe("oversize fails CLOSED", () => {
|
||||
expect(r.findings[0].id).toBe("engine.input_too_large");
|
||||
expect(exitCodeFor(r)).toBe(3);
|
||||
});
|
||||
|
||||
// #1824: a malformed --max-bytes used to reach the engine as NaN. `byteLen >
|
||||
// NaN` is always false, silently disabling the fail-closed guard. The engine
|
||||
// guardrail must fall back to the default cap for any non-finite / <= 0 value.
|
||||
test("NaN maxBytes falls back to the default cap (does NOT disable the guard)", () => {
|
||||
const big = "a".repeat(2 * 1024 * 1024); // > 1 MiB default cap
|
||||
const r = scan(big, { maxBytes: NaN });
|
||||
expect(r.oversize).toBe(true);
|
||||
expect(r.findings[0].id).toBe("engine.input_too_large");
|
||||
expect(exitCodeFor(r)).toBe(3);
|
||||
});
|
||||
|
||||
test("negative / zero maxBytes falls back to the default cap", () => {
|
||||
// negative would make `byteLen > -5` always true (block everything);
|
||||
// the guardrail normalizes it to the default instead.
|
||||
const small = "ok";
|
||||
expect(scan(small, { maxBytes: -5 }).oversize).toBeFalsy();
|
||||
expect(scan(small, { maxBytes: 0 }).oversize).toBeFalsy();
|
||||
const big = "a".repeat(2 * 1024 * 1024);
|
||||
expect(scan(big, { maxBytes: -5 }).oversize).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validators", () => {
|
||||
|
||||
Reference in New Issue
Block a user