diff --git a/site/package.json b/site/package.json index 9db41828d38..161b90b95f6 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 241100290e1..1a57ac45353 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.test.tsx b/site/src/modules/resources/AgentLogs/AgentLogLine.test.tsx index 80b1d900b04..7dcdc1a5de4 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 d7b3c50dbfe..46f6e24784e 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,9 +16,13 @@ export const AgentLogLine: FC = ({ sourceIcon, style, }) => { - const output = useMemo(() => { - return convert.toHtml(line.output.split(/\r/g).pop() as string); - }, [line.output]); + // Only render the text after the last carriage return so progress-bar style + // output that redraws a single line shows its final state. + const lastCarriageReturn = line.output.lastIndexOf("\r"); + const output = + lastCarriageReturn === -1 + ? line.output + : line.output.slice(lastCarriageReturn + 1); const timestamp = useMemo(() => { return dayjs(line.time).format("HH:mm:ss.SSS"); }, [line.time]); @@ -29,12 +31,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 bd4f3364b7b..1cd1035a079 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, + }, +};