Skip to content
Closed
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
75 changes: 56 additions & 19 deletions src/lab/artifacts/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ const HOSTNAME_RE = /(?<![\w.-])(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){1,8}(
// `ETIMEDOUT after 30 seconds` into `ETIMEDOUT [host] 30 seconds`: a redaction
// that destroys the diagnostic and hides nothing.
// Markers differ in confidence, and treating them alike cost accuracy both
// ways. STRONG markers are resolver/socket errors and `host=`: whatever
// follows is a host by construction, so a bare `redis` or `localhost` counts.
// ways. STRONG markers are resolver/socket errors and `host=`: the argument
// position is a host by construction, so a bare `redis` or `localhost` counts
// there while the prose that can follow a marker survives.
// WEAK markers appear in ordinary prose (`upstream provider.metric.p95
// exceeded`), so they only redact a candidate that is already host-shaped.
const STRONG_HOST_CONTEXT_RE =
Expand All @@ -166,11 +167,12 @@ const WEAK_HOST_CONTEXT_RE =
/\b(?:upstream)["']?\s*[\s=:]\s*["']?([A-Za-z0-9_.-]{1,255})/gi;

/**
* A dotted run of plain alphabetic words with no digits or hyphens in any
* label — `provider.metric.p95` reads as a namespace, not a host. Under a
* WEAK marker this is left alone; under a STRONG one the marker decides.
* A dotted run of plain alphabetic words, or the conventional
* `<name>.metric.p<digits>` shape, reads as a namespace rather than a host.
* Other digit-suffixed labels are host-shaped and must not receive this weak
* marker exemption. Under a STRONG marker the marker always decides.
*/
const DOTTED_NAMESPACE_RE = /^[a-z]+(?:\.[a-z]+)*\.[a-z]+[0-9]*$/i;
const DOTTED_NAMESPACE_RE = /^(?:[a-z]+(?:\.[a-z]+)+|[a-z]+(?:\.[a-z]+)*\.metric\.p[0-9]+)$/i;

/**
* Does this token look like a host rather than an English word?
Expand All @@ -184,8 +186,10 @@ 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 in a grammar position proven to hold the
* destination of 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 +202,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 +503,13 @@ 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. The destination sits inside
// `tail`, so the rewrite is spliced at an offset in it — replacing
// against the whole match lets a marker word that repeats the
// destination (`connect to connect failed`) take the `[host]` instead.
const head = m.slice(0, m.length - tail.length);
const redact = (at: number, token: string) =>
head + tail.slice(0, at) + "[host]" + tail.slice(at + token.length);
// 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 @@ -509,13 +518,41 @@ function scrubString(value: string): string {
tail.match(/(?<![\w.-])([A-Za-z0-9_.-]{1,255}):\d{1,5}(?![\w.])/) ??
tail.match(/(?<![\w.-])([A-Za-z0-9_.-]{1,255})\s+(?:on\s+)?port\s+\d{1,5}\b/i);
if (ported?.[1] && !PROSE_AFTER_MARKER.has(ported[1].toLowerCase())) {
return m.replace(ported[1], "[host]");
return redact(ported.index ?? 0, ported[1]);
}
// 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);
for (const token of tail.split(/[\s:]+/)) {
if (token && isHostCandidate(token, resolver)) return m.replace(token, "[host]");
// A failure immediately following an unported connect-to target supplies
// the missing network context without making ordinary connective prose
// (`connect to your account`) host-bearing.
const failedConnectTarget = /\bconnect(?:ing)?\s+to\b/i.test(m)
? tail.match(/^([A-Za-z][A-Za-z0-9]{0,254})\s+(?:failed|refused|unreachable|reset|timed\s+out)\b/i)
: null;
if (failedConnectTarget?.[1]) {
return redact(failedConnectTarget.index ?? 0, failedConnectTarget[1]);
}
// A bare destination name is licensed only where the grammar proves the
// position is the destination: as the marker's sole argument
// (`ECONNREFUSED redis`, `dial tcp redis`) or as the argument of `lookup`
// (`dial tcp: lookup redis`). Past an open-ended connective chain nothing
// proves the next word is a destination — `ETIMEDOUT while waiting for
// response` is prose. Natural-language `connect to` licenses nothing:
// `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);
const tokens = tail.split(/[\s:]+/).filter(Boolean);
let cursor = 0;
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i]!;
const at = tail.indexOf(token, cursor);
cursor = at + token.length;
// A sentence-final period travels with the token (`ECONNREFUSED redis.`):
// classify the name without it, and leave it outside the mask so the
// message still reads as a sentence.
const core = token.replace(/\.+$/, "");
if (!core) continue;
if (isHostCandidate(core)) return redact(at, core);
const bareLicensed =
destinationContext && (tokens.length === 1 || tokens[i - 1]?.toLowerCase() === "lookup");
if (bareLicensed && isHostCandidate(core, true)) return redact(at, core);
}
return m;
});
Expand Down Expand Up @@ -583,4 +620,4 @@ export function sanitizeDiagnostic(value: unknown): string {

export function sanitizedJsonBytes(value: unknown): Uint8Array {
return new TextEncoder().encode(jcsStringify(scrubValue(value, 0)));
}
}
25 changes: 16 additions & 9 deletions structure/adapters/compatibility-lab.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,31 +60,38 @@ before anything else: `dial tcp redis:6379` needs no further signal.

Otherwise, **strong** markers (`ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED`,
`ETIMEDOUT`, `EHOSTUNREACH`, `dial tcp`, `host=`/`host:`) introduce a
destination, and a resolver marker licenses even a bare name
(`getaddrinfo ENOTFOUND redis`). The destination is not assumed adjacent — Go
writes `dial tcp: lookup <host>: no such host` — so the following few tokens are
scanned and the first host-shaped one is replaced. A plain English word is not
host-shaped, so `ETIMEDOUT request after 30 seconds` is untouched.
destination. The destination is not assumed adjacent — Go writes
`dial tcp: lookup <host>: no such host` — so the following few tokens are
scanned and the first host-shaped one is replaced. A bare name counts only in
a position the grammar proves is the destination — the marker's sole argument
(`ECONNREFUSED redis`, `dial tcp redis`) or the argument of `lookup`
(`dial tcp: lookup redis`). Connective prose after a marker survives:
`ETIMEDOUT request after 30 seconds` and `ETIMEDOUT while waiting for
response` are both untouched.

**Weak** markers (`upstream`, `connect to`) read as English at least as often as
they name a host, so they redact only a candidate that is already host-shaped
and is not a plain dotted namespace. `upstream provider.metric.p95 exceeded` and
`Unable to connect to your account` both survive.
and is not a plain dotted namespace or the conventional `*.metric.p<digits>`
form. `upstream provider.metric.p95 exceeded` and
`Unable to connect to your account` both survive; `upstream db.prod1` does not.
For `connect to`, an immediately following network failure term also makes a
bare target unambiguous, so `connect to gateway failed` is redacted while
`Unable to connect to your account` survives.

### Known limits

Recorded rather than implied, so a reader knows what is not covered:

| Form | Behavior |
|------|----------|
| Bare service name after natural-language `connect to` with no port at all (`connect to gateway failed`) | not redacted — the phrase is prose too often to trust. A port in either notation (`gateway:443`, `gateway on port 443`) does make it a host |
| Bare `db.prod-1` outside any network context | not redacted — indistinguishable from a metric namespace |
| Bare word amid prose after a socket marker (`ETIMEDOUT operation timed out`) | not redacted — only the marker's sole argument or the word after `lookup` is a proven destination position |
| Standalone UUID, standalone `user_…`, bare-label value (`org: engineering`) | not redacted — indistinguishable from request, trace, and correlation ids |
| Phone numbers, generic high-entropy blobs | not redacted — no non-destructive pattern |
| Cisco dotted MAC (`0123.4567.89ab`), ideographic-dot IDN | not redacted — unusual notations |
| Escaped-quote mail local part | partially redacted; the address is broken but a fragment of the local part can remain |
| Percent-encoding nested more than six deep | not decoded further |
| Fully alphabetic dotted namespace (`provider.timeout`, `provider.request.duration`) | **over-redacted to `[host]`** — indistinguishable from a real hostname. A namespace whose last label carries a digit (`provider.metric.p95`) survives |
| Fully alphabetic dotted namespace (`provider.timeout`, `provider.request.duration`) | **over-redacted to `[host]`** — indistinguishable from a real hostname. A digit-suffixed namespace survives bare (`release.v2`); after a weak marker only the conventional `*.metric.p<digits>` form does (`provider.metric.p95`) — `upstream db.prod1` and `upstream api.v2` redact |

The marker behaviors and the redacted categories are asserted in both
directions — positive cases for what must be removed, negative cases for the
Expand Down
58 changes: 51 additions & 7 deletions tests/lab/lab-evidence-sanitization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ describe("SEC-02 sanitizer boundary", () => {
.toBe("dial tcp [host]:443: connect: refused");
expect(sanitizeDiagnostic("upstream api.us-east-1 unavailable"))
.toBe("upstream [host] unavailable");
for (const host of ["internal.service1", "db.prod1", "api.v2"]) {
expect(sanitizeDiagnostic(`upstream ${host} unavailable`))
.toBe("upstream [host] unavailable");
}
// Without such a marker they survive — a recorded limit, asserted so it
// cannot drift silently in either direction.
expect(sanitizeDiagnostic("db.prod-1")).toBe("db.prod-1");
Expand All @@ -232,14 +236,15 @@ describe("SEC-02 sanitizer boundary", () => {
});

test("marker confidence decides how much context is trusted", () => {
// STRONG markers — resolver and socket errors, `host=`, `connect to` —
// name a destination by construction, so even a bare word is a host.
// STRONG resolver and socket markers name a destination by construction,
// so even a bare word is a host.
expect(sanitizeDiagnostic("dial tcp localhost:11434: connect: refused"))
.toBe("dial tcp [host]:11434: connect: refused");
expect(sanitizeDiagnostic("getaddrinfo ENOTFOUND redis")).toBe("getaddrinfo ENOTFOUND [host]");
// `connect to` was demoted to a weak marker: it reads as English too often
// (`Unable to connect to your account`). Without a port it no longer
// licenses a bare name — a documented limit, asserted below.
// Even a destination spelled like the marker resolves to the tail token.
expect(sanitizeDiagnostic("host host")).toBe("host [host]");
// `connect to` reads as English too often to license a bare word on its own
// (`Unable to connect to your account`); a failure term disambiguates it.
// WEAK markers appear in prose, so a dotted namespace after one survives.
expect(sanitizeDiagnostic("upstream provider.metric.p95 exceeded"))
.toBe("upstream provider.metric.p95 exceeded");
Expand Down Expand Up @@ -290,18 +295,57 @@ describe("SEC-02 sanitizer boundary", () => {
.toBe("Unable to connect to [host] on port 443: timed out");
expect(sanitizeDiagnostic("connect to gateway port 8080 failed"))
.toBe("connect to [host] port 8080 failed");
// Marker repetition does not move the redaction off the destination.
expect(sanitizeDiagnostic("connect to connect:443 refused"))
.toBe("connect to [host]:443 refused");
});

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]");
// `lookup` is the one mid-message position proven to carry the name: Go
// writes `dial tcp: lookup <name>: <reason>`.
expect(sanitizeDiagnostic("dial tcp: lookup redis")).toBe("dial tcp: lookup [host]");
expect(sanitizeDiagnostic("dial tcp: lookup redis: no such host"))
.toBe("dial tcp: lookup [host]: no such host");
// A sentence-final period rides with the token: it must not defeat host
// classification, and it stays outside the mask.
expect(sanitizeDiagnostic("ECONNREFUSED redis.")).toBe("ECONNREFUSED [host].");
expect(sanitizeDiagnostic("dial tcp db.prod1.")).toBe("dial tcp [host].");
});

test("timeout and socket prose after a marker survives", () => {
// A bare name is licensed only where the grammar proves it is the
// destination. Connective prose after a socket marker is not a host:
// redacting it destroys the diagnostic and hides nothing.
expect(sanitizeDiagnostic("ETIMEDOUT while waiting for response"))
.toBe("ETIMEDOUT while waiting for response");
expect(sanitizeDiagnostic("ETIMEDOUT operation timed out"))
.toBe("ETIMEDOUT operation timed out");
expect(sanitizeDiagnostic("ECONNREFUSED connection refused"))
.toBe("ECONNREFUSED connection refused");
});

test("natural-language connect-to prose is left alone", () => {
test("connect-to prose survives while real failures still redact", () => {
// `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`
// is not redacted; that limit is documented and asserted here so it cannot
// change silently.
// A following failure term still supplies network context for a bare
// destination, so connective prose survives while real failures redact.
expect(sanitizeDiagnostic("Unable to connect to your account. Please retry."))
.toBe("Unable to connect to your account. Please retry.");
expect(sanitizeDiagnostic("failed to connect to the upstream service"))
.toBe("failed to connect to the upstream service");
expect(sanitizeDiagnostic("connect to gateway failed")).toBe("connect to gateway failed");
expect(sanitizeDiagnostic("connect to gateway failed")).toBe("connect to [host] failed");
expect(sanitizeDiagnostic("connecting to redis timed out")).toBe("connecting to [host] timed out");
// A destination that repeats the marker word is still the thing redacted:
// replacing across the whole match took the marker's `connect` instead.
expect(sanitizeDiagnostic("connect to connect failed")).toBe("connect to [host] failed");
expect(sanitizeDiagnostic("connecting to connecting timed out"))
.toBe("connecting to [host] timed out");
});

test("an internationalized domain does not preserve the address", () => {
Expand Down
Loading