diff --git a/README.md b/README.md index ab69074..becddb1 100644 --- a/README.md +++ b/README.md @@ -116,8 +116,14 @@ Personal data is removed twice before it is stored. plus `redactKeys`), serializes errors deny-by-default, and scrubs SSN shapes, emails and URL userinfo and queries from every string. The span exporter drops request and response bodies and headers, bound SQL values and Redis statements, redacts exception messages, strips URL - queries and userinfo, replaces a URL path with its route template, and redacts any attribute - named by `redactKeys`. + queries and userinfo, replaces a URL path with its route template, templates token paths, and + redacts any attribute named by `redactKeys`. The three Slack webhook shapes + (`/services/`, `/triggers/` and `/workflows/` plus `//`) are templated whatever + the host, so a non-Slack `/services/a/b/c` path on a client or unrouted span is templated too + (a span with an `http.route` keeps the route). A URL an app puts in an attribute outside the + URL keys is not templated: use `redactKeys`. Log text (`scrubMessage`, `scrubErrorText`) + strips userinfo, query and fragment but does not template token paths, so never log a + webhook or token URL. 2. **Layer 2, in the agent.** The Alloy agent in `codelittinc/carbon-observability` repeats the pattern scrub on every span, log and metric before it leaves the host. It repeats only this package's defaults (the contract below), never an app's `redactKeys`: for those, layer 1 is diff --git a/dist/url-path.d.ts b/dist/url-path.d.ts index 533e054..28f7e1f 100644 --- a/dist/url-path.d.ts +++ b/dist/url-path.d.ts @@ -2,7 +2,8 @@ * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start * of the path. An applicant intake token opens and submits an application for whoever holds it: * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` - * match, as a server routes them alike. + * match, as a server routes them alike. A Slack webhook (`/services/…`, `/triggers/…`, `/workflows/…`, each + * `//`) is host-agnostic like the rest: all three segments are replaced. */ export declare const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[]; /** Pinned by scrub-contract.test.ts: the agent's layer-2 span scrub repeats these. */ diff --git a/dist/url-path.js b/dist/url-path.js index 305f3d0..8749e08 100644 --- a/dist/url-path.js +++ b/dist/url-path.js @@ -3,11 +3,15 @@ import { describePatterns } from "./scrub-descriptor.js"; * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start * of the path. An applicant intake token opens and submits an application for whoever holds it: * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` - * match, as a server routes them alike. + * match, as a server routes them alike. A Slack webhook (`/services/…`, `/triggers/…`, `/workflows/…`, each + * `//`) is host-agnostic like the rest: all three segments are replaced. */ export const TOKEN_PATH_TEMPLATES = Object.freeze([ Object.freeze([/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"]), Object.freeze([/^\/+apply\/+[^/?#]+/i, "/apply/[token]"]), + Object.freeze([/^\/+services(?:\/+[^/?#]+){3}/i, "/services/[webhook]"]), + Object.freeze([/^\/+triggers(?:\/+[^/?#]+){3}/i, "/triggers/[webhook]"]), + Object.freeze([/^\/+workflows(?:\/+[^/?#]+){3}/i, "/workflows/[webhook]"]), ]); // A `scheme:` chain and `//`, or a bare `//`, then the host. const ORIGIN = /^((?:[a-z][a-z0-9+.-]*:)*\/\/[^/?#]*)(.*)$/is; diff --git a/package.json b/package.json index fd23b02..a7e7453 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codelittinc/otel-node", - "version": "0.1.1", + "version": "0.1.2", "description": "In-process OpenTelemetry for Node services: SDK boot, layer-1 PII scrub, OTLP logs.", "license": "UNLICENSED", "repository": "github:codelittinc/otel-node", diff --git a/src/__tests__/redacting-exporter-paths.test.ts b/src/__tests__/redacting-exporter-paths.test.ts index 4de259a..4666415 100644 --- a/src/__tests__/redacting-exporter-paths.test.ts +++ b/src/__tests__/redacting-exporter-paths.test.ts @@ -156,3 +156,136 @@ describe("RedactingSpanExporter — span names", () => { expect(exportSpan(SpanKind.INTERNAL, {}, name).name).toBe(name); }); }); + +// WHY: a Slack incoming-webhook URL is `/services///`; the last two segments +// are the secret, and an outbound fetch to it carries the whole URL on its client span. +describe("RedactingSpanExporter — Slack webhook paths", () => { + const SEGMENTS = ["T0AAAAAAA", "B0BBBBBBB", "xoxbSECRETsecret123"]; + const hook = (host = "https://hooks.slack.com") => `${host}/services/${SEGMENTS.join("/")}`; + const leaks = (span: ReturnType) => { + const text = JSON.stringify({ + name: span.name, + attributes: span.attributes, + events: span.events, + links: span.links, + status: span.status, + }); + return SEGMENTS.filter((segment) => text.includes(segment)); + }; + + it("templates the path in url.full and http.url and leaks no segment anywhere", () => { + const span = exportSpan(SpanKind.CLIENT, { "url.full": hook(), "http.url": hook() }); + expect(span.attributes["url.full"]).toBe("https://hooks.slack.com/services/[webhook]"); + expect(span.attributes["http.url"]).toBe("https://hooks.slack.com/services/[webhook]"); + expect(leaks(span)).toEqual([]); + }); + + it.each([ + ["a query string", `${hook()}?x=1`, "https://hooks.slack.com/services/[webhook]"], + [ + "doubled slashes", + "https://hooks.slack.com//services//T0AAAAAAA//B0BBBBBBB//xoxbSECRETsecret123", + "https://hooks.slack.com/services/[webhook]", + ], + [ + "upper-case Services", + "https://hooks.slack.com/Services/T0AAAAAAA/B0BBBBBBB/xoxbSECRETsecret123", + "https://hooks.slack.com/services/[webhook]", + ], + [ + "a trailing path", + `${hook()}/extra`, + "https://hooks.slack.com/services/[webhook]/extra", + ], + [ + "no scheme", + "//hooks.slack.com/services/T0AAAAAAA/B0BBBBBBB/xoxbSECRETsecret123", + "//hooks.slack.com/services/[webhook]", + ], + ["a bare path", "/services/T0AAAAAAA/B0BBBBBBB/xoxbSECRETsecret123", "/services/[webhook]"], + ])("templates %s", (_name, url, expected) => { + const span = exportSpan(SpanKind.CLIENT, { "url.full": url, "http.url": url }); + expect(span.attributes["url.full"]).toBe(expected); + expect(span.attributes["http.url"]).toBe(expected); + expect(leaks(span)).toEqual([]); + }); + + it.each(["/services", "/services/foo", "/servicesX/a/b/c", "/services/a/b"])( + "leaves %s unchanged", + (path) => { + const span = exportSpan(SpanKind.CLIENT, { "url.full": `https://x.test${path}` }); + expect(span.attributes["url.full"]).toBe(`https://x.test${path}`); + }, + ); +}); + +describe("RedactingSpanExporter — Slack webhook paths in events, links and path keys", () => { + const SECRETS = ["T0AAAAAAA", "B0BBBBBBB", "xoxbSECRETsecret123"]; + const URL_IN = `https://hooks.slack.com/services/${SECRETS.join("/")}`; + const URL_OUT = "https://hooks.slack.com/services/[webhook]"; + + function exportWith(attributes: Attributes) { + const memory = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new RedactingSpanExporter(memory))], + }); + const tracer = provider.getTracer("t"); + const linked = { + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + traceFlags: 1, + }; + const span = tracer.startSpan("GET", { + kind: SpanKind.CLIENT, + attributes, + links: [{ context: linked, attributes: { "url.full": URL_IN } }], + }); + span.addEvent("retry", { "url.full": URL_IN }); + span.end(); + return memory.getFinishedSpans()[0]!; + } + + it("templates the URL in an event attribute and a link attribute", () => { + const span = exportWith({ "url.full": URL_IN }); + expect(span.events[0]!.attributes!["url.full"]).toBe(URL_OUT); + expect(span.links[0]!.attributes!["url.full"]).toBe(URL_OUT); + const text = JSON.stringify({ a: span.attributes, e: span.events, l: span.links }); + expect(SECRETS.filter((secret) => text.includes(secret))).toEqual([]); + }); + + it("drops url.path without a route and templates http.target", () => { + const span = exportWith({ + "url.path": `/services/${SECRETS.join("/")}`, + "http.target": `/services/${SECRETS.join("/")}?x=1`, + }); + expect(span.attributes["url.path"]).toBeUndefined(); + expect(span.attributes["http.target"]).toBe("/services/[webhook]"); + }); + + it.each([ + ["triggers", "/triggers/[webhook]"], + ["workflows", "/workflows/[webhook]"], + ])("templates a Slack %s webhook path", (word, out) => { + for (const url of [ + `https://hooks.slack.com/${word}/T0AAAAAAA/B0BBBBBBB/xoxbSECRETsecret123?x=1`, + `https://hooks.slack.com//${word.toUpperCase()}//T0AAAAAAA//B0BBBBBBB//xoxbSECRETsecret123`, + ]) { + const span = exportWith({ "url.full": url, "http.url": url }); + expect(span.attributes["url.full"]).toBe(`https://hooks.slack.com${out}`); + expect(span.attributes["http.url"]).toBe(`https://hooks.slack.com${out}`); + expect(JSON.stringify(span.attributes)).not.toContain("xoxbSECRET"); + } + }); + + it.each([ + "/triggers", + "/triggers/a/b", + "/triggersX/a/b/c", + "/workflows", + "/workflows/a/b", + "/workflowsX/a/b/c", + ])("leaves %s unchanged", (path) => { + const span = exportWith({ "url.full": `https://x.test${path}` }); + expect(span.attributes["url.full"]).toBe(`https://x.test${path}`); + }); +}); diff --git a/src/__tests__/url-credentials-entry.test.ts b/src/__tests__/url-credentials-entry.test.ts index 032a25c..93625c0 100644 --- a/src/__tests__/url-credentials-entry.test.ts +++ b/src/__tests__/url-credentials-entry.test.ts @@ -92,6 +92,12 @@ describe("the scrub entry", () => { expect(redactStripQuery).toBe(stripQuery); }); + it("templates a Slack webhook URL through the scrub entry", () => { + expect( + scrubEntry.templateUrlPath("https://hooks.slack.com/services/T0A/B0B/SECRET", undefined), + ).toBe("https://hooks.slack.com/services/[webhook]"); + }); + it("exports a token path list no consumer can change", () => { const list = scrubEntry.TOKEN_PATH_TEMPLATES as unknown as unknown[][]; const before = JSON.stringify(list.map(([re, to]) => [String(re), to])); diff --git a/src/scrub-contract.json b/src/scrub-contract.json index b424515..11340fe 100644 --- a/src/scrub-contract.json +++ b/src/scrub-contract.json @@ -97,6 +97,21 @@ "source": "^\\/+apply\\/+[^/?#]+", "flags": "i", "replacement": "/apply/[token]" + }, + { + "source": "^\\/+services(?:\\/+[^/?#]+){3}", + "flags": "i", + "replacement": "/services/[webhook]" + }, + { + "source": "^\\/+triggers(?:\\/+[^/?#]+){3}", + "flags": "i", + "replacement": "/triggers/[webhook]" + }, + { + "source": "^\\/+workflows(?:\\/+[^/?#]+){3}", + "flags": "i", + "replacement": "/workflows/[webhook]" } ], "URL_KEYS": ["http.target", "http.url", "url.full", "url.original", "url.path"], diff --git a/src/url-path.ts b/src/url-path.ts index 9703061..f216883 100644 --- a/src/url-path.ts +++ b/src/url-path.ts @@ -4,11 +4,15 @@ import { describePatterns } from "./scrub-descriptor.js"; * Paths whose segments are bearer secrets, with the template each becomes. Anchored at the start * of the path. An applicant intake token opens and submits an application for whoever holds it: * the web page (`/apply/[token]`) and the api routes it calls. Any letter case and any run of `/` - * match, as a server routes them alike. + * match, as a server routes them alike. A Slack webhook (`/services/…`, `/triggers/…`, `/workflows/…`, each + * `//`) is host-agnostic like the rest: all three segments are replaced. */ export const TOKEN_PATH_TEMPLATES: readonly (readonly [RegExp, string])[] = Object.freeze([ Object.freeze([/^\/+v1\/+applications\/+by-token\/+[^/?#]+/i, "/v1/applications/by-token/:token"] as const), Object.freeze([/^\/+apply\/+[^/?#]+/i, "/apply/[token]"] as const), + Object.freeze([/^\/+services(?:\/+[^/?#]+){3}/i, "/services/[webhook]"] as const), + Object.freeze([/^\/+triggers(?:\/+[^/?#]+){3}/i, "/triggers/[webhook]"] as const), + Object.freeze([/^\/+workflows(?:\/+[^/?#]+){3}/i, "/workflows/[webhook]"] as const), ]); // A `scheme:` chain and `//`, or a bare `//`, then the host.