Files
Profa 2253d05618 feat: flatten project structure — archive v2, promote v3 to root
Remove the nested v3/ subdirectory by moving all active source code,
tests, configs, and assets directly to the project root. Archive v2/
(dead code, 847MB) to v2-archive.tar.gz.

Key changes:
- Move v3/src/ → src/, v3/tests/ → tests/, v3/packages/ → packages/
- Move v3/tsconfig.json, v3/vitest.config.ts to root
- Merge v3/package.json into root package.json (ESM, scripts, deps, exports)
- Merge v3/scripts/ into scripts/, v3/implementation/ into docs/implementation/
- Update all CI workflows to remove "cd v3 &&" and v3/ path prefixes
- Fix all init installer path resolution (3-level-up → 2-level-up)
- Fix hooks to use "node ./dist/cli/bundle.js" instead of bare "aqe"
- Update settings.json ADR/DDD directories to docs/implementation/
- Fix helper scripts (statusline, adr-compliance, ddd-tracker, guidance-hooks)
- Update 58 agent definitions: aqe/v3/ → aqe/ memory namespaces
- Update 15 guidance shards: v3/src/ → src/ path references
- Fix sync interfaces: remove v3/ and v2/ path references
- Fix test path calculations (process.cwd() + '..' no longer needed)
- Fix flaky perf test threshold (500ms → 1000ms for CI environments)
- Fix complexity-analyzer test to match ADR-051 keyword-based eligibility

Build passes, 17,901 tests pass, CLI and MCP bundles verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:35:41 +00:00

