From db31b302a344e5e38f55be91037f23513b01e4c2 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Sat, 27 Jun 2026 04:45:04 +0800 Subject: [PATCH] Add sensitive tool regression coverage --- .../tools/security-header-analyzer/page.tsx | 12 +- .../sensitive-tools-regression.test.tsx | 297 ++++++++++++++++++ tests/guards/sensitive-storage-audit.test.ts | 4 + tests/unit/env-parser-utils.test.ts | 18 ++ tests/unit/har-viewer-sanitizer-utils.test.ts | 38 +++ tests/unit/log-parser-logic.test.ts | 31 ++ tests/unit/log-scrubber-utils.test.ts | 18 ++ tests/unit/security-header-analyzer.test.ts | 23 ++ 8 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 tests/component/sensitive-tools-regression.test.tsx diff --git a/src/features/tools/security-header-analyzer/page.tsx b/src/features/tools/security-header-analyzer/page.tsx index f40d2a1e..d955b89b 100644 --- a/src/features/tools/security-header-analyzer/page.tsx +++ b/src/features/tools/security-header-analyzer/page.tsx @@ -33,10 +33,20 @@ const statusIcons: Record = { + copy_report: "Copy report", + header_input_hint: "Paste response headers only. Request or response bodies are not needed.", + header_input_label: "HTTP response headers", + header_input_placeholder: "HTTP/2 200\ncontent-security-policy: default-src 'self'; object-src 'none'\nstrict-transport-security: max-age=31536000; includeSubDomains", + sample_action: "Sample", + score_hint: "Warnings count as partial credit. Review each recommendation before changing production headers.", + score_label: "Security Header Score", +} + export function SecurityHeaderAnalyzerPage() { const { t } = useLang() const toolT = t.tools["security_header_analyzer"] as Record - const text = React.useCallback((key: string) => toolT[key], [toolT]) + const text = React.useCallback((key: string) => toolT[key] || FALLBACK_LABELS[key] || key, [toolT]) const [input, setInput] = React.useState(SAMPLE_HEADERS) const summary = React.useMemo(() => analyzeSecurityHeaders(input), [input]) diff --git a/tests/component/sensitive-tools-regression.test.tsx b/tests/component/sensitive-tools-regression.test.tsx new file mode 100644 index 00000000..a1986fd6 --- /dev/null +++ b/tests/component/sensitive-tools-regression.test.tsx @@ -0,0 +1,297 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { LangProvider } from "@/core/i18n/lang-provider" +import { getTranslation } from "@/core/i18n/translations/catalog" +import { LogScrubberPage } from "@/features/tools/log-scrubber/page" +import { HarViewerSanitizerPage } from "@/features/tools/har-viewer-sanitizer/page" +import { CertificateDecoderPage } from "@/features/tools/certificate-decoder/page" +import { CspParserPage } from "@/features/tools/csp-parser/page" +import { SecurityHeaderAnalyzerPage } from "@/features/tools/security-header-analyzer/page" +import { EnvVariableParserPage } from "@/features/tools/env-parser/page" +import { LocalLogParserPage } from "@/features/tools/local-log-parser/page" + +const clipboardWriteMock = vi.fn() +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), +})) + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) + +vi.mock("next/navigation", () => ({ + usePathname: () => "/en/log-scrubber", +})) + +vi.mock("@/core/clipboard/clipboard", () => ({ + safeClipboardWrite: (value: string) => clipboardWriteMock(value), +})) + +vi.mock("sonner", () => ({ + toast: toastMocks, +})) + +vi.mock("@/core/seo/components/related-tools", () => ({ + RelatedTools: ({ toolKey }: { toolKey: string }) =>
{toolKey}
, +})) + +function renderEnglish(ui: React.ReactNode) { + return render( + + {ui} + , + ) +} + +function expectSensitiveWarningLinks() { + expect(screen.getByLabelText("Sensitive input warning")).toBeInTheDocument() + expect(screen.getByRole("link", { name: /Trust Center/i })).toHaveAttribute("href", "/en/trust-center") + expect(screen.getByRole("link", { name: /DevTools/i })).toHaveAttribute("href", "/en/trust-center#verify-local-processing") +} + +function expectNoPayloadStorage(payload: string) { + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index) + const value = key ? window.localStorage.getItem(key) : null + expect(`${key ?? ""}:${value ?? ""}`).not.toContain(payload) + } +} + +function derLength(length: number) { + if (length < 0x80) return [length] + const bytes: number[] = [] + let remaining = length + while (remaining > 0) { + bytes.unshift(remaining & 0xff) + remaining >>= 8 + } + return [0x80 | bytes.length, ...bytes] +} + +function der(tag: number, value: number[]) { + return [tag, ...derLength(value.length), ...value] +} + +function sequence(...children: number[][]) { + return der(0x30, children.flat()) +} + +function setOf(...children: number[][]) { + return der(0x31, children.flat()) +} + +function oid(value: string) { + const parts = value.split(".").map(Number) + const body = [parts[0] * 40 + parts[1]] + for (const part of parts.slice(2)) { + const encoded = [part & 0x7f] + let remaining = part >> 7 + while (remaining > 0) { + encoded.unshift((remaining & 0x7f) | 0x80) + remaining >>= 7 + } + body.push(...encoded) + } + return der(0x06, body) +} + +function utf8(value: string) { + return Array.from(new TextEncoder().encode(value)) +} + +function distinguishedName(commonName: string) { + return sequence(setOf(sequence(oid("2.5.4.3"), der(0x0c, utf8(commonName))))) +} + +function minimalCertificatePem() { + const algorithm = sequence(oid("1.2.840.113549.1.1.11"), der(0x05, [])) + const name = distinguishedName("Byteflow Test") + const validity = sequence(der(0x17, utf8("260101000000Z")), der(0x17, utf8("300101000000Z"))) + const publicKey = sequence(sequence(oid("1.2.840.113549.1.1.1"), der(0x05, [])), der(0x03, [0x00, 0x30, 0x00])) + const tbsCertificate = sequence(der(0x02, [0x01]), algorithm, name, validity, name, publicKey) + const certBytes = new Uint8Array(sequence(tbsCertificate, algorithm, der(0x03, [0x00, 0x00]))) + const base64 = Buffer.from(certBytes).toString("base64").replace(/.{1,64}/g, "$&\n").trim() + return `-----BEGIN CERTIFICATE-----\n${base64}\n-----END CERTIFICATE-----` +} + +describe("sensitive tool regression coverage", () => { + beforeEach(() => { + vi.clearAllMocks() + clipboardWriteMock.mockResolvedValue({ ok: true }) + window.localStorage.clear() + }) + + it("redacts Log Scrubber sample data and does not persist payloads", () => { + renderEnglish() + + expectSensitiveWarningLinks() + fireEvent.click(screen.getByRole("button", { name: "Sample" })) + fireEvent.click(screen.getByRole("button", { name: "Scrub logs" })) + + const output = screen.getByPlaceholderText("Redacted logs will appear here...") as HTMLTextAreaElement + expect(output.value).toContain("[EMAIL_REDACTED]") + expect(output.value).toContain("[IP_REDACTED]") + expect(output.value).toContain("[TOKEN_REDACTED]") + expect(output.value).not.toContain("alice@example.com") + expect(output.value).not.toContain("hunter2") + expect(screen.getByText(/Automated redaction is a safety layer/)).toBeInTheDocument() + expectNoPayloadStorage("alice@example.com") + }) + + it("sanitizes HAR exports, handles malformed input, and avoids storage writes", () => { + renderEnglish() + + expectSensitiveWarningLinks() + fireEvent.change(screen.getByPlaceholderText("Paste a HAR JSON export..."), { + target: { value: "{not-json" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Sanitize" })) + expect(screen.getByRole("alert")).toHaveTextContent(/Expected property name|Unexpected token|valid JSON|Unable to sanitize HAR/i) + + fireEvent.click(screen.getByRole("button", { name: /Try example/i })) + fireEvent.click(screen.getByRole("button", { name: "Sanitize" })) + + const output = screen.getByPlaceholderText("Sanitized HAR JSON will appear here...") as HTMLTextAreaElement + expect(output.value).toContain("[REDACTED]") + expect(output.value).toContain("_byteflowSanitizerSummary") + expect(output.value).not.toContain("Bearer secret") + expect(output.value).not.toContain("cookie-secret") + expectNoPayloadStorage("Bearer secret") + }) + + it("decodes normal certificates and covers empty, malformed, and large invalid PEM input without storage", () => { + renderEnglish() + + expectSensitiveWarningLinks() + const pemInput = screen.getByPlaceholderText(/-----BEGIN CERTIFICATE-----/) + const validPem = minimalCertificatePem() + + fireEvent.click(screen.getByRole("button", { name: "Decode" })) + expect(screen.queryByText(/Failed to parse certificate/i)).not.toBeInTheDocument() + + fireEvent.change(pemInput, { target: { value: validPem } }) + fireEvent.click(screen.getByRole("button", { name: "Decode" })) + expect(screen.getAllByText("CN=Byteflow Test").length).toBeGreaterThanOrEqual(2) + expect(screen.getByText("SHA256withRSA")).toBeInTheDocument() + expectNoPayloadStorage(validPem.slice(28, 52)) + + fireEvent.change(pemInput, { + target: { value: "not a certificate" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Decode" })) + expect(screen.getByText("Failed to parse certificate. Ensure it's a valid PEM-encoded X.509 certificate.")).toBeInTheDocument() + expectNoPayloadStorage("not a certificate") + + const largeInvalidPem = `-----BEGIN CERTIFICATE-----\n${"A".repeat(12_000)}\n-----END CERTIFICATE-----` + fireEvent.change(pemInput, { target: { value: largeInvalidPem } }) + fireEvent.click(screen.getByRole("button", { name: "Decode" })) + expect(screen.getByText("Failed to parse certificate. Ensure it's a valid PEM-encoded X.509 certificate.")).toBeInTheDocument() + expectNoPayloadStorage("AAAA") + }) + + it("keeps CSP analysis visible for normal, malformed, empty, and large risky policies without persistence", async () => { + renderEnglish() + + expectSensitiveWarningLinks() + const cspInput = screen.getByPlaceholderText(/default-src/) + + expect(await screen.findByText("default-src")).toBeInTheDocument() + expect(screen.getByText("frame-ancestors")).toBeInTheDocument() + + fireEvent.change(cspInput, { + target: { value: "not-a-directive @@@; script-src 'unsafe-inline' *" }, + }) + expect(await screen.findByText(/Unknown directive/)).toBeInTheDocument() + expect(screen.getAllByText(/unsafe-inline/).length).toBeGreaterThan(0) + + const largePolicy = [ + ...Array.from({ length: 180 }, (_, index) => `img-src https://cdn-${index}.example.com`), + "script-src 'unsafe-inline' *", + ].join("; ") + fireEvent.change(cspInput, { + target: { value: largePolicy }, + }) + expect(await screen.findByText("https://cdn-179.example.com")).toBeInTheDocument() + expect(screen.getAllByText(/unsafe-inline/).length).toBeGreaterThan(0) + expect(screen.getByText("Missing Recommended Directives")).toBeInTheDocument() + + fireEvent.change(cspInput, { + target: { value: "script-src 'unsafe-inline' *" }, + }) + expect(await screen.findByText("Missing Recommended Directives")).toBeInTheDocument() + expect(screen.getAllByText(/unsafe-inline/).length).toBeGreaterThan(0) + + fireEvent.click(screen.getByRole("button", { name: /Clear/i })) + expect(screen.queryByText("Missing Recommended Directives")).not.toBeInTheDocument() + expectNoPayloadStorage("unsafe-inline") + }) + + it("copies Security Header Analyzer reports for empty and normal inputs without storage writes", async () => { + renderEnglish() + + expectSensitiveWarningLinks() + expect(screen.getByText(/Security Header Score/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Copy" })) + + await waitFor(() => expect(clipboardWriteMock).toHaveBeenCalledWith(expect.stringContaining("Security Header Score"))) + expect(clipboardWriteMock).toHaveBeenCalledWith(expect.stringContaining("Content-Security-Policy")) + + fireEvent.change(screen.getByPlaceholderText(/HTTP\/2 200/), { target: { value: "" } }) + expect(screen.getByText(/Missing Content-Security-Policy header/)).toBeInTheDocument() + expectNoPayloadStorage("content-security-policy") + }) + + it("exports Env Parser output without writing secret payloads to localStorage", async () => { + renderEnglish() + + expectSensitiveWarningLinks() + fireEvent.change(screen.getByPlaceholderText("Paste .env content here..."), { + target: { value: "API_KEY=super-secret-value" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Copy" })) + + await waitFor(() => expect(clipboardWriteMock).toHaveBeenCalledWith(expect.stringContaining("super-secret-value"))) + expectNoPayloadStorage("super-secret-value") + }) + + it("parses and exports Local Log Parser output without default storage", () => { + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:logs") + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined) + const clickMock = vi.fn() + const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName: string) => { + const element = document.createElementNS("http://www.w3.org/1999/xhtml", tagName) as HTMLElement + if (tagName.toLowerCase() === "a") { + Object.assign(element, { click: clickMock }) + } + return element + }) + + renderEnglish() + + expectSensitiveWarningLinks() + fireEvent.click(screen.getByRole("button", { name: "Parse Logs" })) + expect(toastMocks.error).toHaveBeenCalledWith("Input is required.") + expect(screen.queryByRole("button", { name: "Export JSON" })).not.toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText(/Paste logs here/), { + target: { value: "2026-06-10T10:00:00Z ERROR token=[REDACTED]" }, + }) + fireEvent.click(screen.getByRole("button", { name: "Parse Logs" })) + expect(screen.getByText("Errors")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Export JSON" })) + + expect(createObjectURL).toHaveBeenCalled() + expect(clickMock).toHaveBeenCalled() + expectNoPayloadStorage("token=[REDACTED]") + + createElementSpy.mockRestore() + createObjectURL.mockRestore() + revokeObjectURL.mockRestore() + }) +}) diff --git a/tests/guards/sensitive-storage-audit.test.ts b/tests/guards/sensitive-storage-audit.test.ts index 35e9f304..366703a1 100644 --- a/tests/guards/sensitive-storage-audit.test.ts +++ b/tests/guards/sensitive-storage-audit.test.ts @@ -10,6 +10,10 @@ const SENSITIVE_AUDIT_TOOLS = [ "log-scrubber", "har-viewer-sanitizer", "certificate-decoder", + "csp-parser", + "security-header-analyzer", + "env-parser", + "local-log-parser", ] as const const FORBIDDEN_FEATURE_PATTERNS = [ diff --git a/tests/unit/env-parser-utils.test.ts b/tests/unit/env-parser-utils.test.ts index 9874cf8a..58ac15da 100644 --- a/tests/unit/env-parser-utils.test.ts +++ b/tests/unit/env-parser-utils.test.ts @@ -21,4 +21,22 @@ describe("env parser utils", () => { expect(envToYaml(parsed)).toBe("PORT: \"3000\"\nSECRET: \"quoted value\"") expect(envToDockerArgs(parsed)).toBe("-e PORT=\"3000\" \\\n -e SECRET=\"quoted value\"") }) + + it("handles empty, malformed, and large env files without dropping secrets from expected exports", () => { + expect(parseEnvFile("")).toEqual([{ key: "", value: "", isComment: false, isEmpty: true, line: 1 }]) + expect(parseEnvFile("MALFORMED_LINE")[0]).toMatchObject({ + key: "MALFORMED_LINE", + value: "", + line: 1, + }) + + const large = Array.from({ length: 500 }, (_, index) => `SECRET_${index}=value-${index}`).join("\n") + const parsed = parseEnvFile(large) + const exported = envToJson(parsed) + + expect(parsed).toHaveLength(500) + expect(exported).toContain('"SECRET_499": "value-499"') + expect(envToDockerArgs(parsed)).toContain('-e SECRET_499="value-499"') + expect(envToYaml(parsed)).toContain('SECRET_499: "value-499"') + }) }) diff --git a/tests/unit/har-viewer-sanitizer-utils.test.ts b/tests/unit/har-viewer-sanitizer-utils.test.ts index f7e5a2d5..7674e07c 100644 --- a/tests/unit/har-viewer-sanitizer-utils.test.ts +++ b/tests/unit/har-viewer-sanitizer-utils.test.ts @@ -139,4 +139,42 @@ describe("HAR viewer and sanitizer utilities", () => { expect(result.error).toBeTruthy() expect(result.entries).toEqual([]) }) + + it("handles empty, malformed, and large HAR inputs with sanitized exports", () => { + expect(parseHarSummary("").error).toBeTruthy() + expect(sanitizeHar("{not-json").error).toBeTruthy() + + const entries = Array.from({ length: 250 }, (_, index) => ({ + startedDateTime: "2026-06-10T10:00:00.000Z", + time: index, + request: { + method: "GET", + url: `https://api.example.com/items/${index}?token=secret-${index}&email=user${index}@example.com`, + headers: [{ name: "Authorization", value: `Bearer secret-${index}` }], + cookies: [{ name: "session", value: `cookie-${index}` }], + queryString: [ + { name: "token", value: `secret-${index}` }, + { name: "email", value: `user${index}@example.com` }, + ], + }, + response: { + status: index % 2 === 0 ? 200 : 500, + headers: [{ name: "Set-Cookie", value: `id=secret-${index}` }], + cookies: [{ name: "id", value: `secret-${index}` }], + content: { mimeType: "application/json", text: `{"token":"secret-${index}"}` }, + }, + })) + const input = JSON.stringify({ log: { entries } }) + const summary = parseHarSummary(input) + const sanitized = sanitizeHar(input) + + expect(summary.error).toBeUndefined() + expect(summary.totalRequests).toBe(250) + expect(summary.entries[249].url).not.toContain("secret-249") + expect(sanitized.error).toBeUndefined() + expect(sanitized.output).not.toContain("secret-249") + expect(sanitized.output).not.toContain("user249@example.com") + expect(sanitized.output).toContain("_byteflowSanitizerSummary") + expect(sanitized.redactionCount).toBeGreaterThan(1_000) + }) }) diff --git a/tests/unit/log-parser-logic.test.ts b/tests/unit/log-parser-logic.test.ts index 7e54793c..8dba5a26 100644 --- a/tests/unit/log-parser-logic.test.ts +++ b/tests/unit/log-parser-logic.test.ts @@ -115,6 +115,25 @@ ERROR: third` expect(result.entries[0].lineNumber).toBe(1) expect(result.entries[1].lineNumber).toBe(3) }) + + it("should handle malformed JSON-like lines and large log inputs", () => { + const large = Array.from({ length: 1_000 }, (_, index) => ( + index % 10 === 0 + ? `{"timestamp":"2026-06-10T10:00:00Z","level":"ERROR","message":"failed ${index}"}` + : `2026-06-10T10:00:00Z INFO request ${index}` + )).join("\n") + const result = parseLogs(`{not-json}\n${large}`) + + expect(result.totalLines).toBe(1_001) + expect(result.entries).toHaveLength(1_001) + expect(result.entries[0]).toMatchObject({ + lineNumber: 1, + level: undefined, + message: "{not-json}", + }) + expect(result.levelCounts.ERROR).toBe(100) + expect(result.levelCounts.INFO).toBe(900) + }) }) describe("filterLogs", () => { @@ -189,4 +208,16 @@ describe("exportToJSON", () => { expect(parsed).toHaveLength(1) expect(parsed[0].level).toBe("INFO") }) + + it("should export the filtered sanitized subset exactly", () => { + const entries = parseLogs(`INFO ok +ERROR token=[REDACTED] +WARN skipped`).entries + const filtered = filterLogs(entries, { levels: ["ERROR"] }) + + expect(exportToCSV(filtered)).toContain("token=[REDACTED]") + expect(exportToCSV(filtered)).not.toContain("WARN skipped") + expect(exportToJSON(filtered)).toContain("token=[REDACTED]") + expect(exportToJSON(filtered)).not.toContain("WARN skipped") + }) }) diff --git a/tests/unit/log-scrubber-utils.test.ts b/tests/unit/log-scrubber-utils.test.ts index 8007a55f..6649832d 100644 --- a/tests/unit/log-scrubber-utils.test.ts +++ b/tests/unit/log-scrubber-utils.test.ts @@ -140,4 +140,22 @@ describe("scrubLogs", () => { expect(summary.email).toBe(2) expect(summary.ipv4).toBe(1) }) + + it("handles empty, malformed, and large inputs without leaking known secrets", () => { + expect(scrubLogs("").output).toBe("") + expect(scrubLogs("{{{{ not-json but still a log line").redactionCount).toBe(0) + + const secret = joinTokenParts(["sk", "live", "largeinputsecret1234567890"], "_") + const largeInput = Array.from({ length: 1_000 }, (_, index) => ( + `2026-06-10T10:${String(index % 60).padStart(2, "0")}:00Z ERROR user${index}@example.com token=${secret} ip=203.0.113.${index % 255}` + )).join("\n") + const result = scrubLogs(largeInput) + + expect(result.redactionCount).toBe(3_000) + expect(result.output).not.toContain(secret) + expect(result.output).not.toContain("user999@example.com") + expect(result.output).toContain("[EMAIL_REDACTED]") + expect(result.output).toContain("[IP_REDACTED]") + expect(result.output).toContain("[TOKEN_REDACTED]") + }) }) diff --git a/tests/unit/security-header-analyzer.test.ts b/tests/unit/security-header-analyzer.test.ts index 75508be4..e602e805 100644 --- a/tests/unit/security-header-analyzer.test.ts +++ b/tests/unit/security-header-analyzer.test.ts @@ -47,4 +47,27 @@ x-frame-options: ALLOWALL expect(report).toContain("X-Content-Type-Options") expect(report).toContain("Recommendations") }) + + it("handles empty, malformed, duplicate, and large header blocks", () => { + const empty = analyzeSecurityHeaders("") + expect(empty.failCount).toBeGreaterThan(0) + expect(empty.percentage).toBeLessThan(50) + + const malformed = analyzeSecurityHeaders("not a header\nx-frame-options DENY\nx-content-type-options: nosniff") + expect(malformed.assessments.find((item) => item.key === "X-Content-Type-Options")?.status).toBe("pass") + expect(malformed.assessments.find((item) => item.key === "X-Frame-Options")?.status).toBe("fail") + + const duplicate = analyzeSecurityHeaders("x-frame-options: DENY\nx-frame-options: SAMEORIGIN") + expect(duplicate.assessments.find((item) => item.key === "X-Frame-Options")?.value).toBe("DENY, SAMEORIGIN") + + const large = analyzeSecurityHeaders([ + "HTTP/2 200", + ...Array.from({ length: 500 }, (_, index) => `x-debug-${index}: value-${index}`), + "content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'", + "strict-transport-security: max-age=31536000; includeSubDomains", + "x-content-type-options: nosniff", + ].join("\n")) + expect(large.assessments.find((item) => item.key === "Content-Security-Policy")?.status).toBe("pass") + expect(formatSecurityHeaderReport(large)).toContain("Strict-Transport-Security") + }) })