Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions src/lab/artifacts/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,9 @@ const DOTTED_NAMESPACE_RE = /^[a-z]+(?:\.[a-z]+)*\.[a-z]+[0-9]*$/i;
* A stopword list would repeat the delimiter-enumeration mistake, so the
* candidate is validated instead. A token qualifies when it carries host
* punctuation (dot, hyphen, underscore, digit) or is a reserved name; a bare
* English word does not — EXCEPT directly after a resolver marker, where the
* argument is a name by construction and `ENOTFOUND redis` must still redact.
* English word does not — EXCEPT after a resolver or socket-state marker,
* where the argument is a name by construction and `ENOTFOUND redis` and
* `ECONNREFUSED redis` must still redact.
*/
const RESERVED_HOST_NAMES = new Set(["localhost", "broadcasthost"]);
const PROSE_AFTER_MARKER = new Set([
Expand All @@ -198,8 +199,8 @@ function isHostCandidate(value: string, bareWordAllowed = false): boolean {
if (/[.\-_0-9]/.test(value)) {
return AMBIGUOUS_HOST_RE.test(value) || CONTEXTUAL_HOST_TOKEN_RE.test(value);
}
// A bare word: only a resolver marker makes it a host, and only when it is
// not one of the connective words those messages actually use.
// A bare word: only a destination-bearing marker makes it a host, and only
// when it is not one of the connective words those messages actually use.
return bareWordAllowed && !PROSE_AFTER_MARKER.has(lower);
}

Expand Down Expand Up @@ -499,8 +500,8 @@ function scrubString(value: string): string {
s = s.replace(STRONG_HOST_CONTEXT_RE, (m, tail: string) => {
// Scan the few tokens after the marker for the first host-shaped one:
// Go writes `dial tcp: lookup <host>: no such host`, so the destination
// is not always adjacent to the marker. A resolver marker also licenses a
// bare name (`ENOTFOUND redis`), which a socket-state marker does not.
// is not always adjacent to the marker. Resolver and socket-state markers
// also license bare destination names such as `ENOTFOUND redis`.
// A name paired with a port is a destination whatever else is true:
// `dial tcp redis:6379` needs no other evidence. Both notations count —
// adjacent `host:443` and spelled-out `gateway on port 443` — because the
Expand All @@ -511,11 +512,17 @@ function scrubString(value: string): string {
if (ported?.[1] && !PROSE_AFTER_MARKER.has(ported[1].toLowerCase())) {
return m.replace(ported[1], "[host]");
}
// Otherwise only a resolver marker licenses a bare name. Natural-language
// `connect to` does not: `Unable to connect to your account` is prose.
const resolver = /ENOTFOUND|EAI_AGAIN|lookup|host/i.test(m);
// Resolver and socket-state markers license a bare destination name.
// Natural-language `connect to` does not: `Unable to connect to your
// account` is prose.
const destinationContext =
/ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|dial\s+(?:tcp|udp)|lookup|\bhost\b/i.test(m);
let bareNamePossible = destinationContext;
for (const token of tail.split(/[\s:]+/)) {
if (token && isHostCandidate(token, resolver)) return m.replace(token, "[host]");
if (!token) continue;
if (isHostCandidate(token)) return m.replace(token, "[host]");
if (bareNamePossible && isHostCandidate(token, true)) return m.replace(token, "[host]");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid treating arbitrary errno prose as a hostname

When a socket error is followed by ordinary prose whose first word is absent from PROSE_AFTER_MARKER, isHostCandidate(token, true) accepts that word as a bare hostname. Consequently, common diagnostics such as ETIMEDOUT waiting for response become ETIMEDOUT [host] for response; worse, ETIMEDOUT connecting to gateway redacts connecting while leaving the actual host gateway exposed. Use positive destination syntax rather than a finite prose stoplist, and add regression cases for these phrasing variants.

Useful? React with 👍 / 👎.

if (!PROSE_AFTER_MARKER.has(token.toLowerCase())) bareNamePossible = false;
}
return m;
});
Expand Down
7 changes: 7 additions & 0 deletions tests/lab-evidence-sanitization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,13 @@ describe("SEC-02 sanitizer boundary", () => {
.toBe("connect to [host] port 8080 failed");
});

test("socket errors redact bare single-label destinations", () => {
expect(sanitizeDiagnostic("ECONNREFUSED redis")).toBe("ECONNREFUSED [host]");
expect(sanitizeDiagnostic("ETIMEDOUT gateway")).toBe("ETIMEDOUT [host]");
expect(sanitizeDiagnostic("EHOSTUNREACH backend")).toBe("EHOSTUNREACH [host]");
expect(sanitizeDiagnostic("dial tcp redis")).toBe("dial tcp [host]");
});

test("natural-language connect-to prose is left alone", () => {
// `connect to` reads as English far more often than as a destination, so
// it no longer licenses a bare word. The cost is that `connect to gateway`
Expand Down
Loading