redaction.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { describe, expect, test } from "bun:test"
  2. import { HttpBody, HttpClientRequest } from "effect/unstable/http"
  3. import { redactedErrorRequest } from "../src/http/recorder"
  4. import { make, redactHeaders, redactUrl } from "../src/redaction/redactor"
  5. import { secretFindings } from "../src/redaction/secrets"
  6. describe("redaction", () => {
  7. test("redacts sensitive URL query parameters", () => {
  8. expect(
  9. redactUrl(
  10. "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
  11. ),
  12. ).toBe(
  13. "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
  14. )
  15. })
  16. test("redacts URL credentials", () => {
  17. expect(redactUrl("https://user:password@example.test/path?safe=value")).toBe(
  18. "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
  19. )
  20. })
  21. test("applies custom URL redaction after built-in redaction", () => {
  22. expect(
  23. redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) =>
  24. url.replace("/accounts/real-account/", "/accounts/{account}/"),
  25. ),
  26. ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
  27. })
  28. test("redacts sensitive headers when allow-listed", () => {
  29. expect(
  30. redactHeaders(
  31. {
  32. authorization: "Bearer secret-token",
  33. "content-type": "application/json",
  34. "x-custom-token": "custom-secret",
  35. "x-api-key": "secret-key",
  36. "x-goog-api-key": "secret-google-key",
  37. },
  38. ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
  39. ["x-custom-token"],
  40. ),
  41. ).toEqual({
  42. authorization: "[REDACTED]",
  43. "content-type": "application/json",
  44. "x-api-key": "[REDACTED]",
  45. "x-custom-token": "[REDACTED]",
  46. "x-goog-api-key": "[REDACTED]",
  47. })
  48. })
  49. test("redacts error requests without retaining headers, params, or body", () => {
  50. const request = HttpClientRequest.post("https://example.test/path", {
  51. headers: { authorization: "Bearer super-secret" },
  52. body: HttpBody.text("super-secret-body", "text/plain"),
  53. }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
  54. expect(redactedErrorRequest(request).toJSON()).toMatchObject({
  55. url: "https://example.test/path",
  56. urlParams: { params: [] },
  57. headers: {},
  58. body: { _tag: "Empty" },
  59. })
  60. })
  61. test("detects secret-looking values without returning the secret", () => {
  62. expect(
  63. secretFindings({
  64. version: 1,
  65. interactions: [
  66. {
  67. transport: "http",
  68. request: {
  69. method: "POST",
  70. url: "https://example.test/path?key=sk-123456789012345678901234",
  71. headers: {},
  72. body: JSON.stringify({
  73. nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE",
  74. }),
  75. },
  76. response: {
  77. status: 200,
  78. headers: {},
  79. body: "Bearer abcdefghijklmnopqrstuvwxyz",
  80. },
  81. },
  82. ],
  83. }),
  84. ).toEqual([
  85. { path: "interactions[0].request.url", reason: "API key" },
  86. { path: "interactions[0].request.body", reason: "Google API key" },
  87. { path: "interactions[0].response.body", reason: "bearer token" },
  88. ])
  89. })
  90. test("detects secret-looking values inside metadata", () => {
  91. expect(
  92. secretFindings({
  93. version: 1,
  94. metadata: { token: "sk-123456789012345678901234" },
  95. interactions: [],
  96. }),
  97. ).toEqual([{ path: "metadata.token", reason: "API key" }])
  98. })
  99. test("redacts configured and common sensitive JSON fields", () => {
  100. const redactor = make({
  101. jsonFields: ["account_id"],
  102. })
  103. const request = redactor.request({
  104. method: "POST",
  105. url: "https://example.test/path",
  106. headers: { "content-type": "application/json" },
  107. body: JSON.stringify({
  108. password: "secret-password",
  109. accessToken: "access-token",
  110. nested: { account_id: "account-123", safe: "visible" },
  111. }),
  112. })
  113. expect(JSON.parse(request.body)).toEqual({
  114. password: "[REDACTED]",
  115. accessToken: "[REDACTED]",
  116. nested: { account_id: "[REDACTED]", safe: "visible" },
  117. })
  118. })
  119. test("preserves JSON text when no fields are redacted", () => {
  120. const body = '{\n "id": 9007199254740993,\n "safe": true\n}'
  121. expect(
  122. make().request({
  123. method: "POST",
  124. url: "https://example.test/path",
  125. headers: { "content-type": "application/json" },
  126. body,
  127. }).body,
  128. ).toBe(body)
  129. })
  130. test("extends default header redaction and allow lists", () => {
  131. const redactor = make({
  132. headers: ["x-custom-token"],
  133. allowRequestHeaders: ["anthropic-version", "x-custom-token"],
  134. })
  135. expect(
  136. redactor.request({
  137. method: "GET",
  138. url: "https://example.test/path",
  139. headers: {
  140. authorization: "Bearer secret",
  141. "content-type": "application/json",
  142. "anthropic-version": "2023-06-01",
  143. "x-custom-token": "secret",
  144. },
  145. body: "",
  146. }).headers,
  147. ).toEqual({
  148. "anthropic-version": "2023-06-01",
  149. "content-type": "application/json",
  150. "x-custom-token": "[REDACTED]",
  151. })
  152. })
  153. })