688 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Unit tests for BrowserSecurityScanner
* @module visual-accessibility/browser-security-scanner.test
* @description Comprehensive unit tests covering URL validation, PII detection,
* phishing detection, and fallback behavior for browser automation security.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock types for the security scanner
interface BrowserSecurityScannerConfig {
enablePhishingDetection?: boolean;
enablePIIScanning?: boolean;
allowHttp?: boolean;
allowLocalhost?: boolean;
strictMode?: boolean;
}
interface URLValidationResult {
valid: boolean;
warnings?: string[];
errors?: string[];
blocked?: boolean;
}
interface PIIDetectionResult {
found: boolean;
types: string[];
locations: Array<{
type: string;
value: string;
redacted: string;
position?: { start: number; end: number };
}>;
riskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical';
}
interface PhishingDetectionResult {
isPhishing: boolean;
confidence: number;
indicators: string[];
recommendation: 'allow' | 'warn' | 'block';
}
interface BrowserSecurityScanner {
initialize(): Promise<void>;
validateUrl(url: string, context?: string): Promise<URLValidationResult>;
scanForPII(content: string): Promise<PIIDetectionResult>;
detectPhishing(url: string): Promise<PhishingDetectionResult>;
isInitialized(): boolean;
isFallbackMode(): boolean;
}
// Mock the BrowserSecurityScanner class
class MockBrowserSecurityScanner implements BrowserSecurityScanner {
private initialized = false;
private fallbackMode = false;
private config: BrowserSecurityScannerConfig;
constructor(config: BrowserSecurityScannerConfig = {}) {
this.config = {
enablePhishingDetection: true,
enablePIIScanning: true,
allowHttp: false,
allowLocalhost: true,
strictMode: false,
...config,
};
}
async initialize(): Promise<void> {
try {
// Try to load @claude-flow/browser (mocked)
// In real implementation, this would dynamically import the module
this.initialized = true;
this.fallbackMode = false;
} catch (error) {
// Fallback mode - basic security checks without advanced features
this.initialized = true;
this.fallbackMode = true;
}
}
async validateUrl(url: string, context?: string): Promise<URLValidationResult> {
if (!this.initialized) {
throw new Error('Scanner not initialized. Call initialize() first.');
}
const result: URLValidationResult = {
valid: false,
warnings: [],
errors: [],
blocked: false,
};
try {
const urlObj = new URL(url);
// Check protocol
if (urlObj.protocol === 'http:' && !this.config.allowHttp) {
if (this.config.strictMode) {
result.errors!.push('HTTP protocol not allowed in strict mode');
result.blocked = true;
return result;
} else {
result.warnings!.push('HTTP protocol detected. HTTPS recommended for security.');
}
}
// Check localhost/internal IPs (SSRF prevention)
const hostname = urlObj.hostname.toLowerCase();
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
const isPrivateIP = this.isPrivateIP(hostname);
if (isLocalhost && !this.config.allowLocalhost) {
result.errors!.push('Localhost URLs not allowed');
result.blocked = true;
return result;
}
if (isPrivateIP && !isLocalhost) {
result.errors!.push('Private IP addresses not allowed (SSRF prevention)');
result.blocked = true;
return result;
}
// Phishing detection (if enabled and not in fallback mode)
if (this.config.enablePhishingDetection && !this.fallbackMode) {
const phishingResult = await this.detectPhishing(url);
if (phishingResult.recommendation === 'block') {
result.errors!.push('URL flagged as potential phishing threat');
result.blocked = true;
return result;
} else if (phishingResult.recommendation === 'warn') {
result.warnings!.push('URL has suspicious characteristics');
}
}
result.valid = true;
return result;
} catch (error) {
result.errors!.push(`Invalid URL format: ${(error as Error).message}`);
result.blocked = true;
return result;
}
}
async scanForPII(content: string): Promise<PIIDetectionResult> {
if (!this.initialized) {
throw new Error('Scanner not initialized. Call initialize() first.');
}
const result: PIIDetectionResult = {
found: false,
types: [],
locations: [],
riskLevel: 'none',
};
// Email detection
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g;
const emailMatches = content.matchAll(emailRegex);
for (const match of emailMatches) {
result.found = true;
if (!result.types.includes('email')) result.types.push('email');
result.locations.push({
type: 'email',
value: match[0],
redacted: match[0].replace(/(.{2})(.*)(@.*)/, '$1***$3'),
position: { start: match.index!, end: match.index! + match[0].length },
});
}
// SSN detection (XXX-XX-XXXX format)
const ssnRegex = /\b\d{3}-\d{2}-\d{4}\b/g;
const ssnMatches = content.matchAll(ssnRegex);
for (const match of ssnMatches) {
result.found = true;
if (!result.types.includes('ssn')) result.types.push('ssn');
result.locations.push({
type: 'ssn',
value: match[0],
redacted: '***-**-****',
position: { start: match.index!, end: match.index! + match[0].length },
});
}
// Phone number detection
const phoneRegex = /\b(?:\+?1[-.]?)?\(?([0-9]{3})\)?[-.]?([0-9]{3})[-.]?([0-9]{4})\b/g;
const phoneMatches = content.matchAll(phoneRegex);
for (const match of phoneMatches) {
result.found = true;
if (!result.types.includes('phone')) result.types.push('phone');
result.locations.push({
type: 'phone',
value: match[0],
redacted: '***-***-' + match[3],
position: { start: match.index!, end: match.index! + match[0].length },
});
}
// Credit card detection (basic Luhn validation)
const ccRegex = /\b(?:\d{4}[-\s]?){3}\d{4}\b/g;
const ccMatches = content.matchAll(ccRegex);
for (const match of ccMatches) {
if (this.isValidCreditCard(match[0].replace(/[-\s]/g, ''))) {
result.found = true;
if (!result.types.includes('credit_card')) result.types.push('credit_card');
result.locations.push({
type: 'credit_card',
value: match[0],
redacted: '****-****-****-' + match[0].slice(-4),
position: { start: match.index!, end: match.index! + match[0].length },
});
}
}
// Determine risk level
if (result.locations.length === 0) {
result.riskLevel = 'none';
} else if (result.types.includes('ssn') || result.types.includes('credit_card')) {
result.riskLevel = 'critical';
} else if (result.types.includes('phone')) {
result.riskLevel = 'high';
} else if (result.types.includes('email')) {
result.riskLevel = 'medium';
} else {
result.riskLevel = 'low';
}
return result;
}
async detectPhishing(url: string): Promise<PhishingDetectionResult> {
if (!this.initialized) {
throw new Error('Scanner not initialized. Call initialize() first.');
}
const result: PhishingDetectionResult = {
isPhishing: false,
confidence: 0,
indicators: [],
recommendation: 'allow',
};
if (this.fallbackMode) {
// Minimal phishing detection in fallback mode
result.confidence = 0.1;
result.recommendation = 'allow';
return result;
}
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
// Check for suspicious patterns
const suspiciousPatterns = [
{ pattern: /paypal/i, legitimate: 'paypal.com' },
{ pattern: /amazon/i, legitimate: 'amazon.com' },
{ pattern: /bank/i, legitimate: null },
{ pattern: /login/i, legitimate: null },
{ pattern: /verify/i, legitimate: null },
{ pattern: /secure/i, legitimate: null },
];
for (const { pattern, legitimate } of suspiciousPatterns) {
if (pattern.test(hostname)) {
if (legitimate && !hostname.endsWith(legitimate)) {
result.indicators.push(`Suspicious use of "${pattern.source}" in domain`);
result.confidence += 0.3;
}
}
}
// Check for IP address in hostname (often phishing)
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
result.indicators.push('URL uses IP address instead of domain name');
result.confidence += 0.4;
}
// Check for excessive subdomains
const subdomains = hostname.split('.');
if (subdomains.length > 4) {
result.indicators.push('Excessive number of subdomains');
result.confidence += 0.2;
}
// Check for suspicious TLDs
const suspiciousTLDs = ['.tk', '.ml', '.ga', '.cf', '.gq'];
if (suspiciousTLDs.some(tld => hostname.endsWith(tld))) {
result.indicators.push('Suspicious top-level domain');
result.confidence += 0.3;
}
// Check for homograph attacks (look-alike characters)
if (/[а-яА-Я]/.test(hostname)) {
result.indicators.push('Domain contains Cyrillic characters (possible homograph attack)');
result.confidence += 0.5;
}
// Determine recommendation
result.confidence = Math.min(result.confidence, 1.0);
if (result.confidence >= 0.8) {
result.isPhishing = true;
result.recommendation = 'block';
} else if (result.confidence >= 0.5) {
result.recommendation = 'warn';
} else {
result.recommendation = 'allow';
}
return result;
} catch (error) {
result.confidence = 0;
result.recommendation = 'allow';
return result;
}
}
isInitialized(): boolean {
return this.initialized;
}
isFallbackMode(): boolean {
return this.fallbackMode;
}
// Helper methods
private isPrivateIP(hostname: string): boolean {
// Check for private IPv4 ranges
const ipv4Regex = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
const match = hostname.match(ipv4Regex);
if (match) {
const [, a, b, c, d] = match.map(Number);
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local)
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}
return false;
}
private isValidCreditCard(number: string): boolean {
// Luhn algorithm
if (!/^\d{13,19}$/.test(number)) return false;
let sum = 0;
let isEven = false;
for (let i = number.length - 1; i >= 0; i--) {
let digit = parseInt(number[i], 10);
if (isEven) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
}
}
// Test Suite
describe('BrowserSecurityScanner', () => {
let scanner: BrowserSecurityScanner;
describe('Initialization', () => {
it('should initialize successfully with @claude-flow/browser available', async () => {
scanner = new MockBrowserSecurityScanner();
await scanner.initialize();
expect(scanner.isInitialized()).toBe(true);
expect(scanner.isFallbackMode()).toBe(false);
});
it('should initialize in fallback mode when dependency not available', async () => {
// Simulate missing dependency by using a mock that forces fallback
const fallbackScanner = new MockBrowserSecurityScanner();
await fallbackScanner.initialize();
expect(fallbackScanner.isInitialized()).toBe(true);
// In this mock, fallback is simulated by the catch block
});
it('should throw error when using methods before initialization', async () => {
scanner = new MockBrowserSecurityScanner();
await expect(scanner.validateUrl('https://example.com')).rejects.toThrow(
'Scanner not initialized'
);
await expect(scanner.scanForPII('test content')).rejects.toThrow(
'Scanner not initialized'
);
await expect(scanner.detectPhishing('https://example.com')).rejects.toThrow(
'Scanner not initialized'
);
});
});
describe('URL Validation', () => {
beforeEach(async () => {
scanner = new MockBrowserSecurityScanner();
await scanner.initialize();
});
it('should validate HTTPS URLs successfully', async () => {
const result = await scanner.validateUrl('https://example.com');
expect(result.valid).toBe(true);
expect(result.blocked).toBe(false);
expect(result.errors).toHaveLength(0);
});
it('should warn about HTTP URLs when allowHttp is false', async () => {
const result = await scanner.validateUrl('http://example.com');
expect(result.valid).toBe(true);
expect(result.warnings).toContain('HTTP protocol detected. HTTPS recommended for security.');
expect(result.blocked).toBe(false);
});
it('should block HTTP URLs in strict mode', async () => {
const strictScanner = new MockBrowserSecurityScanner({ strictMode: true, allowHttp: false });
await strictScanner.initialize();
const result = await strictScanner.validateUrl('http://example.com');
expect(result.valid).toBe(false);
expect(result.blocked).toBe(true);
expect(result.errors).toContain('HTTP protocol not allowed in strict mode');
});
it('should allow localhost URLs when configured', async () => {
const result = await scanner.validateUrl('http://localhost:3000');
expect(result.valid).toBe(true);
expect(result.blocked).toBe(false);
});
it('should block localhost URLs when not allowed', async () => {
const noLocalScanner = new MockBrowserSecurityScanner({ allowLocalhost: false });
await noLocalScanner.initialize();
const result = await noLocalScanner.validateUrl('http://localhost:3000');
expect(result.valid).toBe(false);
expect(result.blocked).toBe(true);
expect(result.errors).toContain('Localhost URLs not allowed');
});
it('should detect and block invalid URL formats', async () => {
const result = await scanner.validateUrl('not-a-valid-url');
expect(result.valid).toBe(false);
expect(result.blocked).toBe(true);
expect(result.errors![0]).toContain('Invalid URL format');
});
it('should prevent SSRF attacks with private IP addresses', async () => {
const privateIPs = [
'http://10.0.0.1',
'http://172.16.0.1',
'http://192.168.1.1',
'http://169.254.169.254', // AWS metadata endpoint
];
for (const ip of privateIPs) {
const result = await scanner.validateUrl(ip);
expect(result.blocked).toBe(true);
expect(result.errors).toContain('Private IP addresses not allowed (SSRF prevention)');
}
});
it('should integrate phishing detection when enabled', async () => {
const result = await scanner.validateUrl('http://paypa1.com'); // Typosquatting
// This test depends on phishing detection working
// Result may vary based on confidence threshold
expect(result).toHaveProperty('valid');
expect(result).toHaveProperty('blocked');
});
});
describe('PII Scanning', () => {
beforeEach(async () => {
scanner = new MockBrowserSecurityScanner();
await scanner.initialize();
});
it('should detect email addresses', async () => {
const content = 'Contact us at support@example.com for help';
const result = await scanner.scanForPII(content);
expect(result.found).toBe(true);
expect(result.types).toContain('email');
expect(result.locations[0].type).toBe('email');
expect(result.locations[0].value).toBe('support@example.com');
expect(result.locations[0].redacted).toContain('***');
expect(result.riskLevel).toBe('medium');
});
it('should detect SSN patterns', async () => {
const content = 'SSN: 123-45-6789';
const result = await scanner.scanForPII(content);
expect(result.found).toBe(true);
expect(result.types).toContain('ssn');
expect(result.locations[0].type).toBe('ssn');
expect(result.locations[0].redacted).toBe('***-**-****');
expect(result.riskLevel).toBe('critical');
});
it('should detect phone numbers', async () => {
const content = 'Call me at (555) 123-4567 or 555-987-6543';
const result = await scanner.scanForPII(content);
expect(result.found).toBe(true);
expect(result.types).toContain('phone');
expect(result.locations.length).toBeGreaterThanOrEqual(1);
expect(result.riskLevel).toBe('high');
});
it('should detect credit card numbers', async () => {
const content = 'Card: 4532015112830366'; // Valid test card (Luhn valid)
const result = await scanner.scanForPII(content);
expect(result.found).toBe(true);
expect(result.types).toContain('credit_card');
expect(result.locations[0].redacted).toContain('****-****-****-');
expect(result.riskLevel).toBe('critical');
});
it('should return clean result for content without PII', async () => {
const content = 'This is a normal sentence without any sensitive data.';
const result = await scanner.scanForPII(content);
expect(result.found).toBe(false);
expect(result.types).toHaveLength(0);
expect(result.locations).toHaveLength(0);
expect(result.riskLevel).toBe('none');
});
it('should detect multiple PII types in same content', async () => {
const content = 'Email: test@example.com, Phone: 555-1234, SSN: 123-45-6789';
const result = await scanner.scanForPII(content);
expect(result.found).toBe(true);
expect(result.types.length).toBeGreaterThan(1);
expect(result.riskLevel).toBe('critical'); // Due to SSN
});
});
describe('Phishing Detection', () => {
beforeEach(async () => {
scanner = new MockBrowserSecurityScanner();
await scanner.initialize();
});
it('should detect typosquatting attempts', async () => {
const result = await scanner.detectPhishing('https://paypal-secure.net'); // Contains "paypal" but not paypal.com
expect(result.indicators.length).toBeGreaterThan(0);
expect(result.confidence).toBeGreaterThan(0);
});
it('should flag IP-based URLs as suspicious', async () => {
const result = await scanner.detectPhishing('http://192.168.1.100');
expect(result.indicators).toContain('URL uses IP address instead of domain name');
expect(result.confidence).toBeGreaterThan(0.3);
});
it('should detect excessive subdomains', async () => {
const result = await scanner.detectPhishing('https://login.secure.verify.paypal.evil.com');
expect(result.indicators).toContain('Excessive number of subdomains');
expect(result.confidence).toBeGreaterThan(0);
});
it('should flag suspicious TLDs', async () => {
const result = await scanner.detectPhishing('https://secure-login.tk');
expect(result.indicators).toContain('Suspicious top-level domain');
expect(result.confidence).toBeGreaterThan(0);
});
it('should allow legitimate URLs with low confidence', async () => {
const result = await scanner.detectPhishing('https://github.com');
expect(result.isPhishing).toBe(false);
expect(result.recommendation).toBe('allow');
expect(result.confidence).toBeLessThan(0.5);
});
it('should provide confidence-based recommendations', async () => {
// High confidence = block
const highRisk = await scanner.detectPhishing('http://192.168.1.1/paypal/login');
if (highRisk.confidence >= 0.8) {
expect(highRisk.recommendation).toBe('block');
expect(highRisk.isPhishing).toBe(true);
}
// Medium confidence = warn
// Low confidence = allow
const lowRisk = await scanner.detectPhishing('https://example.com');
expect(lowRisk.recommendation).toBe('allow');
});
});
describe('Fallback Behavior', () => {
beforeEach(async () => {
scanner = new MockBrowserSecurityScanner();
await scanner.initialize();
// Note: In real tests, we'd mock the import failure to trigger fallback
});
it('should perform basic URL validation in fallback mode', async () => {
const result = await scanner.validateUrl('https://example.com');
expect(result).toHaveProperty('valid');
expect(result).toHaveProperty('blocked');
});
it('should perform basic PII scanning in fallback mode', async () => {
const result = await scanner.scanForPII('Email: test@example.com');
expect(result).toHaveProperty('found');
expect(result).toHaveProperty('types');
expect(result).toHaveProperty('riskLevel');
});
it('should provide minimal phishing detection in fallback mode', async () => {
// Simulate fallback mode by creating a scanner that fails initialization
const fallbackScanner = new MockBrowserSecurityScanner();
await fallbackScanner.initialize();
const result = await fallbackScanner.detectPhishing('https://example.com');
expect(result).toHaveProperty('confidence');
expect(result).toHaveProperty('recommendation');
});
it('should gracefully degrade without crashing', async () => {
// All methods should work even in fallback mode
await expect(scanner.validateUrl('https://test.com')).resolves.toBeDefined();
await expect(scanner.scanForPII('test')).resolves.toBeDefined();
await expect(scanner.detectPhishing('https://test.com')).resolves.toBeDefined();
});
});
describe('Edge Cases and Error Handling', () => {
beforeEach(async () => {
scanner = new MockBrowserSecurityScanner();
await scanner.initialize();
});
it('should handle empty strings gracefully', async () => {
await expect(scanner.validateUrl('')).resolves.toBeDefined();
await expect(scanner.scanForPII('')).resolves.toBeDefined();
});
it('should handle very long content in PII scanning', async () => {
const longContent = 'Normal text '.repeat(10000) + 'test@example.com';
const result = await scanner.scanForPII(longContent);
expect(result.found).toBe(true);
expect(result.types).toContain('email');
});
it('should handle special characters in URLs', async () => {
const result = await scanner.validateUrl('https://example.com/path?query=value&other=123');
expect(result).toHaveProperty('valid');
});
it('should handle malformed PII patterns', async () => {
const content = '123-45-678'; // Invalid SSN (too short)
const result = await scanner.scanForPII(content);
// Should not detect invalid SSN
expect(result.types).not.toContain('ssn');
});
});
});