330 lines · 12.9 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest"; import {  redactOtlpTraceExport,  redactSpanUrlAttributes,  redactUrlForTelemetry,  redactUrlsInText,} from "./telemetry-url-redaction"; // Synthetic placeholders only — never a real authorization code or state.const CODE = "synthetic-authorization-code";const STATE = "synthetic-csrf-state"; const callbackUrl = `https://app.test/api/oauth/callback?code=${CODE}&state=${STATE}&domain=example.test`; describe("redactSpanUrlAttributes", () => {  it("strips every query value from url.full and url.query", () => {    const attributes: Record<string, unknown> = {      "url.full": callbackUrl,      "url.query": `code=${CODE}&state=${STATE}&domain=example.test`,      "url.path": "/api/oauth/callback",      "http.request.method": "GET",    };     const stripped = redactSpanUrlAttributes(attributes);     expect(stripped).toEqual(["code", "domain", "state"]);    expect(JSON.stringify(attributes)).not.toContain(CODE);    expect(JSON.stringify(attributes)).not.toContain(STATE);    // Route-level visibility is preserved; no query value is.    expect(attributes["url.path"]).toBe("/api/oauth/callback");    expect(attributes["url.full"]).toBe("https://app.test/api/oauth/callback");    expect(attributes["url.query"]).toBe("");  });   it("drops a secret riding under any parameter name — names are never trusted", () => {    // Query-auth placement names are arbitrary strings: a key configured as    // `?owner=…` or `?error=…` is exactly as much a credential as `?key=…`.    // That is why no allowlist of "safe" names can exist.    const attributes: Record<string, unknown> = {      "url.full":        "https://api.test/graphql?owner=synthetic-owner-secret&error=synthetic-error-secret",      "url.query": "owner=synthetic-owner-secret&error=synthetic-error-secret",    };     const stripped = redactSpanUrlAttributes(attributes);     expect(stripped).toEqual(["error", "owner"]);    expect(JSON.stringify(attributes)).not.toContain("synthetic-owner-secret");    expect(JSON.stringify(attributes)).not.toContain("synthetic-error-secret");    expect(attributes["url.full"]).toBe("https://api.test/graphql");  });   it("drops a nested returnTo value wholesale — code, state, and userinfo included", () => {    // The login redirect round-trips the whole OAuth callback URL through    // `returnTo`, so credentials ride inside another parameter's value. The    // value is dropped with every other query value; nothing nested survives.    const returnTo = encodeURIComponent(      `https://canary:synthetic-nested-userinfo-secret@app.test/api/oauth/callback?code=${CODE}&state=${STATE}`,    );    const attributes: Record<string, unknown> = {      "url.full": `https://app.test/login?returnTo=${returnTo}`,      "url.query": `returnTo=${returnTo}`,    };     const stripped = redactSpanUrlAttributes(attributes);     expect(stripped).toEqual(["returnTo"]);    expect(JSON.stringify(attributes)).not.toContain(CODE);    expect(JSON.stringify(attributes)).not.toContain(STATE);    expect(JSON.stringify(attributes)).not.toContain("synthetic-nested-userinfo-secret");    expect(attributes["url.full"]).toBe("https://app.test/login");  });   it("reports a nameless query segment as *, never by its text", () => {    // `?<token>` parses as a parameter NAME — echoing it would leak the token    // through the stripped-keys list.    const attributes: Record<string, unknown> = {      "url.full": "https://api.test/hook?synthetic-bare-token-secret",    };     const stripped = redactSpanUrlAttributes(attributes);     expect(stripped).toEqual(["*"]);    expect(JSON.stringify(attributes)).not.toContain("synthetic-bare-token-secret");    expect(attributes["url.full"]).toBe("https://api.test/hook");  });   it("clears a fragment-borne token from a parseable URL", () => {    const attributes: Record<string, unknown> = {      "url.full": "https://app.test/callback#access_token=synthetic-fragment-token",    };     const stripped = redactSpanUrlAttributes(attributes);     expect(stripped).toEqual(["fragment"]);    expect(JSON.stringify(attributes)).not.toContain("synthetic-fragment-token");    expect(attributes["url.full"]).toBe("https://app.test/callback");  });   it("clears a fragment-borne token from a malformed URL", () => {    const attributes: Record<string, unknown> = {      "url.full": "http://exa mple.test/callback#access_token=synthetic-fragment-token",    };     redactSpanUrlAttributes(attributes);     expect(JSON.stringify(attributes)).not.toContain("synthetic-fragment-token");    expect(attributes["url.full"]).toBe("http://exa mple.test/callback");  });   it("does not treat an @ inside the fragment as userinfo", () => {    const attributes: Record<string, unknown> = {      "url.full": "http://exa mple.test/docs#note@anchor",    };     redactSpanUrlAttributes(attributes);     expect(attributes["url.full"]).toBe("http://exa mple.test/docs");  });   it("strips userinfo from a URL attribute", () => {    const attributes: Record<string, unknown> = {      "url.full": "https://svc:synthetic-basic-password@api.test/graphql",    };     expect(redactSpanUrlAttributes(attributes)).toEqual(["userinfo"]);    expect(attributes["url.full"]).toBe("https://api.test/graphql");    expect(JSON.stringify(attributes)).not.toContain("synthetic-basic-password");  });   it("scrubs a URL embedded in a free-text attribute", () => {    const attributes: Record<string, unknown> = {      "error.message": "request to https://api.test/graphql?key=synthetic-key failed",    };     redactSpanUrlAttributes(attributes);     expect(attributes["error.message"]).toBe("request to https://api.test/graphql failed");  });   it("degrades an unparseable URL attribute instead of passing it through", () => {    const attributes: Record<string, unknown> = {      "url.full": "http://exa mple.test/graphql?key=synthetic-key",    };     expect(redactSpanUrlAttributes(attributes)).toEqual(["key"]);    expect(attributes["url.full"]).toBe("http://exa mple.test/graphql");  });   it("redacts the string elements of an array attribute value in place", () => {    // OTel attributes permit string[] values — an array element carries a    // credential-bearing URL exactly as a scalar does. Non-string elements    // pass through untouched and the array keeps its identity.    const SECRET = "synthetic-array-element-secret";    const urlArray = [`https://canary:${SECRET}@host.test/graphql?key=${SECRET}`, 42];    const attributes: Record<string, unknown> = {      "url.full": urlArray,      "url.query": [`key=${SECRET}`, 7],      "error.notes": [`request to https://api.test/graphql?key=${SECRET} failed`, true],    };     const stripped = redactSpanUrlAttributes(attributes);     expect(stripped).toEqual(["key", "userinfo"]);    expect(JSON.stringify(attributes)).not.toContain(SECRET);    expect(attributes["url.full"]).toBe(urlArray);    expect(attributes["url.full"]).toEqual(["https://host.test/graphql", 42]);    expect(attributes["url.query"]).toEqual(["", 7]);    expect(attributes["error.notes"]).toEqual(["request to https://api.test/graphql failed", true]);  });   it("leaves a query-, fragment-, and userinfo-free URL untouched", () => {    const attributes: Record<string, unknown> = {      "url.full": "https://app.test/api/integrations",      "url.path": "/api/integrations",    };     expect(redactSpanUrlAttributes(attributes)).toEqual([]);    expect(attributes["url.full"]).toBe("https://app.test/api/integrations");  });}); describe("redactUrlForTelemetry", () => {  it("strips query, fragment, and userinfo together", () => {    const result = redactUrlForTelemetry(      "https://svc:synthetic-pass@api.test/x?owner=synthetic-q#access_token=synthetic-f",    );    expect(result.url).toBe("https://api.test/x");    expect(result.stripped).toEqual(["fragment", "owner", "userinfo"]);  });   it("returns a clean URL unchanged", () => {    expect(redactUrlForTelemetry("https://api.test/x")).toEqual({      url: "https://api.test/x",      stripped: [],    });  });}); describe("redactUrlsInText", () => {  it("scrubs userinfo, query values, and fragments from embedded URLs", () => {    const text = `Transport: fetch failed (GET https://u:synthetic-pass@api.test/x?key=synthetic-key#t=synthetic-frag)`;    expect(redactUrlsInText(text)).toBe("Transport: fetch failed (GET https://api.test/x)");  });}); describe("redactOtlpTraceExport", () => {  it("scrubs every channel of a serialized OTLP batch", () => {    const SECRET = "synthetic-otlp-secret";    const payload = {      resourceSpans: [        {          resource: { attributes: [] },          scopeSpans: [            {              scope: { name: "executor-web" },              spans: [                {                  traceId: "0af7651916cd43dd8448eb211c80319c",                  spanId: "b7ad6b7169203331",                  name: "http.client GET",                  attributes: [                    {                      key: "url.full",                      value: {                        stringValue: `https://u:${SECRET}-userinfo@api.test/x?owner=${SECRET}-query#t=${SECRET}-fragment`,                      },                    },                    { key: "url.query", value: { stringValue: `owner=${SECRET}-query` } },                    {                      key: "http.url",                      // Malformed on purpose: the free-text regex alone would                      // not match it, so the key-aware path must.                      value: { stringValue: `http://exa mple.test/x?key=${SECRET}-malformed` },                    },                  ],                  events: [                    {                      name: "exception",                      attributes: [                        {                          key: "exception.message",                          value: {                            stringValue: `GET https://api.test/x?key=${SECRET}-event failed`,                          },                        },                      ],                    },                  ],                  status: {                    code: 2,                    message: `GET https://api.test/x?key=${SECRET}-status failed`,                  },                  links: [                    {                      traceId: "0af7651916cd43dd8448eb211c80319c",                      spanId: "00f067aa0ba902b7",                      attributes: [                        {                          key: "peer.url",                          value: { stringValue: `https://api.test/x?key=${SECRET}-link` },                        },                      ],                    },                  ],                },              ],            },          ],        },      ],    };     const serialized = JSON.stringify(redactOtlpTraceExport(payload));     expect(serialized).not.toContain(SECRET);    // Non-vacuous: identity, route, and structure survive the scrub.    expect(serialized).toContain("0af7651916cd43dd8448eb211c80319c");    expect(serialized).toContain("https://api.test/x");    expect(serialized).toContain("exception");    expect(serialized).toContain("http://exa mple.test/x");  });   it("scrubs the sibling fields of a URL-keyed KeyValue instead of passing them through", () => {    // The URL-aware KeyValue special case must not exempt the REST of that    // object from the walk: a crafted KeyValue can carry URL-bearing text in a    // sibling of `key`/`value.stringValue`, and the browser-traces forwarder    // feeds attacker-shaped JSON straight through this function.    const SECRET = "synthetic-keyvalue-sibling-secret";    const payload = {      resourceSpans: [        {          scopeSpans: [            {              spans: [                {                  attributes: [                    {                      key: "url.full",                      value: {                        stringValue: "https://api.test/x?owner=synthetic-keyvalue-main-secret",                        extraValue: `see https://api.test/x?owner=${SECRET}-inner`,                      },                      note: `retry of https://api.test/x?owner=${SECRET}-sibling`,                    },                  ],                },              ],            },          ],        },      ],    };     const serialized = JSON.stringify(redactOtlpTraceExport(payload));     expect(serialized).not.toContain(SECRET);    expect(serialized).not.toContain("synthetic-keyvalue-main-secret");    // Non-vacuous: the redacted URL survives in every field.    expect(serialized).toContain("https://api.test/x");  });   it("drops content past the nesting bound rather than forwarding it unexamined", () => {    let deep: unknown = "https://api.test/x?key=synthetic-deep-secret";    for (let index = 0; index < 200; index += 1) deep = [deep];    const serialized = JSON.stringify(redactOtlpTraceExport({ resourceSpans: deep }));    expect(serialized).not.toContain("synthetic-deep-secret");  });});