web-vuln-scanner
Version:
Advanced, lightweight web vulnerability scanner with smart detection and easy-to-use interface
573 lines (499 loc) • 18.8 kB
JavaScript
/**
* Production-Ready Web Vulnerability Scanner
* Enterprise-grade vulnerability scanning with comprehensive security, monitoring, and resilience
*/
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
const EventEmitter = require('events');
// Debug function with fallback
let debug;
try {
debug = require('debug')('web-vuln-scanner');
} catch (error) {
// Fallback debug function for environments without debug module
debug = (msg) => {
if (process.env.DEBUG || process.env.NODE_ENV === 'development') {
console.log(`[DEBUG] ${msg}`);
}
};
}
// Production-ready modules
const { SecurityValidator } = require('./security/validator');
const { ConfigManager } = require('./config/config-manager');
const { CircuitBreaker, RetryManager, ErrorHandler, ResourceManager } = require('./resilience/error-handler');
const { Logger, MetricsCollector, PerformanceMonitor } = require('./monitoring/logger');
// Initialize JSDOM with fallback
let JSDOM, VirtualConsole;
try {
const jsdomModule = require('jsdom');
JSDOM = jsdomModule.JSDOM;
VirtualConsole = jsdomModule.VirtualConsole;
} catch (error) {
console.warn('JSDOM not available, DOM parsing features will be limited');
// Provide minimal fallback
JSDOM = class {
constructor(html) {
this.window = {
document: {
createElement: () => ({}),
querySelectorAll: () => [],
querySelector: () => null,
getElementsByTagName: () => [],
body: { innerHTML: html || '' }
}
};
}
};
VirtualConsole = class {
on() { return this; }
};
}
// Handle both CommonJS and ES module versions of p-limit
let pLimit;
try {
pLimit = require('p-limit');
} catch (error) {
// Fallback for when p-limit is not available
pLimit = (concurrency) => (fn) => fn();
}
const { Crawler } = require('./crawler');
// Import scanners with error handling
const importScanner = (path) => {
try {
return require(path);
} catch (error) {
console.warn(`Failed to import scanner: ${path} - ${error.message}`);
return null;
}
};
// Production-ready scanner imports
const headerScanner = importScanner('./scanners/header');
const xssScanner = importScanner('./scanners/xss');
const sqlScanner = importScanner('./scanners/sql-injection');
const sslScanner = importScanner('./scanners/ssl-tls');
const portScanner = importScanner('./scanners/port');
const dirTraversalScanner = importScanner('./scanners/dir-traversal');
const csrfScanner = importScanner('./scanners/csrf');
const cspScanner = importScanner('./scanners/csp');
const rceScanner = importScanner('./scanners/rce');
const idorScanner = importScanner('./scanners/idor');
const misconfigScanner = importScanner('./scanners/misconfigured-headers');
const wafScanner = importScanner('./scanners/waf');
class Scanner extends EventEmitter {
constructor(url, options = {}) {
super();
// Initialize production components
this.config = ConfigManager.getInstance();
this.logger = new Logger(this.config.get('logging'));
this.metrics = new MetricsCollector(this.config.get('monitoring'));
this.monitor = new PerformanceMonitor(this.logger, this.metrics);
this.validator = new SecurityValidator();
this.errorHandler = new ErrorHandler({ logger: this.logger });
this.resourceManager = new ResourceManager(this.config.get('scanner'));
// Circuit breakers for external dependencies
this.httpCircuitBreaker = new CircuitBreaker({
failureThreshold: 5,
timeout: 30000,
resetTimeout: 300000
});
this.retryManager = new RetryManager({
maxRetries: this.config.get('scanner.retryAttempts', 3),
baseDelay: this.config.get('scanner.retryDelay', 1000),
maxDelay: 30000
});
// Validate and sanitize URL
const urlValidation = this.validator.validateUrl(url);
if (!urlValidation.isValid) {
throw new Error(`Invalid URL: ${urlValidation.errors.join(', ')}`);
}
this.url = urlValidation.sanitized;
// Validate and merge options with config
const configValidation = this.validator.validateScanConfig(options);
if (!configValidation.isValid) {
throw new Error(`Invalid configuration: ${configValidation.errors.join(', ')}`);
}
// Merge configuration from multiple sources
this.options = {
timeout: this.config.get('scanner.requestTimeout', 30000),
userAgent: this.config.get('scanner.userAgent', 'WebVulnScanner/2.0'),
scanModules: [
'headers', 'xss', 'sql', 'ssl', 'ports',
'dirTraversal', 'csrf', 'csp',
'rce', 'idor', 'misconfiguredHeaders', 'waf'
],
depth: this.config.get('scanner.maxDepth', 5),
concurrency: this.config.get('scanner.maxConcurrency', 10),
disableCrawler: false,
enableJS: this.config.get('scanner.enableJavaScript', false),
headers: {},
maxRetries: this.config.get('scanner.retryAttempts', 3),
retryDelay: this.config.get('scanner.retryDelay', 1000),
...configValidation.sanitized
};
// Validate headers
const headerValidation = this.validator.validateHeaders(this.options.headers);
if (!headerValidation.isValid) {
this.logger.warn('Invalid headers provided', { errors: headerValidation.errors });
}
this.options.headers = headerValidation.sanitized;
this.results = {
scanId: this.validator.generateSecureId(),
url: this.url,
timestamp: new Date().toISOString(),
scanDuration: 0,
scannedUrls: [],
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0, info: 0 },
vulnerabilities: [],
metadata: {
scannerVersion: '2.0.0',
configuration: this.config.getSafeConfig(),
userAgent: this.options.userAgent
}
};
// Initialize crawler with error handling and monitoring
try {
this.crawler = new Crawler({
baseUrl: this.url,
depth: this.options.depth,
concurrency: this.options.concurrency,
userAgent: this.options.userAgent,
headers: this.options.headers
});
} catch (crawlerError) {
debug(`Crawler initialization failed: ${crawlerError.message}`);
this.crawler = null;
}
}
normalizeUrl(url) {
if (!url || typeof url !== 'string') {
throw new Error('Invalid URL provided');
}
return url.startsWith('http') ? url : `https://${url}`;
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async fetchPage(targetUrl, method = 'GET', data = null, retries = 0) {
const startTime = Date.now();
try {
debug(`Fetching ${targetUrl} (attempt ${retries + 1})`);
const options = {
method,
headers: {
'User-Agent': this.options.userAgent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
...this.options.headers
},
timeout: this.options.timeout,
redirect: 'follow'
};
if (data && method !== 'GET') {
options.body = typeof data === 'string' ? data : JSON.stringify(data);
options.headers['Content-Type'] = typeof data === 'string'
? 'application/x-www-form-urlencoded'
: 'application/json';
}
const response = await fetch(targetUrl, options);
const contentType = response.headers.get('content-type') || '';
const content = await response.text();
let dom = null;
if (contentType.includes('text/html')) {
try {
const cleanedContent = this.stripResourceElements(content);
dom = new JSDOM(cleanedContent, {
resources: 'usable',
runScripts: 'outside-only',
pretendToBeVisual: false,
virtualConsole: this.createSilentVirtualConsole()
});
} catch (domError) {
debug(`DOM parse failed for ${targetUrl}: ${domError.message}`);
// Continue without DOM - some scanners can still work with raw content
}
}
return {
status: response.status,
headers: Object.fromEntries(response.headers),
content,
dom,
url: response.url,
responseTime: Date.now() - startTime
};
} catch (error) {
if (retries < this.options.maxRetries && this.isRetryableError(error)) {
await this.delay(this.options.retryDelay * (retries + 1));
return this.fetchPage(targetUrl, method, data, retries + 1);
}
debug(`Failed to fetch ${targetUrl}: ${error.message}`);
throw new Error(`Fetch error: ${error.message}`);
}
}
isRetryableError(error) {
const retryableErrors = ['ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT'];
return retryableErrors.some(code => error.message.includes(code)) ||
error.message.includes('timeout');
}
createSilentVirtualConsole() {
const virtualConsole = new VirtualConsole();
// Completely silence all console output
virtualConsole.sendTo = () => {};
['error', 'warn', 'info', 'log', 'debug', 'trace', 'jsdomError'].forEach(event => {
virtualConsole.on(event, () => {});
});
return virtualConsole;
}
stripResourceElements(html) {
if (!html || typeof html !== 'string') {
return '';
}
return html
// Remove all link elements (CSS, etc.)
.replace(/<link[^>]*>/gi, '<!-- link removed -->')
// Remove script elements with src attributes
.replace(/<script[^>]*src[^>]*>.*?<\/script\b[^>]*>/gis, '<!-- script removed -->')
// Remove img elements with external src
.replace(/<img[^>]*src=["'][^"']*["'][^>]*>/gi, '<img>')
// Remove iframe elements
.replace(/<iframe[^>]*>.*?<\/iframe>/gis, '<!-- iframe removed -->')
// Remove object and embed elements
.replace(/<(object|embed)[^>]*>.*?<\/\1>/gis, '<!-- $1 removed -->')
// Remove style imports
.replace(/ [^;]*;/gi, '/* import removed */')
// Remove problematic CSS that might cause parsing issues
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '<style>/* CSS removed */</style>');
}
async runScan() {
const startTime = Date.now();
debug(`Starting scan: ${this.url}`);
const limit = pLimit ? pLimit(this.options.concurrency) : (fn => fn());
let pages = [this.url];
// Crawling phase
if (!this.options.disableCrawler && this.crawler) {
try {
const crawled = await this.crawler.crawl();
if (Array.isArray(crawled)) {
pages = [...new Set([this.url, ...crawled])];
}
} catch (crawlerError) {
debug(`Crawler error: ${crawlerError.message}`);
this.addResults([{
type: 'crawler_error',
url: this.url,
severity: 'info',
description: `Crawler failed: ${crawlerError.message}`
}]);
}
}
this.results.scannedUrls = pages;
debug(`Scanning ${pages.length} pages`);
// Scan all discovered pages
const scanTasks = pages.map(url => limit(() => this.scanOneUrl(url)));
try {
await Promise.allSettled(scanTasks);
} catch (scanError) {
debug(`Scan tasks error: ${scanError.message}`);
}
// Run SSL scan if enabled
if (this.options.scanModules.includes('ssl') && sslScanner) {
try {
const sslResults = await sslScanner.scan(this.url);
this.addResults(sslResults);
} catch (sslError) {
debug(`SSL scan error: ${sslError.message}`);
this.addResults([{
type: 'ssl_scan_error',
url: this.url,
severity: 'info',
description: `SSL scan failed: ${sslError.message}`
}]);
}
}
// Run port scan if enabled
if (this.options.scanModules.includes('ports') && portScanner) {
try {
const portResults = await portScanner.scan(this.url);
this.addResults(portResults);
} catch (portError) {
debug(`Port scan error: ${portError.message}`);
this.addResults([{
type: 'port_scan_error',
url: this.url,
severity: 'info',
description: `Port scan failed: ${portError.message}`
}]);
}
}
// Calculate scan duration
this.results.scanDuration = Date.now() - startTime;
debug(`Scan completed in ${this.results.scanDuration}ms`);
return this.results;
}
async scanOneUrl(url) {
try {
const page = await this.fetchPage(url);
await this.scanUrl(page, url);
} catch (err) {
debug(`Error scanning ${url}: ${err.message}`);
this.results.vulnerabilities.push({
type: 'scan_error',
url,
severity: 'info',
description: `Scan error: ${err.message}`,
timestamp: new Date().toISOString()
});
}
}
async scanUrl(page, url) {
// Only include scanners that were successfully imported
const scanners = {};
if (headerScanner) scanners.headers = headerScanner;
if (xssScanner) scanners.xss = xssScanner;
if (sqlScanner) scanners.sql = sqlScanner;
if (dirTraversalScanner) scanners.dirTraversal = dirTraversalScanner;
if (csrfScanner) scanners.csrf = csrfScanner;
if (cspScanner) scanners.csp = cspScanner;
if (rceScanner) scanners.rce = rceScanner;
if (idorScanner) scanners.idor = idorScanner;
if (misconfigScanner) scanners.misconfiguredHeaders = misconfigScanner;
if (wafScanner) scanners.waf = wafScanner;
const modulesToRun = this.options.scanModules
.filter(name => scanners[name] && typeof scanners[name].scan === 'function');
debug(`Running ${modulesToRun.length} scanners on ${url}`);
try {
const results = await Promise.allSettled(
modulesToRun.map(async (name) => {
try {
const scanResult = await Promise.race([
scanners[name].scan(page, url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Scanner timeout')), this.options.timeout)
)
]);
return Array.isArray(scanResult) ? scanResult :
scanResult ? [scanResult] : [];
} catch (scannerError) {
debug(`Scanner ${name} error on ${url}: ${scannerError.message}`);
return [{
type: `${name}_scanner_error`,
url,
severity: 'info',
description: `${name} scanner failed: ${scannerError.message}`,
timestamp: new Date().toISOString()
}];
}
})
);
results.forEach((result, index) => {
if (result.status === 'fulfilled' && Array.isArray(result.value)) {
result.value.forEach(vuln => {
if (vuln && typeof vuln === 'object') {
if (!vuln.url) vuln.url = url;
if (!vuln.timestamp) vuln.timestamp = new Date().toISOString();
this.addResults([vuln]);
}
});
} else if (result.status === 'rejected') {
debug(`Scanner ${modulesToRun[index]} rejected: ${result.reason?.message}`);
}
});
} catch (scanError) {
debug(`Scan URL error: ${scanError.message}`);
this.addResults([{
type: 'scan_url_error',
url,
severity: 'info',
description: `URL scan failed: ${scanError.message}`,
timestamp: new Date().toISOString()
}]);
}
}
addResults(vulns) {
if (!Array.isArray(vulns) || vulns.length === 0) {
return;
}
const validVulns = vulns.filter(v =>
v &&
typeof v === 'object' &&
v.type &&
v.severity &&
['critical', 'high', 'medium', 'low', 'info'].includes(v.severity)
);
if (validVulns.length === 0) return;
this.results.vulnerabilities.push(...validVulns);
validVulns.forEach(v => {
this.results.summary.total++;
this.results.summary[v.severity]++;
});
debug(`Added ${validVulns.length} vulnerabilities. Total: ${this.results.vulnerabilities.length}`);
}
getReport() {
if (!this.results || !this.results.vulnerabilities) {
return {
error: 'No scan results available',
timestamp: new Date().toISOString()
};
}
const sortOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
const sortedVulnerabilities = [...this.results.vulnerabilities].sort((a, b) => {
return (sortOrder[a.severity] || 999) - (sortOrder[b.severity] || 999);
});
return {
...this.results,
vulnerabilities: sortedVulnerabilities,
scannerInfo: {
version: '1.2.1',
timestamp: this.results.timestamp,
scanDuration: this.results.scanDuration,
urlsScanned: this.results.scannedUrls.length
}
};
}
// Generate professional HTML report
generateHTMLReport(options = {}) {
const enhancedHtmlReporter = require('./reporters/enhanced-html-reporter');
const reportData = this.getReport();
return enhancedHtmlReporter.generateReport(reportData, {
title: options.title || 'Web Vulnerability Assessment Report',
includeExecutiveSummary: options.includeExecutiveSummary !== false,
includeRiskMatrix: options.includeRiskMatrix !== false,
includeCompliance: options.includeCompliance !== false,
customBranding: options.customBranding || null
});
}
// Generate JSON report
generateJSONReport() {
return JSON.stringify(this.getReport(), null, 2);
}
// Save report to file
async saveReport(filePath, format = 'html', options = {}) {
const fs = require('fs').promises;
const path = require('path');
let content;
const fileExtension = path.extname(filePath) || `.${format}`;
const finalPath = filePath.endsWith(fileExtension) ? filePath : `${filePath}${fileExtension}`;
switch (format.toLowerCase()) {
case 'html':
content = this.generateHTMLReport(options);
break;
case 'json':
content = this.generateJSONReport();
break;
default:
throw new Error(`Unsupported report format: ${format}`);
}
await fs.writeFile(finalPath, content, 'utf8');
return finalPath;
}
// Cleanup method to properly dispose of resources
async cleanup() {
if (this.crawler && typeof this.crawler.cleanup === 'function') {
await this.crawler.cleanup();
}
// Clear large objects
this.results.vulnerabilities = [];
this.results.scannedUrls = [];
}
}
module.exports = Scanner;