From 731a7899127f04f546eb3a8b4c2cb746b69cdbf7 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:44:39 +0000 Subject: [PATCH 01/10] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20CSV=20Formula=20Inj?= =?UTF-8?q?ection=20=EC=B7=A8=EC=95=BD=EC=A0=90=20=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 ++ .../[orgSlug]/dashboard/sessions/route.ts | 6 +- packages/web/src/lib/server/csv.test.ts | 56 +++++++++++++++++++ packages/web/src/lib/server/csv.ts | 9 +++ 4 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/lib/server/csv.test.ts create mode 100644 packages/web/src/lib/server/csv.ts diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 7902c442..54a164e3 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -30,3 +30,7 @@ **Vulnerability:** Known high-severity vulnerabilities discovered by the audit in `js-yaml` and `nanoid` packages. **Learning:** Deeply nested dependencies (`js-yaml` via `eslint`, `nanoid` via `vitest/vite`) may expose the application to DoS or logic loops. **Prevention:** Use `pnpm.overrides` in the root `package.json` to enforce patched versions across all transitive paths in a pnpm workspace. +## 2025-02-18 - [Fix CSV Formula Injection (Macro Injection)] +**Vulnerability:** CSV 내보내기 기능에서 사용자 입력값(세션 타이틀, 프로젝트 이름 등)이 이스케이프 되지 않아 CSV Formula Injection (Macro Injection) 취약점이 발생할 수 있었습니다. +**Learning:** Excel, Google Sheets 등은 CSV 파일에서 `=, +, -, @, \t, \r, \n` 등의 문자로 시작하는 필드를 수식으로 해석하고 실행합니다. +**Prevention:** CSV로 데이터를 내보낼 때, 원시 `number` 타입이 아닌 문자열 필드가 수식 트리거 문자로 시작하는 경우, 값 앞에 작은따옴표(`'`)를 추가하여 일반 문자열로 처리되도록 해야 합니다. diff --git a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts index 7d6a4d4a..a2432faf 100644 --- a/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts +++ b/packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts @@ -10,6 +10,7 @@ import { resolveOrgScopedProjectIds, } from '@/lib/server/dashboard-route-helper' import { canAccessIndividualData, forbiddenByRole } from '@/lib/server/rbac' +import { csvField } from '@/lib/server/csv' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -71,11 +72,6 @@ function mapSessionItem(session: SessionWithInclude): SessionItem { } } -function csvField(value: string | number | null | undefined) { - if (value === null || value === undefined) return '' - const text = String(value) - return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text -} function buildSessionsCsv(sessions: SessionWithInclude[]) { const headers = [ diff --git a/packages/web/src/lib/server/csv.test.ts b/packages/web/src/lib/server/csv.test.ts new file mode 100644 index 00000000..0cd4c5c5 --- /dev/null +++ b/packages/web/src/lib/server/csv.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { csvField } from './csv' + +describe('csvField', () => { + it('handles null and undefined', () => { + expect(csvField(null)).toBe('') + expect(csvField(undefined)).toBe('') + }) + + it('preserves numbers', () => { + expect(csvField(123)).toBe('123') + expect(csvField(-456)).toBe('-456') + expect(csvField(0)).toBe('0') + }) + + it('prepends single quote to formula injection triggers', () => { + expect(csvField('=cmd')).toBe("'=cmd") + expect(csvField('+cmd')).toBe("'+cmd") + expect(csvField('-cmd')).toBe("'-cmd") + expect(csvField('@cmd')).toBe("'@cmd") + expect(csvField('\tcmd')).toBe("'\tcmd") + // \r and \n are handled by the next check and will be quoted + expect(csvField('\rcmd')).toBe('"\'\rcmd"') + expect(csvField('\ncmd')).toBe('"\'\ncmd"') + }) + + it('prepends single quote to full-width formula injection triggers', () => { + expect(csvField('\uff1dcmd')).toBe("'\uff1dcmd") + expect(csvField('\uff0bcmd')).toBe("'\uff0bcmd") + expect(csvField('\uff0dcmd')).toBe("'\uff0dcmd") + expect(csvField('\uff20cmd')).toBe("'\uff20cmd") + }) + + it('prepends single quote even with leading spaces', () => { + expect(csvField(' =cmd')).toBe("' =cmd") + expect(csvField(' -cmd')).toBe("' -cmd") + expect(csvField(' \tcmd')).toBe("' \tcmd") + }) + + it('escapes quotes and handles commas', () => { + expect(csvField('normal,string')).toBe('"normal,string"') + expect(csvField('string with "quotes"')).toBe('"string with ""quotes"""') + expect(csvField('multi\nline')).toBe('"multi\nline"') + }) + + it('does not prepend quote for normal strings', () => { + expect(csvField('normal')).toBe('normal') + expect(csvField(' normal')).toBe(' normal') + expect(csvField('123')).toBe('123') + }) + + it('combines formula injection prevention with quote escaping', () => { + expect(csvField('=cmd,test')).toBe('"\'=cmd,test"') + expect(csvField('=-"test"')).toBe('"\'=-""test"""') + }) +}) diff --git a/packages/web/src/lib/server/csv.ts b/packages/web/src/lib/server/csv.ts new file mode 100644 index 00000000..d258613e --- /dev/null +++ b/packages/web/src/lib/server/csv.ts @@ -0,0 +1,9 @@ +export function csvField(value: string | number | null | undefined) { + if (value === null || value === undefined) return '' + let text = String(value) + // Prevent CSV Formula Injection (Macro Injection) + if (typeof value !== 'number' && /^ *[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/.test(text)) { + text = "'" + text + } + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text +} From 8ded41374fcdc847a7a7253d828b4abb2be07305 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:06:50 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=A2=85=EC=86=8D=EC=84=B1=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(browserslist,=20deepmerge-ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 ++ pnpm-lock.yaml | 65 ++++++++++++++++++++++++++++---------------------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index d085ba62..1dc69f7d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0", "pnpm": { "overrides": { + "browserslist": "^4.28.9", + "deepmerge-ts": "^8.0.2", "@babel/core": "7.29.7", "esbuild": "0.28.1", "hono": "^4.12.34", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dfd315f..3e67acba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: + browserslist: ^4.28.9 + deepmerge-ts: ^8.0.2 '@babel/core': 7.29.7 esbuild: 0.28.1 hono: ^4.12.34 @@ -1939,8 +1941,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.33: - resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1962,8 +1964,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2006,6 +2008,9 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2278,9 +2283,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} - engines: {node: '>=16.0.0'} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} @@ -2369,8 +2374,8 @@ packages: effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} - electron-to-chromium@1.5.364: - resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3673,8 +3678,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.46: - resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} npm-run-path@4.0.1: @@ -4526,11 +4531,11 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: ^4.28.9 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4845,7 +4850,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -5659,7 +5664,7 @@ snapshots: '@prisma/config@6.19.3(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) - deepmerge-ts: 7.1.5 + deepmerge-ts: 8.0.2 effect: 3.21.0 empathic: 2.0.0 transitivePeerDependencies: @@ -6359,7 +6364,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.33: {} + baseline-browser-mapping@2.11.21: {} bcryptjs@2.4.3: {} @@ -6389,13 +6394,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.10.33 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.364 - node-releases: 2.0.46 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) bundle-name@4.1.0: dependencies: @@ -6443,6 +6448,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} chai@5.3.3: @@ -6660,7 +6667,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@8.0.2: {} deepmerge@4.3.1: {} @@ -6740,7 +6747,7 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - electron-to-chromium@1.5.364: {} + electron-to-chromium@1.5.422: {} emoji-regex@10.6.0: {} @@ -8406,7 +8413,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.46: {} + node-releases@2.0.54: {} npm-run-path@4.0.1: dependencies: @@ -8999,7 +9006,7 @@ snapshots: '@dotenvx/dotenvx': 1.70.0 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.2 + browserslist: 4.28.9 commander: 14.0.3 cosmiconfig: 9.0.1(typescript@5.9.3) dedent: 1.7.2 @@ -9491,9 +9498,9 @@ snapshots: until-async@3.0.2: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 From 76d93bd0a9c6b55638acc3285b2a3731e4380991 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 07:08:29 +0900 Subject: [PATCH 03/10] chore(security): restore canonical Sentinel guidance --- .jules/sentinel.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 54a164e3..7902c442 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -30,7 +30,3 @@ **Vulnerability:** Known high-severity vulnerabilities discovered by the audit in `js-yaml` and `nanoid` packages. **Learning:** Deeply nested dependencies (`js-yaml` via `eslint`, `nanoid` via `vitest/vite`) may expose the application to DoS or logic loops. **Prevention:** Use `pnpm.overrides` in the root `package.json` to enforce patched versions across all transitive paths in a pnpm workspace. -## 2025-02-18 - [Fix CSV Formula Injection (Macro Injection)] -**Vulnerability:** CSV 내보내기 기능에서 사용자 입력값(세션 타이틀, 프로젝트 이름 등)이 이스케이프 되지 않아 CSV Formula Injection (Macro Injection) 취약점이 발생할 수 있었습니다. -**Learning:** Excel, Google Sheets 등은 CSV 파일에서 `=, +, -, @, \t, \r, \n` 등의 문자로 시작하는 필드를 수식으로 해석하고 실행합니다. -**Prevention:** CSV로 데이터를 내보낼 때, 원시 `number` 타입이 아닌 문자열 필드가 수식 트리거 문자로 시작하는 경우, 값 앞에 작은따옴표(`'`)를 추가하여 일반 문자열로 처리되도록 해야 합니다. From cef6664011f45fc9fa0f1998726d84ee4b04d510 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 07:09:17 +0900 Subject: [PATCH 04/10] chore(security): remove unrelated dependency override churn --- package.json | 2 -- pnpm-lock.yaml | 65 ++++++++++++++++++++++---------------------------- 2 files changed, 29 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index 1dc69f7d..d085ba62 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,6 @@ "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0", "pnpm": { "overrides": { - "browserslist": "^4.28.9", - "deepmerge-ts": "^8.0.2", "@babel/core": "7.29.7", "esbuild": "0.28.1", "hono": "^4.12.34", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e67acba..6dfd315f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,6 @@ settings: excludeLinksFromLockfile: false overrides: - browserslist: ^4.28.9 - deepmerge-ts: ^8.0.2 '@babel/core': 7.29.7 esbuild: 0.28.1 hono: ^4.12.34 @@ -1941,8 +1939,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.21: - resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} engines: {node: '>=6.0.0'} hasBin: true @@ -1964,8 +1962,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.9: - resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2008,9 +2006,6 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} - caniuse-lite@1.0.30001810: - resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} - ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2283,9 +2278,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@8.0.2: - resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} - engines: {node: '>=16.9.0'} + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} @@ -2374,8 +2369,8 @@ packages: effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} - electron-to-chromium@1.5.422: - resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + electron-to-chromium@1.5.364: + resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3678,8 +3673,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.54: - resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} npm-run-path@4.0.1: @@ -4531,11 +4526,11 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - update-browserslist-db@1.3.2: - resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: - browserslist: ^4.28.9 + browserslist: '>= 4.21.0' uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4850,7 +4845,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.9 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -5664,7 +5659,7 @@ snapshots: '@prisma/config@6.19.3(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) - deepmerge-ts: 8.0.2 + deepmerge-ts: 7.1.5 effect: 3.21.0 empathic: 2.0.0 transitivePeerDependencies: @@ -6364,7 +6359,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.21: {} + baseline-browser-mapping@2.10.33: {} bcryptjs@2.4.3: {} @@ -6394,13 +6389,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.9: + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.11.21 - caniuse-lite: 1.0.30001810 - electron-to-chromium: 1.5.422 - node-releases: 2.0.54 - update-browserslist-db: 1.3.2(browserslist@4.28.9) + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.364 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) bundle-name@4.1.0: dependencies: @@ -6448,8 +6443,6 @@ snapshots: caniuse-lite@1.0.30001793: {} - caniuse-lite@1.0.30001810: {} - ccount@2.0.1: {} chai@5.3.3: @@ -6667,7 +6660,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@8.0.2: {} + deepmerge-ts@7.1.5: {} deepmerge@4.3.1: {} @@ -6747,7 +6740,7 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - electron-to-chromium@1.5.422: {} + electron-to-chromium@1.5.364: {} emoji-regex@10.6.0: {} @@ -8413,7 +8406,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.54: {} + node-releases@2.0.46: {} npm-run-path@4.0.1: dependencies: @@ -9006,7 +8999,7 @@ snapshots: '@dotenvx/dotenvx': 1.70.0 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.9 + browserslist: 4.28.2 commander: 14.0.3 cosmiconfig: 9.0.1(typescript@5.9.3) dedent: 1.7.2 @@ -9498,9 +9491,9 @@ snapshots: until-async@3.0.2: {} - update-browserslist-db@1.3.2(browserslist@4.28.9): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.9 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 From 5c86fef70af7964052c6b1a96d02f3e8edd5a444 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 07:11:02 +0900 Subject: [PATCH 05/10] docs(product): establish code-current technical gap baseline --- docs/product-technical-gap-baseline.md | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/product-technical-gap-baseline.md diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md new file mode 100644 index 00000000..d3e04b0b --- /dev/null +++ b/docs/product-technical-gap-baseline.md @@ -0,0 +1,64 @@ +# Argos product–technical gap baseline + +Status: Proposed / code-current baseline +Evidence generation: `cef6664011f45fc9fa0f1998726d84ee4b04d510` on PR #596, protected target `developmental@2fa92012bcf80acc1f921a4bafea76b3b1424b46` +Last reviewed: 2026-09-08 + +## Product boundary + +Argos is a team-level analytics product for Claude Code and Codex usage. The repository owns the CLI/hook collection path, the web dashboard, organization/project/session analytics, and the PostgreSQL-backed application data model. The monorepo is pnpm + Turborepo; `packages/web` is the Next.js dashboard, `packages/shared` contains shared schemas/types, and `packages/cli` publishes `argos-ai`. + +This repository owns Argos domain truth. Cross-product platform capabilities must be consumed through released contracts rather than copied source or mutable sibling heads. + +## Bounded contexts and invariants + +### Collection + +The CLI/hook path observes agent activity and sends events without blocking the observed developer workflow. ADR-005 and ADR-006 make fire-and-forget collection and tolerated event loss explicit trade-offs. The collection boundary must not turn observability failure into developer-workflow failure. + +### Organization and project access + +Organization membership, project identity, RBAC and individual-data access determine which session data a caller may read or export. Export code must preserve the same authorization scope as its source dashboard/API query; formatting must not become an authorization bypass. + +### Session analytics and export + +A session export is a projection of already-authorized session records. CSV serialization is an output-boundary responsibility: field quoting, delimiter containment and spreadsheet formula interpretation must be handled without mutating numeric semantics or allowing attacker-controlled text to become executable spreadsheet formulas. + +Invariant for text fields: no exported cell may begin, after CSV parsing, with a spreadsheet formula-triggering prefix from the supported target applications. Numeric values retain numeric representation. Quotes, delimiters, CR and LF remain valid data and must not create additional cells. + +## Current gap register + +| Gap | Buyer/control impact | Current evidence | Acceptance | +| --- | --- | --- | --- | +| CSV/spreadsheet formula interpretation | An authorized user can export attacker-controlled session/project text and later open it in a spreadsheet; an unsafe cell can be interpreted as a formula. | PR #596 extracts `csvField()` and tests `=`, `+`, `-`, `@`, tab, CR/LF, leading spaces, quote/comma containment and full-width trigger variants. Unrelated dependency override churn and branch-local Sentinel doctrine were removed in normal descendants. | Exact-head unit/type/lint/security gates must be terminal GREEN; raw CSV must preserve one field per input field; at least the spreadsheet applications used by the product's target environment must be checked with benign formula payloads. Excel save/re-open behavior must be included if Excel is a supported target. | +| Spreadsheet-specific mitigation portability | There is no universal CSV escaping scheme that behaves identically across Excel, LibreOffice and other consumers. | OWASP notes that quote/apostrophe escaping can be removed or become ineffective after Excel save/re-open, and describes application-dependent alternatives. | Document the supported spreadsheet target(s). Keep the serializer contract tied to observed target behavior; do not claim universal prevention from unit tests alone. | +| Canonical commercial gap baseline | This file did not exist on protected `developmental` before PR #596, so product/security work had no single code-current Gap projection. | Protected-base lookup returned no `docs/product-technical-gap-baseline.md`. | Maintain this file alongside PRD/ADR/code changes and record exact evidence generations rather than aspirational completion claims. | + +## PR #596 decision record + +Problem: the sessions CSV exporter serialized untrusted text using syntactic CSV quoting only. CSV quoting prevents delimiter breakout but does not by itself prevent a spreadsheet from interpreting a cell beginning with a formula trigger. + +Constraints: preserve authorized data, preserve raw numeric values, keep the deterministic formatting logic independently testable, avoid unrelated dependency/security-policy churn, and do not overstate spreadsheet-specific security without real application evidence. + +Alternatives considered: + +1. Leave CSV quoting unchanged. Rejected because spreadsheet formula interpretation is outside RFC-style CSV delimiter escaping. +2. Reject formula-like values. Rejected because session/project text is domain data and should not be discarded at export. +3. Prefix risky string cells with a text marker and retain CSV quote escaping. Selected as the current candidate because it preserves the original text visibly while keeping the formatting rule localized in a pure function. +4. Claim universal spreadsheet safety from the serializer unit tests. Rejected. Spreadsheet applications differ, and OWASP explicitly records Excel save/re-open caveats. + +Current code decision: `packages/web/src/lib/server/csv.ts` owns the deterministic serialization rule; the sessions route imports it instead of carrying a route-local formatter. `csv.test.ts` is the contract safety net. The PR remains non-release-ready until exact-head gates and application-level spreadsheet acceptance are available. + +## TRACEABILITY + +- Product identity and workflow: `README.md` +- Repository structure and operating rules: `CLAUDE.md`, `AGENTS.md` +- Architectural decisions: `docs/adr.md`, especially ADR-001 and ADR-005/006 +- Production export caller: `packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts` +- Serialization boundary: `packages/web/src/lib/server/csv.ts` +- Deterministic regression suite: `packages/web/src/lib/server/csv.test.ts` +- Repair lineage: PR #596, evidence generation `cef6664011f45fc9fa0f1998726d84ee4b04d510` + +## Reference + +OWASP Foundation. (n.d.). *CSV injection*. OWASP. Retrieved September 8, 2026, from https://owasp.org/www-community/attacks/CSV_Injection From 5936bcbd38b964c223fd0ef0909137d3a39413b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 07:23:40 +0900 Subject: [PATCH 06/10] refactor(export): document CSV serialization boundary --- packages/web/src/lib/server/csv.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/web/src/lib/server/csv.ts b/packages/web/src/lib/server/csv.ts index d258613e..ded7fc17 100644 --- a/packages/web/src/lib/server/csv.ts +++ b/packages/web/src/lib/server/csv.ts @@ -1,9 +1,21 @@ -export function csvField(value: string | number | null | undefined) { +const FORMULA_TRIGGER = /^ *[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/ + +/** + * Serialize one already-authorized dashboard value as a single CSV field. + * + * String values that spreadsheet software may interpret as formulas receive a + * leading text marker before RFC-style quote/delimiter escaping. Numeric input + * deliberately keeps its numeric representation. This is an export boundary, + * not a universal spreadsheet-security guarantee; target-application behavior + * is verified separately in the product technical gap baseline. + */ +export function csvField(value: string | number | null | undefined): string { if (value === null || value === undefined) return '' + let text = String(value) - // Prevent CSV Formula Injection (Macro Injection) - if (typeof value !== 'number' && /^ *[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/.test(text)) { - text = "'" + text + if (typeof value !== 'number' && FORMULA_TRIGGER.test(text)) { + text = `'${text}` } + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text } From 1324e1ed2771ab10dfc87ac34833cdb4b6220581 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 07:23:58 +0900 Subject: [PATCH 07/10] test(export): make CSV edge cases individually attributable --- packages/web/src/lib/server/csv.test.ts | 84 ++++++++++++++----------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/packages/web/src/lib/server/csv.test.ts b/packages/web/src/lib/server/csv.test.ts index 0cd4c5c5..74e5d666 100644 --- a/packages/web/src/lib/server/csv.test.ts +++ b/packages/web/src/lib/server/csv.test.ts @@ -2,55 +2,67 @@ import { describe, expect, it } from 'vitest' import { csvField } from './csv' describe('csvField', () => { - it('handles null and undefined', () => { - expect(csvField(null)).toBe('') - expect(csvField(undefined)).toBe('') + it.each([null, undefined])('%s 는 빈 필드로 직렬화한다', (value) => { + expect(csvField(value)).toBe('') }) - it('preserves numbers', () => { - expect(csvField(123)).toBe('123') - expect(csvField(-456)).toBe('-456') - expect(csvField(0)).toBe('0') + it.each([ + [123, '123'], + [-456, '-456'], + [0, '0'], + ] as const)('숫자 %s 의 숫자 표현을 보존한다', (value, expected) => { + expect(csvField(value)).toBe(expected) }) - it('prepends single quote to formula injection triggers', () => { - expect(csvField('=cmd')).toBe("'=cmd") - expect(csvField('+cmd')).toBe("'+cmd") - expect(csvField('-cmd')).toBe("'-cmd") - expect(csvField('@cmd')).toBe("'@cmd") - expect(csvField('\tcmd')).toBe("'\tcmd") - // \r and \n are handled by the next check and will be quoted - expect(csvField('\rcmd')).toBe('"\'\rcmd"') - expect(csvField('\ncmd')).toBe('"\'\ncmd"') + it.each([ + ['=cmd', "'=cmd"], + ['+cmd', "'+cmd"], + ['-cmd', "'-cmd"], + ['@cmd', "'@cmd"], + ['\tcmd', "'\tcmd"], + ['\rcmd', '"\'\rcmd"'], + ['\ncmd', '"\'\ncmd"'], + ] as const)('수식 시작 문자열 %j 앞에 텍스트 표식을 붙인다', (value, expected) => { + expect(csvField(value)).toBe(expected) }) - it('prepends single quote to full-width formula injection triggers', () => { - expect(csvField('\uff1dcmd')).toBe("'\uff1dcmd") - expect(csvField('\uff0bcmd')).toBe("'\uff0bcmd") - expect(csvField('\uff0dcmd')).toBe("'\uff0dcmd") - expect(csvField('\uff20cmd')).toBe("'\uff20cmd") + it.each([ + ['\uff1dcmd', "'\uff1dcmd"], + ['\uff0bcmd', "'\uff0bcmd"], + ['\uff0dcmd', "'\uff0dcmd"], + ['\uff20cmd', "'\uff20cmd"], + ] as const)('전각 수식 시작 문자 %j 를 텍스트로 직렬화한다', (value, expected) => { + expect(csvField(value)).toBe(expected) }) - it('prepends single quote even with leading spaces', () => { - expect(csvField(' =cmd')).toBe("' =cmd") - expect(csvField(' -cmd')).toBe("' -cmd") - expect(csvField(' \tcmd')).toBe("' \tcmd") + it.each([ + [' =cmd', "' =cmd"], + [' -cmd', "' -cmd"], + [' \tcmd', "' \tcmd"], + ] as const)('선행 공백 뒤 수식 시작 문자열 %j 도 텍스트로 직렬화한다', (value, expected) => { + expect(csvField(value)).toBe(expected) }) - it('escapes quotes and handles commas', () => { - expect(csvField('normal,string')).toBe('"normal,string"') - expect(csvField('string with "quotes"')).toBe('"string with ""quotes"""') - expect(csvField('multi\nline')).toBe('"multi\nline"') + it.each([ + ['normal,string', '"normal,string"'], + ['string with "quotes"', '"string with ""quotes"""'], + ['multi\nline', '"multi\nline"'], + ] as const)('구분자나 개행이 있는 %j 를 하나의 CSV 필드로 유지한다', (value, expected) => { + expect(csvField(value)).toBe(expected) }) - it('does not prepend quote for normal strings', () => { - expect(csvField('normal')).toBe('normal') - expect(csvField(' normal')).toBe(' normal') - expect(csvField('123')).toBe('123') + it.each([ + ['normal', 'normal'], + [' normal', ' normal'], + ['123', '123'], + ] as const)('일반 문자열 %j 를 불필요하게 변형하지 않는다', (value, expected) => { + expect(csvField(value)).toBe(expected) }) - it('combines formula injection prevention with quote escaping', () => { - expect(csvField('=cmd,test')).toBe('"\'=cmd,test"') - expect(csvField('=-"test"')).toBe('"\'=-""test"""') + it.each([ + ['=cmd,test', '"\'=cmd,test"'], + ['=-"test"', '"\'=-""test"""'], + ] as const)('수식 방어와 CSV quote escaping 을 함께 적용한다: %j', (value, expected) => { + expect(csvField(value)).toBe(expected) }) }) From d36827ccc0c3c860b293f68127120695dabf2a3d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:46:57 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=A2=85=EC=86=8D=EC=84=B1=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(browserslist,=20deepmerge-ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 4 ++ docs/product-technical-gap-baseline.md | 64 ------------------- package.json | 2 + packages/web/src/lib/server/csv.test.ts | 84 +++++++++++-------------- packages/web/src/lib/server/csv.ts | 20 ++---- pnpm-lock.yaml | 65 ++++++++++--------- 6 files changed, 82 insertions(+), 157 deletions(-) delete mode 100644 docs/product-technical-gap-baseline.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 7902c442..54a164e3 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -30,3 +30,7 @@ **Vulnerability:** Known high-severity vulnerabilities discovered by the audit in `js-yaml` and `nanoid` packages. **Learning:** Deeply nested dependencies (`js-yaml` via `eslint`, `nanoid` via `vitest/vite`) may expose the application to DoS or logic loops. **Prevention:** Use `pnpm.overrides` in the root `package.json` to enforce patched versions across all transitive paths in a pnpm workspace. +## 2025-02-18 - [Fix CSV Formula Injection (Macro Injection)] +**Vulnerability:** CSV 내보내기 기능에서 사용자 입력값(세션 타이틀, 프로젝트 이름 등)이 이스케이프 되지 않아 CSV Formula Injection (Macro Injection) 취약점이 발생할 수 있었습니다. +**Learning:** Excel, Google Sheets 등은 CSV 파일에서 `=, +, -, @, \t, \r, \n` 등의 문자로 시작하는 필드를 수식으로 해석하고 실행합니다. +**Prevention:** CSV로 데이터를 내보낼 때, 원시 `number` 타입이 아닌 문자열 필드가 수식 트리거 문자로 시작하는 경우, 값 앞에 작은따옴표(`'`)를 추가하여 일반 문자열로 처리되도록 해야 합니다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md deleted file mode 100644 index d3e04b0b..00000000 --- a/docs/product-technical-gap-baseline.md +++ /dev/null @@ -1,64 +0,0 @@ -# Argos product–technical gap baseline - -Status: Proposed / code-current baseline -Evidence generation: `cef6664011f45fc9fa0f1998726d84ee4b04d510` on PR #596, protected target `developmental@2fa92012bcf80acc1f921a4bafea76b3b1424b46` -Last reviewed: 2026-09-08 - -## Product boundary - -Argos is a team-level analytics product for Claude Code and Codex usage. The repository owns the CLI/hook collection path, the web dashboard, organization/project/session analytics, and the PostgreSQL-backed application data model. The monorepo is pnpm + Turborepo; `packages/web` is the Next.js dashboard, `packages/shared` contains shared schemas/types, and `packages/cli` publishes `argos-ai`. - -This repository owns Argos domain truth. Cross-product platform capabilities must be consumed through released contracts rather than copied source or mutable sibling heads. - -## Bounded contexts and invariants - -### Collection - -The CLI/hook path observes agent activity and sends events without blocking the observed developer workflow. ADR-005 and ADR-006 make fire-and-forget collection and tolerated event loss explicit trade-offs. The collection boundary must not turn observability failure into developer-workflow failure. - -### Organization and project access - -Organization membership, project identity, RBAC and individual-data access determine which session data a caller may read or export. Export code must preserve the same authorization scope as its source dashboard/API query; formatting must not become an authorization bypass. - -### Session analytics and export - -A session export is a projection of already-authorized session records. CSV serialization is an output-boundary responsibility: field quoting, delimiter containment and spreadsheet formula interpretation must be handled without mutating numeric semantics or allowing attacker-controlled text to become executable spreadsheet formulas. - -Invariant for text fields: no exported cell may begin, after CSV parsing, with a spreadsheet formula-triggering prefix from the supported target applications. Numeric values retain numeric representation. Quotes, delimiters, CR and LF remain valid data and must not create additional cells. - -## Current gap register - -| Gap | Buyer/control impact | Current evidence | Acceptance | -| --- | --- | --- | --- | -| CSV/spreadsheet formula interpretation | An authorized user can export attacker-controlled session/project text and later open it in a spreadsheet; an unsafe cell can be interpreted as a formula. | PR #596 extracts `csvField()` and tests `=`, `+`, `-`, `@`, tab, CR/LF, leading spaces, quote/comma containment and full-width trigger variants. Unrelated dependency override churn and branch-local Sentinel doctrine were removed in normal descendants. | Exact-head unit/type/lint/security gates must be terminal GREEN; raw CSV must preserve one field per input field; at least the spreadsheet applications used by the product's target environment must be checked with benign formula payloads. Excel save/re-open behavior must be included if Excel is a supported target. | -| Spreadsheet-specific mitigation portability | There is no universal CSV escaping scheme that behaves identically across Excel, LibreOffice and other consumers. | OWASP notes that quote/apostrophe escaping can be removed or become ineffective after Excel save/re-open, and describes application-dependent alternatives. | Document the supported spreadsheet target(s). Keep the serializer contract tied to observed target behavior; do not claim universal prevention from unit tests alone. | -| Canonical commercial gap baseline | This file did not exist on protected `developmental` before PR #596, so product/security work had no single code-current Gap projection. | Protected-base lookup returned no `docs/product-technical-gap-baseline.md`. | Maintain this file alongside PRD/ADR/code changes and record exact evidence generations rather than aspirational completion claims. | - -## PR #596 decision record - -Problem: the sessions CSV exporter serialized untrusted text using syntactic CSV quoting only. CSV quoting prevents delimiter breakout but does not by itself prevent a spreadsheet from interpreting a cell beginning with a formula trigger. - -Constraints: preserve authorized data, preserve raw numeric values, keep the deterministic formatting logic independently testable, avoid unrelated dependency/security-policy churn, and do not overstate spreadsheet-specific security without real application evidence. - -Alternatives considered: - -1. Leave CSV quoting unchanged. Rejected because spreadsheet formula interpretation is outside RFC-style CSV delimiter escaping. -2. Reject formula-like values. Rejected because session/project text is domain data and should not be discarded at export. -3. Prefix risky string cells with a text marker and retain CSV quote escaping. Selected as the current candidate because it preserves the original text visibly while keeping the formatting rule localized in a pure function. -4. Claim universal spreadsheet safety from the serializer unit tests. Rejected. Spreadsheet applications differ, and OWASP explicitly records Excel save/re-open caveats. - -Current code decision: `packages/web/src/lib/server/csv.ts` owns the deterministic serialization rule; the sessions route imports it instead of carrying a route-local formatter. `csv.test.ts` is the contract safety net. The PR remains non-release-ready until exact-head gates and application-level spreadsheet acceptance are available. - -## TRACEABILITY - -- Product identity and workflow: `README.md` -- Repository structure and operating rules: `CLAUDE.md`, `AGENTS.md` -- Architectural decisions: `docs/adr.md`, especially ADR-001 and ADR-005/006 -- Production export caller: `packages/web/src/app/api/orgs/[orgSlug]/dashboard/sessions/route.ts` -- Serialization boundary: `packages/web/src/lib/server/csv.ts` -- Deterministic regression suite: `packages/web/src/lib/server/csv.test.ts` -- Repair lineage: PR #596, evidence generation `cef6664011f45fc9fa0f1998726d84ee4b04d510` - -## Reference - -OWASP Foundation. (n.d.). *CSV injection*. OWASP. Retrieved September 8, 2026, from https://owasp.org/www-community/attacks/CSV_Injection diff --git a/package.json b/package.json index d085ba62..1dc69f7d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0", "pnpm": { "overrides": { + "browserslist": "^4.28.9", + "deepmerge-ts": "^8.0.2", "@babel/core": "7.29.7", "esbuild": "0.28.1", "hono": "^4.12.34", diff --git a/packages/web/src/lib/server/csv.test.ts b/packages/web/src/lib/server/csv.test.ts index 74e5d666..0cd4c5c5 100644 --- a/packages/web/src/lib/server/csv.test.ts +++ b/packages/web/src/lib/server/csv.test.ts @@ -2,67 +2,55 @@ import { describe, expect, it } from 'vitest' import { csvField } from './csv' describe('csvField', () => { - it.each([null, undefined])('%s 는 빈 필드로 직렬화한다', (value) => { - expect(csvField(value)).toBe('') + it('handles null and undefined', () => { + expect(csvField(null)).toBe('') + expect(csvField(undefined)).toBe('') }) - it.each([ - [123, '123'], - [-456, '-456'], - [0, '0'], - ] as const)('숫자 %s 의 숫자 표현을 보존한다', (value, expected) => { - expect(csvField(value)).toBe(expected) + it('preserves numbers', () => { + expect(csvField(123)).toBe('123') + expect(csvField(-456)).toBe('-456') + expect(csvField(0)).toBe('0') }) - it.each([ - ['=cmd', "'=cmd"], - ['+cmd', "'+cmd"], - ['-cmd', "'-cmd"], - ['@cmd', "'@cmd"], - ['\tcmd', "'\tcmd"], - ['\rcmd', '"\'\rcmd"'], - ['\ncmd', '"\'\ncmd"'], - ] as const)('수식 시작 문자열 %j 앞에 텍스트 표식을 붙인다', (value, expected) => { - expect(csvField(value)).toBe(expected) + it('prepends single quote to formula injection triggers', () => { + expect(csvField('=cmd')).toBe("'=cmd") + expect(csvField('+cmd')).toBe("'+cmd") + expect(csvField('-cmd')).toBe("'-cmd") + expect(csvField('@cmd')).toBe("'@cmd") + expect(csvField('\tcmd')).toBe("'\tcmd") + // \r and \n are handled by the next check and will be quoted + expect(csvField('\rcmd')).toBe('"\'\rcmd"') + expect(csvField('\ncmd')).toBe('"\'\ncmd"') }) - it.each([ - ['\uff1dcmd', "'\uff1dcmd"], - ['\uff0bcmd', "'\uff0bcmd"], - ['\uff0dcmd', "'\uff0dcmd"], - ['\uff20cmd', "'\uff20cmd"], - ] as const)('전각 수식 시작 문자 %j 를 텍스트로 직렬화한다', (value, expected) => { - expect(csvField(value)).toBe(expected) + it('prepends single quote to full-width formula injection triggers', () => { + expect(csvField('\uff1dcmd')).toBe("'\uff1dcmd") + expect(csvField('\uff0bcmd')).toBe("'\uff0bcmd") + expect(csvField('\uff0dcmd')).toBe("'\uff0dcmd") + expect(csvField('\uff20cmd')).toBe("'\uff20cmd") }) - it.each([ - [' =cmd', "' =cmd"], - [' -cmd', "' -cmd"], - [' \tcmd', "' \tcmd"], - ] as const)('선행 공백 뒤 수식 시작 문자열 %j 도 텍스트로 직렬화한다', (value, expected) => { - expect(csvField(value)).toBe(expected) + it('prepends single quote even with leading spaces', () => { + expect(csvField(' =cmd')).toBe("' =cmd") + expect(csvField(' -cmd')).toBe("' -cmd") + expect(csvField(' \tcmd')).toBe("' \tcmd") }) - it.each([ - ['normal,string', '"normal,string"'], - ['string with "quotes"', '"string with ""quotes"""'], - ['multi\nline', '"multi\nline"'], - ] as const)('구분자나 개행이 있는 %j 를 하나의 CSV 필드로 유지한다', (value, expected) => { - expect(csvField(value)).toBe(expected) + it('escapes quotes and handles commas', () => { + expect(csvField('normal,string')).toBe('"normal,string"') + expect(csvField('string with "quotes"')).toBe('"string with ""quotes"""') + expect(csvField('multi\nline')).toBe('"multi\nline"') }) - it.each([ - ['normal', 'normal'], - [' normal', ' normal'], - ['123', '123'], - ] as const)('일반 문자열 %j 를 불필요하게 변형하지 않는다', (value, expected) => { - expect(csvField(value)).toBe(expected) + it('does not prepend quote for normal strings', () => { + expect(csvField('normal')).toBe('normal') + expect(csvField(' normal')).toBe(' normal') + expect(csvField('123')).toBe('123') }) - it.each([ - ['=cmd,test', '"\'=cmd,test"'], - ['=-"test"', '"\'=-""test"""'], - ] as const)('수식 방어와 CSV quote escaping 을 함께 적용한다: %j', (value, expected) => { - expect(csvField(value)).toBe(expected) + it('combines formula injection prevention with quote escaping', () => { + expect(csvField('=cmd,test')).toBe('"\'=cmd,test"') + expect(csvField('=-"test"')).toBe('"\'=-""test"""') }) }) diff --git a/packages/web/src/lib/server/csv.ts b/packages/web/src/lib/server/csv.ts index ded7fc17..d258613e 100644 --- a/packages/web/src/lib/server/csv.ts +++ b/packages/web/src/lib/server/csv.ts @@ -1,21 +1,9 @@ -const FORMULA_TRIGGER = /^ *[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/ - -/** - * Serialize one already-authorized dashboard value as a single CSV field. - * - * String values that spreadsheet software may interpret as formulas receive a - * leading text marker before RFC-style quote/delimiter escaping. Numeric input - * deliberately keeps its numeric representation. This is an export boundary, - * not a universal spreadsheet-security guarantee; target-application behavior - * is verified separately in the product technical gap baseline. - */ -export function csvField(value: string | number | null | undefined): string { +export function csvField(value: string | number | null | undefined) { if (value === null || value === undefined) return '' - let text = String(value) - if (typeof value !== 'number' && FORMULA_TRIGGER.test(text)) { - text = `'${text}` + // Prevent CSV Formula Injection (Macro Injection) + if (typeof value !== 'number' && /^ *[=+\-@\t\r\n\uff1d\uff0b\uff0d\uff20]/.test(text)) { + text = "'" + text } - return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dfd315f..3e67acba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: + browserslist: ^4.28.9 + deepmerge-ts: ^8.0.2 '@babel/core': 7.29.7 esbuild: 0.28.1 hono: ^4.12.34 @@ -1939,8 +1941,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.33: - resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1962,8 +1964,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2006,6 +2008,9 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2278,9 +2283,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge-ts@7.1.5: - resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} - engines: {node: '>=16.0.0'} + deepmerge-ts@8.0.2: + resolution: {integrity: sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==} + engines: {node: '>=16.9.0'} deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} @@ -2369,8 +2374,8 @@ packages: effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} - electron-to-chromium@1.5.364: - resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3673,8 +3678,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-releases@2.0.46: - resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} npm-run-path@4.0.1: @@ -4526,11 +4531,11 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: - browserslist: '>= 4.21.0' + browserslist: ^4.28.9 uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -4845,7 +4850,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.9 lru-cache: 5.1.1 semver: 6.3.1 @@ -5659,7 +5664,7 @@ snapshots: '@prisma/config@6.19.3(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) - deepmerge-ts: 7.1.5 + deepmerge-ts: 8.0.2 effect: 3.21.0 empathic: 2.0.0 transitivePeerDependencies: @@ -6359,7 +6364,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.33: {} + baseline-browser-mapping@2.11.21: {} bcryptjs@2.4.3: {} @@ -6389,13 +6394,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.10.33 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.364 - node-releases: 2.0.46 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) bundle-name@4.1.0: dependencies: @@ -6443,6 +6448,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} chai@5.3.3: @@ -6660,7 +6667,7 @@ snapshots: deep-is@0.1.4: {} - deepmerge-ts@7.1.5: {} + deepmerge-ts@8.0.2: {} deepmerge@4.3.1: {} @@ -6740,7 +6747,7 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - electron-to-chromium@1.5.364: {} + electron-to-chromium@1.5.422: {} emoji-regex@10.6.0: {} @@ -8406,7 +8413,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-releases@2.0.46: {} + node-releases@2.0.54: {} npm-run-path@4.0.1: dependencies: @@ -8999,7 +9006,7 @@ snapshots: '@dotenvx/dotenvx': 1.70.0 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.2 + browserslist: 4.28.9 commander: 14.0.3 cosmiconfig: 9.0.1(typescript@5.9.3) dedent: 1.7.2 @@ -9491,9 +9498,9 @@ snapshots: until-async@3.0.2: {} - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 From 8e6b689041beb3c9005e9dd16d5a550b4e37186c Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:34:15 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=A2=85=EC=86=8D=EC=84=B1=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(browserslist,=20deepmerge-ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From b63a83ec756cede32984d71375f521578f09ccd3 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:52:51 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=EB=B3=B4=EC=95=88:=20=EC=B7=A8=EC=95=BD?= =?UTF-8?q?=EC=A0=90=20=EC=A2=85=EC=86=8D=EC=84=B1=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(browserslist,=20deepmerge-ts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit