From 749363a1f96073636efa4c00510824e80f532fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Mon, 13 Jul 2026 20:09:27 +0000 Subject: [PATCH 1/3] fix(site): replace ansi-to-html to remove log viewer ReDoS ansi-to-html 0.7.2 (unmaintained) has a ReDoS in its SGR regex (Cure53 CDM-02-004 / SEC-266). The agent log viewer is the only consumer. Swap it for fancy-ansi, which parses in linear time and escapes untrusted markup by default. Also simplify the carriage-return progress-bar handling from a regex split to lastIndexOf/slice. --- site/package.json | 2 +- site/pnpm-lock.yaml | 27 ++++----- .../resources/AgentLogs/AgentLogLine.tsx | 18 +++--- .../resources/AgentLogs/AgentLogs.stories.tsx | 55 +++++++++++++++++++ 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/site/package.json b/site/package.json index 9db41828d382c..161b90b95f666 100644 --- a/site/package.json +++ b/site/package.json @@ -67,7 +67,6 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "5.5.0", - "ansi-to-html": "0.7.2", "axios": "1.16.1", "chroma-js": "2.6.0", "class-variance-authority": "0.7.1", @@ -79,6 +78,7 @@ "dayjs": "1.11.20", "diff": "8.0.4", "emoji-mart": "5.6.0", + "fancy-ansi": "0.1.3", "file-saver": "2.0.5", "formik": "2.4.9", "front-matter": "4.0.2", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 241100290e101..1a57ac4535330 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -122,9 +122,6 @@ importers: '@xterm/xterm': specifier: 5.5.0 version: 5.5.0 - ansi-to-html: - specifier: 0.7.2 - version: 0.7.2 axios: specifier: 1.16.1 version: 1.16.1 @@ -158,6 +155,9 @@ importers: emoji-mart: specifier: 5.6.0 version: 5.6.0 + fancy-ansi: + specifier: 0.1.3 + version: 0.1.3 file-saver: specifier: 2.0.5 version: 2.0.5 @@ -3097,11 +3097,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==, tarball: https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz} engines: {node: '>=12'} - ansi-to-html@0.7.2: - resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==, tarball: https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz} - engines: {node: '>=8.0.0'} - hasBin: true - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==, tarball: https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz} @@ -3864,9 +3859,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, tarball: https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz} engines: {node: '>= 0.8'} - entities@2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==, tarball: https://registry.npmjs.org/entities/-/entities-2.2.0.tgz} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==, tarball: https://registry.npmjs.org/entities/-/entities-6.0.1.tgz} engines: {node: '>=0.12'} @@ -3955,6 +3947,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, tarball: https://registry.npmjs.org/extend/-/extend-3.0.2.tgz} + fancy-ansi@0.1.3: + resolution: {integrity: sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==, tarball: https://registry.npmjs.org/fancy-ansi/-/fancy-ansi-0.1.3.tgz} + fast-equals@5.3.2: resolution: {integrity: sha512-6rxyATwPCkaFIL3JLqw8qXqMpIZ942pTX/tbQFkRsDGblS8tNGtlUauA/+mt6RUfqn/4MoEr+WDkYoIQbibWuQ==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.2.tgz} engines: {node: '>=6.0.0'} @@ -9313,10 +9308,6 @@ snapshots: ansi-styles@6.2.3: {} - ansi-to-html@0.7.2: - dependencies: - entities: 2.2.0 - any-promise@1.3.0: {} anymatch@3.1.3: @@ -10078,8 +10069,6 @@ snapshots: encodeurl@2.0.0: {} - entities@2.2.0: {} - entities@6.0.1: {} error-ex@1.3.2: @@ -10210,6 +10199,10 @@ snapshots: extend@3.0.2: {} + fancy-ansi@0.1.3: + dependencies: + escape-html: 1.0.3 + fast-equals@5.3.2: {} fast-glob@3.3.3: diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx index d7b3c50dbfe52..e77cada3981c5 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx @@ -1,12 +1,10 @@ -import AnsiToHTML from "ansi-to-html"; import dayjs from "dayjs"; +import { AnsiHtml } from "fancy-ansi/react"; import { type FC, type ReactNode, useMemo } from "react"; import { type Line, LogLine, LogLinePrefix } from "#/components/Logs/LogLine"; // Approximate height of a log line. Used to control virtualized list height. export const AGENT_LOG_LINE_HEIGHT = 20; -const convert = new AnsiToHTML({ escapeXML: true }); - interface AgentLogLineProps { line: Line; style: React.CSSProperties; @@ -18,8 +16,13 @@ export const AgentLogLine: FC = ({ sourceIcon, style, }) => { + // Only render the text after the last carriage return so progress-bar style + // output that redraws a single line shows its final state. const output = useMemo(() => { - return convert.toHtml(line.output.split(/\r/g).pop() as string); + const lastCarriageReturn = line.output.lastIndexOf("\r"); + return lastCarriageReturn === -1 + ? line.output + : line.output.slice(lastCarriageReturn + 1); }, [line.output]); const timestamp = useMemo(() => { return dayjs(line.time).format("HH:mm:ss.SSS"); @@ -29,12 +32,7 @@ export const AgentLogLine: FC = ({ {sourceIcon} {timestamp} - + ); }; diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx index bd4f3364b7b28..b9acffe921124 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx @@ -1,4 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import type { Line } from "#/components/Logs/LogLine"; import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogLine"; import { AgentLogs } from "./AgentLogs"; import { MockLogs, MockSources } from "./mocks"; @@ -28,3 +30,56 @@ export const Overflowed: Story = { overflowed: true, }, }; + +const sourceId = MockSources[0].id; + +// Exercises ANSI parsing, carriage-return progress-bar redraws, untrusted HTML, +// and a ReDoS payload (Cure53 CDM-02-004) that hung the previous ansi-to-html +// converter. fancy-ansi parses in linear time, so this story renders instantly. +const AnsiLogs: readonly Line[] = [ + { + id: 1, + level: "info", + output: + "\u001b[31mred\u001b[0m \u001b[32mgreen\u001b[0m \u001b[1mbold\u001b[0m", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, + { + id: 2, + level: "info", + output: "downloading... 50%\rdownloading... 100%", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, + { + id: 3, + level: "info", + output: 'untrusted markup', + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, + { + id: 4, + level: "info", + output: `\u001b[${"1".repeat(50)}`, + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, +]; + +export const AnsiFormatting: Story = { + args: { + logs: AnsiLogs, + height: AnsiLogs.length * AGENT_LOG_LINE_HEIGHT, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Only the final carriage-return segment is shown. + await canvas.findByText("downloading... 100%"); + expect(canvas.queryByText(/50%/)).not.toBeInTheDocument(); + // Untrusted markup is escaped, not rendered as an element. + await canvas.findByText(/untrusted markup<\/span>/); + expect(canvas.queryByTestId("xss")).not.toBeInTheDocument(); + }, +}; From c68b510f10019e43559f2ea1436a219c101af710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Mon, 13 Jul 2026 20:17:13 +0000 Subject: [PATCH 2/3] test(site/src/modules/resources/AgentLogs): cover ansi rendering; drop useMemo Move the ANSI/ReDoS/carriage-return coverage into the vitest suite instead of a Storybook story, since it is purely functional. Also drop the useMemo around the carriage-return slice. --- .../resources/AgentLogs/AgentLogLine.test.tsx | 37 +++++++++++-- .../resources/AgentLogs/AgentLogLine.tsx | 7 +-- .../resources/AgentLogs/AgentLogs.stories.tsx | 55 ------------------- 3 files changed, 36 insertions(+), 63 deletions(-) diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx index 80b1d900b048d..7dcdc1a5de437 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx @@ -3,21 +3,50 @@ import type { Line } from "#/components/Logs/LogLine"; import { renderComponent } from "#/testHelpers/renderHelpers"; import { AgentLogLine } from "./AgentLogLine"; -const line: Line = { +const makeLine = (output: string): Line => ({ id: 1, level: "info", - output: 'safe xss', + output, sourceId: "source-id", time: "2024-03-14T11:31:04.090715Z", -}; +}); + +const renderLine = (output: string) => + renderComponent( + , + ); describe("AgentLogLine", () => { it("renders log HTML as escaped text", () => { - renderComponent(); + renderLine('safe xss'); expect(screen.queryByTestId("agent-log-xss")).not.toBeInTheDocument(); expect( screen.getByText(/safe xss<\/span>/), ).toBeInTheDocument(); }); + + it("renders ANSI color codes as styled markup", () => { + renderLine("\u001b[31mred\u001b[0m plain"); + + const colored = screen.getByText("red"); + expect(colored.tagName).toBe("SPAN"); + expect(colored.getAttribute("style")).toContain("--ansi-red"); + expect(screen.getByText(/plain/)).toBeInTheDocument(); + }); + + it("shows only the text after the last carriage return", () => { + renderLine("downloading... 50%\rdownloading... 100%"); + + expect(screen.getByText("downloading... 100%")).toBeInTheDocument(); + expect(screen.queryByText(/50%/)).not.toBeInTheDocument(); + }); + + it("renders a ReDoS payload without hanging", () => { + // Cure53 CDM-02-004: this pattern caused catastrophic backtracking in + // ansi-to-html. fancy-ansi parses it in linear time. + const start = performance.now(); + renderLine(`\u001b[${"1".repeat(50000)}`); + expect(performance.now() - start).toBeLessThan(1000); + }); }); diff --git a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx index e77cada3981c5..46f6e24784e8c 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogLine.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogLine.tsx @@ -18,12 +18,11 @@ export const AgentLogLine: FC = ({ }) => { // Only render the text after the last carriage return so progress-bar style // output that redraws a single line shows its final state. - const output = useMemo(() => { - const lastCarriageReturn = line.output.lastIndexOf("\r"); - return lastCarriageReturn === -1 + const lastCarriageReturn = line.output.lastIndexOf("\r"); + const output = + lastCarriageReturn === -1 ? line.output : line.output.slice(lastCarriageReturn + 1); - }, [line.output]); const timestamp = useMemo(() => { return dayjs(line.time).format("HH:mm:ss.SSS"); }, [line.time]); diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx index b9acffe921124..bd4f3364b7b28 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx @@ -1,6 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; -import type { Line } from "#/components/Logs/LogLine"; import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogLine"; import { AgentLogs } from "./AgentLogs"; import { MockLogs, MockSources } from "./mocks"; @@ -30,56 +28,3 @@ export const Overflowed: Story = { overflowed: true, }, }; - -const sourceId = MockSources[0].id; - -// Exercises ANSI parsing, carriage-return progress-bar redraws, untrusted HTML, -// and a ReDoS payload (Cure53 CDM-02-004) that hung the previous ansi-to-html -// converter. fancy-ansi parses in linear time, so this story renders instantly. -const AnsiLogs: readonly Line[] = [ - { - id: 1, - level: "info", - output: - "\u001b[31mred\u001b[0m \u001b[32mgreen\u001b[0m \u001b[1mbold\u001b[0m", - time: "2024-03-14T11:31:04.090715Z", - sourceId, - }, - { - id: 2, - level: "info", - output: "downloading... 50%\rdownloading... 100%", - time: "2024-03-14T11:31:04.090715Z", - sourceId, - }, - { - id: 3, - level: "info", - output: 'untrusted markup', - time: "2024-03-14T11:31:04.090715Z", - sourceId, - }, - { - id: 4, - level: "info", - output: `\u001b[${"1".repeat(50)}`, - time: "2024-03-14T11:31:04.090715Z", - sourceId, - }, -]; - -export const AnsiFormatting: Story = { - args: { - logs: AnsiLogs, - height: AnsiLogs.length * AGENT_LOG_LINE_HEIGHT, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - // Only the final carriage-return segment is shown. - await canvas.findByText("downloading... 100%"); - expect(canvas.queryByText(/50%/)).not.toBeInTheDocument(); - // Untrusted markup is escaped, not rendered as an element. - await canvas.findByText(/untrusted markup<\/span>/); - expect(canvas.queryByTestId("xss")).not.toBeInTheDocument(); - }, -}; From cc1b6d696d8fd32b45c1bb444d77692bd8bde5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?McKayla=20=E3=81=AF=E3=81=AA?= Date: Mon, 13 Jul 2026 21:49:04 +0000 Subject: [PATCH 3/3] docs(site/src/modules/resources/AgentLogs): restore ANSI formatting story Keep the visual AnsiFormatting story for presentation while the functional assertions live in the vitest suite. --- .../resources/AgentLogs/AgentLogs.stories.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx index bd4f3364b7b28..1cd1035a07928 100644 --- a/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx +++ b/site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Line } from "#/components/Logs/LogLine"; import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogLine"; import { AgentLogs } from "./AgentLogs"; import { MockLogs, MockSources } from "./mocks"; @@ -28,3 +29,39 @@ export const Overflowed: Story = { overflowed: true, }, }; + +const sourceId = MockSources[0].id; + +// Demonstrates how ANSI escape sequences and carriage-return progress-bar +// redraws are rendered in the log viewer. +const AnsiLogs: readonly Line[] = [ + { + id: 1, + level: "info", + output: + "\u001b[31mred\u001b[0m \u001b[32mgreen\u001b[0m \u001b[34mblue\u001b[0m \u001b[1mbold\u001b[0m \u001b[3mitalic\u001b[0m", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, + { + id: 2, + level: "info", + output: "\u001b[43m\u001b[30m warning \u001b[0m background colors", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, + { + id: 3, + level: "info", + output: "downloading... 50%\rdownloading... 100%", + time: "2024-03-14T11:31:04.090715Z", + sourceId, + }, +]; + +export const AnsiFormatting: Story = { + args: { + logs: AnsiLogs, + height: AnsiLogs.length * AGENT_LOG_LINE_HEIGHT, + }, +};