ubon
Version:
Security scanner for AI-generated React/Next.js and Python apps. Catches hardcoded secrets, accessibility issues, and vulnerabilities that traditional linters miss.
224 lines (223 loc) • 10.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.AccessibilityScanner = void 0;
const glob_1 = require("glob");
const fs_1 = require("fs");
class AccessibilityScanner {
constructor() {
this.name = 'Accessibility Scanner';
this.patterns = [
{
ruleId: 'A11Y001',
confidence: 0.8,
pattern: /<img(?![^>]*alt=)/gi,
message: 'Image without alt attribute',
severity: 'medium',
fix: 'Add descriptive alt attribute to images'
},
{
ruleId: 'NEXT005',
confidence: 0.6,
pattern: /<img[^>]*src\s*=\s*["']https?:\/\//gi,
message: 'External <img> used in Next.js app (consider next/image)',
severity: 'low',
fix: 'Use next/image for external sources with proper config'
},
{
ruleId: 'NEXT001',
confidence: 0.6,
pattern: /<Link\b(?![^>]*legacyBehavior).*?>\s*<[^a][^>]*>.*?<\/[^>]+>\s*<\/Link>/gi,
message: 'next/link used without anchor or child text (legacyBehavior)',
severity: 'low',
fix: 'Wrap link content with <a> or use correct child'
},
{
ruleId: 'NEXT002',
confidence: 0.6,
pattern: /<a\s+href=\s*['"][^#][^'"\s>]+['"][^>]*>\s*[^<]*<\/a>/gi,
message: 'In-page <a> used for client navigation; prefer next/link',
severity: 'medium',
fix: 'Use <Link href="..."><a>…</a></Link> or modern API'
},
{
ruleId: 'A11Y006',
confidence: 0.6,
pattern: /<img(?![^>]*\b(width|height)\s*=)/gi,
message: 'Image missing width/height attributes',
severity: 'low',
fix: 'Specify width and height to avoid layout shifts'
},
{
ruleId: 'A11Y007',
confidence: 0.6,
pattern: /<Image\b(?![^>]*\b(width|height)\s*=)/gi,
message: 'next/image used without width and height',
severity: 'low',
fix: 'Provide width and height props to <Image>'
},
{
ruleId: 'A11Y002',
confidence: 0.75,
pattern: /<input(?![^>]*aria-label)(?![^>]*id=)/gi,
message: 'Input without label or aria-label',
severity: 'medium',
fix: 'Add proper labeling to form inputs'
},
{
ruleId: 'A11Y003',
confidence: 0.85,
pattern: /<button(?![^>]*aria-label)>\s*<\/button>/gi,
message: 'Empty button without aria-label',
severity: 'high',
fix: 'Add descriptive text or aria-label to buttons'
},
{
ruleId: 'A11Y004',
confidence: 0.7,
pattern: /<div[^>]*onClick|<div[^>]*@click|<div[^>]*v-on:click/gi,
message: 'Div with onClick (not keyboard accessible)',
severity: 'medium',
fix: 'Use button element or add keyboard event handlers'
},
{
ruleId: 'A11Y005',
confidence: 0.6,
pattern: /<a(?![^>]*href)/gi,
message: 'Link without href attribute',
severity: 'low',
fix: 'Add href attribute or use button element'
}
];
}
async scan(options) {
const results = [];
const files = await (0, glob_1.glob)('**/*.{js,jsx,ts,tsx,vue}', {
cwd: options.directory,
ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'examples/**']
});
for (const file of files) {
try {
const content = (0, fs_1.readFileSync)(`${options.directory}/${file}`, 'utf-8');
const lines = content.split('\n');
let ubonDisableAll = false;
lines.forEach((line, index) => {
if (/ubon-disable-file/.test(line)) {
ubonDisableAll = true;
}
if (ubonDisableAll)
return;
const disableNext = /ubon-disable-next-line\s+([A-Z0-9_,\s-]+)/.exec(line);
const prevDisable = index > 0 ? /ubon-disable-next-line\s+([A-Z0-9_,\s-]+)/.exec(lines[index - 1]) : null;
this.patterns.forEach((patternDef, patternIndex) => {
const { pattern, message, severity, fix } = patternDef;
const defaultConfidenceBySeverity = {
high: 0.85,
medium: 0.75,
low: 0.6
};
const ruleId = patternDef.ruleId || `A11Y${String(patternIndex + 1).padStart(3, '0')}`;
const sev = severity;
const confidence = patternDef.confidence ?? defaultConfidenceBySeverity[sev];
// Skip our own pattern definitions
if (file.includes('accessibility-scanner.ts') ||
line.includes('pattern:') ||
line.includes('message:')) {
return;
}
const m = line.match(pattern);
if (m) {
const disabledList = new Set([
...(disableNext && disableNext[1] ? disableNext[1].split(/[,\s]+/).filter(Boolean) : []),
...(prevDisable && prevDisable[1] ? prevDisable[1].split(/[,\s]+/).filter(Boolean) : [])
]);
if (disabledList.has(ruleId)) {
return;
}
if (ruleId === 'A11Y004') {
const hasRole = /role\s*=\s*"button"/i.test(line);
const hasTab = /tabIndex\s*=\s*\{?0\}?/i.test(line);
if (hasRole && hasTab) {
return;
}
}
const fixEdits = [];
if (ruleId === 'A11Y001') {
// Add alt="" right after <img
const insertAt = (line.indexOf('<img') >= 0) ? line.indexOf('<img') + 4 : 1;
fixEdits.push({
file,
startLine: index + 1,
startColumn: insertAt,
endLine: index + 1,
endColumn: insertAt,
replacement: ' alt=""'
});
}
if (ruleId === 'A11Y002') {
// Add aria-label="" after <input if missing label/id
const startIdx = line.toLowerCase().indexOf('<input');
const insertAt = startIdx >= 0 ? startIdx + 6 : 1;
fixEdits.push({
file,
startLine: index + 1,
startColumn: insertAt,
endLine: index + 1,
endColumn: insertAt,
replacement: ' aria-label=""'
});
}
if (ruleId === 'A11Y005') {
// Replace <a ...> without href with <button ...>
fixEdits.push({
file,
startLine: index + 1,
startColumn: 1,
endLine: index + 1,
endColumn: Math.max(1, line.length),
replacement: line.replace(/<a(?![^>]*href)/i, '<button').replace(/<\/a>/i, '</button>')
});
}
if (ruleId === 'A11Y004') {
// Add role and tabIndex to div with onClick
const startIdx = line.toLowerCase().indexOf('<div');
const insertAt = startIdx >= 0 ? startIdx + 4 : 1;
const replacement = line.replace(/<div([^>]*)/i, (m0) => {
if (/role=/i.test(m0))
return m0;
return m0 + ' role="button" tabIndex={0}';
});
fixEdits.push({
file,
startLine: index + 1,
startColumn: 1,
endLine: index + 1,
endColumn: Math.max(1, line.length),
replacement
});
}
results.push({
type: severity === 'high' ? 'error' : 'warning',
category: 'accessibility',
message,
file,
line: index + 1,
range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) },
match: m[0]?.slice(0, 200),
severity,
ruleId,
confidence,
fix,
...(fixEdits.length ? { fixEdits } : {})
});
}
});
});
}
catch (error) {
// Skip files that can't be read
}
}
return results;
}
}
exports.AccessibilityScanner = AccessibilityScanner;