← ubon
src/__tests__/react-security-scanner.test.ts
11.4 KB
Download file
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { ReactSecurityScanner } from '../scanners/react-security-scanner';
describe('ReactSecurityScanner', () => {
const tmp = join(process.cwd(), '.tmp-react-tests');
beforeAll(() => {
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
mkdirSync(tmp, { recursive: true });
});
afterAll(() => {
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
});
describe('TAILWIND001: Dynamic className with Unvalidated Input', () => {
it('detects className with variable without validation', async () => {
const file = join(tmp, 'dynamic-class.tsx');
writeFileSync(file, `
import React from 'react';
export function Button({ variant }: { variant: string }) {
return <button className={variant}>Click me</button>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r => r.ruleId === 'TAILWIND001');
expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
});
it('detects className with props variable', async () => {
const file = join(tmp, 'props-class.tsx');
writeFileSync(file, `
export function Card({ cardClass }) {
return <div className={cardClass}>Content</div>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('props-class.tsx')
);
expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
});
it('detects className with template literal and variable', async () => {
const file = join(tmp, 'template-class.tsx');
writeFileSync(file, `
export function Alert({ type }) {
return <div className={\`alert alert-\${type}\`}>Message</div>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('template-class.tsx')
);
expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
});
it('detects className with user input in template literal', async () => {
const file = join(tmp, 'user-input-class.tsx');
writeFileSync(file, `
export function CustomBox({ userStyle }) {
return <div className={\`box \${userStyle}\`}>Content</div>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('user-input-class.tsx')
);
expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
});
it('detects className with props in template literal', async () => {
const file = join(tmp, 'props-template.tsx');
writeFileSync(file, `
export function Badge({ props }) {
return <span className={\`badge \${props.color}\`}>New</span>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('props-template.tsx')
);
expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
});
it('detects className with string concatenation', async () => {
const file = join(tmp, 'concat-class.tsx');
writeFileSync(file, `
export function Panel({ theme }) {
return <div className={'panel ' + theme}>Content</div>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('concat-class.tsx')
);
expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
});
it('detects className with variable concatenation', async () => {
const file = join(tmp, 'var-concat.tsx');
writeFileSync(file, `
export function Box({ size }) {
return <div className={'box-' + size}>Content</div>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('var-concat.tsx')
);
expect(tailwind001Results.length).toBeGreaterThanOrEqual(1);
});
it('does not flag className with validation', async () => {
const file = join(tmp, 'validated-class.tsx');
writeFileSync(file, `
const allowedVariants = ['primary', 'secondary', 'danger'];
export function Button({ variant }) {
const validVariant = allowedVariants.includes(variant) ? variant : 'primary';
return <button className={validVariant}>Click me</button>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('validated-class.tsx')
);
expect(tailwind001Results.length).toBe(0);
});
it('does not flag className with switch statement', async () => {
const file = join(tmp, 'switch-class.tsx');
writeFileSync(file, `
export function Alert({ type }) {
let alertClass;
switch (type) {
case 'success': alertClass = 'bg-green-500'; break;
case 'error': alertClass = 'bg-red-500'; break;
default: alertClass = 'bg-gray-500';
}
return <div className={alertClass}>Message</div>;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('switch-class.tsx')
);
expect(tailwind001Results.length).toBe(0);
});
it('does not flag className with clsx/classnames utility', async () => {
const file = join(tmp, 'clsx-class.tsx');
writeFileSync(file, `
import clsx from 'clsx';
export function Button({ isActive, variant }) {
return (
<button className={clsx('btn', {
'btn-active': isActive,
'btn-primary': variant === 'primary'
})}>
Click me
</button>
);
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('clsx-class.tsx')
);
expect(tailwind001Results.length).toBe(0);
});
it('does not flag className with cn utility', async () => {
const file = join(tmp, 'cn-class.tsx');
writeFileSync(file, `
import { cn } from '@/lib/utils';
export function Card({ variant, className }) {
return (
<div className={cn('card', variant === 'dark' && 'card-dark', className)}>
Content
</div>
);
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('cn-class.tsx')
);
expect(tailwind001Results.length).toBe(0);
});
it('does not flag static className strings', async () => {
const file = join(tmp, 'static-class.tsx');
writeFileSync(file, `
export function Header() {
return (
<header className="bg-white shadow-md p-4">
<h1 className="text-2xl font-bold">Title</h1>
</header>
);
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('static-class.tsx')
);
expect(tailwind001Results.length).toBe(0);
});
it('handles multiple className violations in one file', async () => {
const file = join(tmp, 'multiple-violations.tsx');
writeFileSync(file, `
export function Dashboard({ theme, userStyle, cardClass }) {
return (
<div className={theme}>
<div className={\`card \${userStyle}\`}>
<div className={'header-' + cardClass}>
Header
</div>
</div>
</div>
);
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('multiple-violations.tsx')
);
expect(tailwind001Results.length).toBeGreaterThanOrEqual(3);
});
it('does not flag common safe variable names', async () => {
const file = join(tmp, 'safe-names.tsx');
writeFileSync(file, `
export function Component() {
const className = 'btn btn-primary';
const classes = 'card shadow-lg';
return (
<div>
<button className={className}>Button</button>
<div className={classes}>Card</div>
</div>
);
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const tailwind001Results = results.filter(r =>
r.ruleId === 'TAILWIND001' && r.file?.includes('safe-names.tsx')
);
expect(tailwind001Results.length).toBe(0);
});
});
describe('Integration Tests', () => {
it('does not scan plain .ts files (no JSX surface)', async () => {
const tsFile = join(tmp, 'utils.ts');
writeFileSync(tsFile, `
export function getClassName(variant: string) {
return variant;
}
`);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const utilsResults = results.filter(r => r.file?.includes('utils.ts'));
expect(utilsResults.length).toBe(0);
});
it('scans .jsx and .tsx files', async () => {
const jsxFile = join(tmp, 'comp.jsx');
const tsxFile = join(tmp, 'comp.tsx');
const code = `
export function Component({ style }) {
return <div className={style}>Content</div>;
}
`;
writeFileSync(jsxFile, code);
writeFileSync(tsxFile, code);
const scanner = new ReactSecurityScanner();
const results = await scanner.scan({ directory: tmp });
const compResults = results.filter(r => r.file?.startsWith('comp.'));
expect(compResults.length).toBeGreaterThanOrEqual(2);
});
});
});