Skip to content

Commit f204c8d

Browse files
committed
fix(cli): redact masked values by merged match ranges so overlapping secrets leave no fragment
Replacing one masked value after another left a remainder whenever two values overlapped in the text: a prefix of a longer token printed ***DEF, and "ABC" with "BCD" across "ABCD" printed ***D. The redactor now locates every occurrence of every masked value in the original text, merges overlapping or touching ranges, and replaces each merged range with one ***, for both streams and the summary file.
1 parent 2d9abc7 commit f204c8d

2 files changed

Lines changed: 63 additions & 7 deletions

File tree

‎src/cli/io.ts‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,53 @@ class RedactingStream extends Writable {
5151
}
5252
}
5353

54+
/**
55+
* `text` with every occurrence of every masked value replaced by `***`.
56+
* Occurrences are located in the original text and overlapping or touching
57+
* ones are merged, so two values that overlap (a prefix of another, or
58+
* "ABC" and "BCD" across "ABCD") leave no fragment, as replacing one value
59+
* after another would.
60+
*/
61+
function redactRanges(text: string, masked: ReadonlySet<string>): string {
62+
const ranges: Array<[number, number]> = [];
63+
for (const value of masked) {
64+
if (value === "") {
65+
continue;
66+
}
67+
for (let at = text.indexOf(value); at !== -1; at = text.indexOf(value, at + 1)) {
68+
ranges.push([at, at + value.length]);
69+
}
70+
}
71+
ranges.sort((a, b) => a[0] - b[0]);
72+
let out = "";
73+
let cursor = 0;
74+
let open: [number, number] | undefined;
75+
for (const [start, end] of ranges) {
76+
if (open !== undefined && start <= open[1]) {
77+
open[1] = Math.max(open[1], end);
78+
continue;
79+
}
80+
if (open !== undefined) {
81+
out += `${text.slice(cursor, open[0])}***`;
82+
cursor = open[1];
83+
}
84+
open = [start, end];
85+
}
86+
if (open !== undefined) {
87+
out += `${text.slice(cursor, open[0])}***`;
88+
cursor = open[1];
89+
}
90+
return out + text.slice(cursor);
91+
}
92+
5493
/**
5594
* Every write to the returned streams is redacted; register a value before
5695
* anything can print it. One registry serves the parser, the Io, and the
5796
* file-only commands alike, so no writer can bypass it.
5897
*/
5998
export function maskedStreams(streams: CliStreams): MaskedStreams {
6099
const registry = maskRegistry(() => {});
61-
const redact = (text: string): string => {
62-
let out = text;
63-
for (const value of registry.masked()) {
64-
out = out.replaceAll(value, "***");
65-
}
66-
return out;
67-
};
100+
const redact = (text: string): string => redactRanges(text, registry.masked());
68101
return {
69102
stdout: new RedactingStream(streams.stdout, redact),
70103
stderr: new RedactingStream(streams.stderr, redact),

‎test/cli/io.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,29 @@ describe("the CLI Io", () => {
9898
expect(readFileSync(summary, "utf8")).toBe("# run by ***\n");
9999
});
100100

101+
test.each<[string, string[], string, string]>([
102+
[
103+
"a prefix of another value",
104+
["github_pat_ABC", "github_pat_ABCDEF"],
105+
"argument 'github_pat_ABCDEF' repeats github_pat_ABC",
106+
"argument '***' repeats ***",
107+
],
108+
["two values overlapping across the text", ["ABC", "BCD"], "x ABCD y", "x *** y"],
109+
["an infix of another value", ["BC", "xABCDEy"], "see xABCDEy and BC", "see *** and ***"],
110+
["two values touching end to start", ["ABC", "DEF"], "ABCDEF", "***"],
111+
["separate occurrences of one value", ["ABC"], "ABC and ABC", "*** and ***"],
112+
["a value absent from the line", ["ABC"], "nothing here", "nothing here"],
113+
])("masking leaves no fragment of %s", (_case, masks, line, redacted) => {
114+
// Replacing one value after another would leave "***D" for the overlap and
115+
// "***DEF" for the prefix; the ranges are merged in the original text instead.
116+
const { io, stderr } = open();
117+
for (const value of masks) {
118+
io.mask(value);
119+
}
120+
io.annotate("error", line);
121+
expect(stderr()).toBe(`error: ${redacted}\n`);
122+
});
123+
101124
test("summary blocks append to the named file and are dropped without one", () => {
102125
const dir = mkdtempSync(join(tmpdir(), "gsac-io-"));
103126
scratch.push(dir);

0 commit comments

Comments
 (0)