From a5562d44837799e2896f386b609b43b9899c30a2 Mon Sep 17 00:00:00 2001 From: Dushyant Acharya Date: Sun, 20 Sep 2026 00:28:17 +0530 Subject: [PATCH 1/2] fix(analytics): add RFC 4180 CSV cell escaping and formatting for CSV exports --- src/services/analytics.exportCSV.test.js | 33 ++++++++++++++++++++++++ src/services/analytics.js | 21 ++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/services/analytics.exportCSV.test.js diff --git a/src/services/analytics.exportCSV.test.js b/src/services/analytics.exportCSV.test.js new file mode 100644 index 0000000..32c3b2d --- /dev/null +++ b/src/services/analytics.exportCSV.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { escapeCSVCell, formatCSVRow } from './analytics' + +describe('escapeCSVCell', () => { + it('returns empty string for null and undefined', () => { + expect(escapeCSVCell(null)).toBe('') + expect(escapeCSVCell(undefined)).toBe('') + }) + + it('leaves simple text and numbers untouched', () => { + expect(escapeCSVCell('hello')).toBe('hello') + expect(escapeCSVCell(123)).toBe('123') + }) + + it('escapes cells containing commas', () => { + expect(escapeCSVCell('JavaScript, TypeScript')).toBe('"JavaScript, TypeScript"') + }) + + it('escapes cells containing double quotes', () => { + expect(escapeCSVCell('Repo "Awesome"')).toBe('"Repo ""Awesome"""') + }) + + it('escapes cells containing newlines', () => { + expect(escapeCSVCell('Line 1\nLine 2')).toBe('"Line 1\nLine 2"') + }) +}) + +describe('formatCSVRow', () => { + it('formats a row into a properly escaped CSV string', () => { + const row = ['OrgExplorer', 'AOSSIE, Inc.', 42, 'Hello "World"'] + expect(formatCSVRow(row)).toBe('OrgExplorer,"AOSSIE, Inc.",42,"Hello ""World"""') + }) +}) diff --git a/src/services/analytics.js b/src/services/analytics.js index 2ca5969..50d6d7f 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -18,7 +18,7 @@ export function computeActivityClassification(repo) { return 'Hibernating' } -// Bus Factor +// Bus Factor export function computeBusFactor(contributors = []) { if (!contributors.length) return { factor: 0, risk: 'unknown' } const total = contributors.reduce((s, c) => s + c.contributions, 0) @@ -150,6 +150,19 @@ export function buildTimeSeries(issues = [], granularity = 'monthly') { .slice(-12) } +export function escapeCSVCell(val) { + if (val === null || val === undefined) return '' + const str = String(val) + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export function formatCSVRow(row) { + return row.map(escapeCSVCell).join(',') +} + // CSV Export function download(content, filename, type = 'text/csv') { const blob = new Blob([content], { type }) @@ -162,19 +175,19 @@ function download(content, filename, type = 'text/csv') { export function exportReposCSV(repos) { const header = ['Repository','Org','Stars','Forks','Open Issues','Health Score','Activity Classification','Language','Last Active'] const rows = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.activityClassification, r.language || 'N/A', r.pushed_at?.slice(0, 10)]) - download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-repos.csv') + download([header, ...rows].map(formatCSVRow).join('\r\n'), 'orgexplorer-repos.csv') } export function exportContributorsCSV(contributors) { const header = ['Login','Total Contributions','Repos','Orgs','Last Active','Connector','Cross-Org'] const rows = contributors.map(c => [c.login, c.totalContribs, c.repos.length, c.orgs.length, c.lastActive?.slice(0, 10) || '', c.isConnector, c.isCrossOrg]) - download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-contributors.csv') + download([header, ...rows].map(formatCSVRow).join('\r\n'), 'orgexplorer-contributors.csv') } export function exportTrendsCSV(series) { const header = ['Date','PRs Created','PRs Merged','PRs Closed','Issues Created','Issues Closed'] const rows = series.map(s => [s.date, s.prs_created, s.prs_merged, s.prs_closed, s.issues_created, s.issues_closed]) - download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-trends.csv') + download([header, ...rows].map(formatCSVRow).join('\r\n'), 'orgexplorer-trends.csv') } export function getTopRepositories(repos, limit = 10) { From 66b0eb1b94604df84a2e065553a91e4fd5d62039 Mon Sep 17 00:00:00 2001 From: Dushyant Acharya Date: Sun, 20 Sep 2026 07:22:56 +0530 Subject: [PATCH 2/2] fix(analytics): neutralize CSV injection formula prefixes and add export integration tests --- src/services/analytics.exportCSV.test.js | 108 ++++++++++++++++++++++- src/services/analytics.js | 5 +- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/services/analytics.exportCSV.test.js b/src/services/analytics.exportCSV.test.js index 32c3b2d..d3acc63 100644 --- a/src/services/analytics.exportCSV.test.js +++ b/src/services/analytics.exportCSV.test.js @@ -1,5 +1,11 @@ -import { describe, it, expect } from 'vitest' -import { escapeCSVCell, formatCSVRow } from './analytics' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + escapeCSVCell, + formatCSVRow, + exportReposCSV, + exportContributorsCSV, + exportTrendsCSV +} from './analytics' describe('escapeCSVCell', () => { it('returns empty string for null and undefined', () => { @@ -7,11 +13,23 @@ describe('escapeCSVCell', () => { expect(escapeCSVCell(undefined)).toBe('') }) - it('leaves simple text and numbers untouched', () => { + it('leaves simple text and positive numbers untouched', () => { expect(escapeCSVCell('hello')).toBe('hello') expect(escapeCSVCell(123)).toBe('123') }) + it('leaves negative numbers (type number) untouched', () => { + expect(escapeCSVCell(-42)).toBe('-42') + }) + + it('neutralizes formula injection characters for string values', () => { + expect(escapeCSVCell('-1E3')).toBe("'-1E3") + expect(escapeCSVCell('=SUM(1,2)')).toBe('"\'=SUM(1,2)"') + expect(escapeCSVCell('+100')).toBe("'+100") + expect(escapeCSVCell('@admin')).toBe("'@admin") + expect(escapeCSVCell('\tTab')).toBe("'\tTab") + }) + it('escapes cells containing commas', () => { expect(escapeCSVCell('JavaScript, TypeScript')).toBe('"JavaScript, TypeScript"') }) @@ -31,3 +49,87 @@ describe('formatCSVRow', () => { expect(formatCSVRow(row)).toBe('OrgExplorer,"AOSSIE, Inc.",42,"Hello ""World"""') }) }) + +describe('CSV Integration Exports', () => { + let blobContents = [] + + beforeEach(() => { + blobContents = [] + class MockBlob { + constructor(content) { + blobContents.push(content[0]) + } + } + vi.stubGlobal('Blob', MockBlob) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:http://localhost/dummy') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + it('exportReposCSV generates valid CSV content with CRLF row separators', () => { + const repos = [ + { + name: '-1E3-repo', + orgLogin: 'facebook', + stargazers_count: 100, + forks_count: 20, + open_issues_count: 5, + healthScore: 85, + activityClassification: 'Thriving', + language: 'C++, Rust', + pushed_at: '2026-09-20T00:00:00Z' + } + ] + + exportReposCSV(repos) + + expect(blobContents.length).toBe(1) + const csv = blobContents[0] + const lines = csv.split('\r\n') + expect(lines[0]).toBe('Repository,Org,Stars,Forks,Open Issues,Health Score,Activity Classification,Language,Last Active') + expect(lines[1]).toBe("'-1E3-repo,facebook,100,20,5,85,Thriving,\"C++, Rust\",2026-09-20") + }) + + it('exportContributorsCSV generates valid CSV content with CRLF row separators', () => { + const contributors = [ + { + login: '@contributor', + totalContribs: 50, + repos: ['repo1', 'repo2'], + orgs: ['org1'], + lastActive: '2026-09-19T00:00:00Z', + isConnector: true, + isCrossOrg: false + } + ] + + exportContributorsCSV(contributors) + + expect(blobContents.length).toBe(1) + const csv = blobContents[0] + const lines = csv.split('\r\n') + expect(lines[0]).toBe('Login,Total Contributions,Repos,Orgs,Last Active,Connector,Cross-Org') + expect(lines[1]).toBe("'@contributor,50,2,1,2026-09-19,true,false") + }) + + it('exportTrendsCSV generates valid CSV content with CRLF row separators', () => { + const series = [ + { + date: '2026-09', + prs_created: 10, + prs_merged: 8, + prs_closed: 2, + issues_created: 5, + issues_closed: 4 + } + ] + + exportTrendsCSV(series) + + expect(blobContents.length).toBe(1) + const csv = blobContents[0] + const lines = csv.split('\r\n') + expect(lines[0]).toBe('Date,PRs Created,PRs Merged,PRs Closed,Issues Created,Issues Closed') + expect(lines[1]).toBe('2026-09,10,8,2,5,4') + }) +}) diff --git a/src/services/analytics.js b/src/services/analytics.js index 50d6d7f..6f865a1 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -152,7 +152,10 @@ export function buildTimeSeries(issues = [], granularity = 'monthly') { export function escapeCSVCell(val) { if (val === null || val === undefined) return '' - const str = String(val) + let str = String(val) + if (typeof val === 'string' && /^[=+\-@\t\r]/.test(str)) { + str = `'${str}` + } if (/[",\n\r]/.test(str)) { return `"${str.replace(/"/g, '""')}"` }