From c825f1ed540518ae7b891adce4c6d6f1042bba64 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Tue, 11 Aug 2026 09:51:30 +0900 Subject: [PATCH] fix(lab): redact bare hosts after socket errors --- src/lab/artifacts/sanitize.ts | 27 ++++++++++++++++--------- tests/lab-evidence-sanitization.test.ts | 7 +++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/lab/artifacts/sanitize.ts b/src/lab/artifacts/sanitize.ts index afb638628..ac2916f86 100644 --- a/src/lab/artifacts/sanitize.ts +++ b/src/lab/artifacts/sanitize.ts @@ -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([ @@ -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); } @@ -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 : 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 @@ -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]"); + if (!PROSE_AFTER_MARKER.has(token.toLowerCase())) bareNamePossible = false; } return m; }); diff --git a/tests/lab-evidence-sanitization.test.ts b/tests/lab-evidence-sanitization.test.ts index d91e9242e..eeb89473b 100644 --- a/tests/lab-evidence-sanitization.test.ts +++ b/tests/lab-evidence-sanitization.test.ts @@ -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`