From 5d9284680d11423bff9f3875d71e44f37de24248 Mon Sep 17 00:00:00 2001 From: CryptoMaN-Rahul Date: Sun, 28 Dec 2025 22:10:13 +0530 Subject: [PATCH] feat: Add Security Headers Analyzer feature - Add comprehensive security header analysis for HTTP responses - Integrate Security tab in response pane with real-time analysis - Implement scoring system (0-100) with letter grades (A+ to F) - Add security indicators to request list with colored badges - Support bulk analysis with progress tracking and summary reports - Include export functionality (JSON, Markdown, CSV formats) - Add caching system for performance optimization - Integrate with import/export system for data persistence - Analyze 11 security headers: CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection, Set-Cookie, COOP, COEP, CORP - Provide actionable remediation guidance for security issues - Include comprehensive test suite with 62 security-specific tests Closes: Security analysis requirements for HTTP response evaluation Implements: Real-time security posture assessment for web applications --- .gitignore | 4 + css/panel.css | 550 ++++++++++++++++ js/core/events.js | 2 + js/features/security-headers/analyzer.js | 186 ++++++ js/features/security-headers/bulk.js | 227 +++++++ js/features/security-headers/cache.js | 72 +++ js/features/security-headers/export.js | 119 ++++ js/features/security-headers/index.js | 125 ++++ js/features/security-headers/indicators.js | 150 +++++ js/features/security-headers/rules.js | 381 +++++++++++ js/features/security-headers/ui.js | 528 +++++++++++++++ js/main.js | 2 + js/ui/request-editor.js | 2 +- js/ui/ui-utils.js | 14 +- package-lock.json | 3 + panel.html | 4 + tests/security-headers.test.js | 719 +++++++++++++++++++++ 17 files changed, 3085 insertions(+), 3 deletions(-) create mode 100644 js/features/security-headers/analyzer.js create mode 100644 js/features/security-headers/bulk.js create mode 100644 js/features/security-headers/cache.js create mode 100644 js/features/security-headers/export.js create mode 100644 js/features/security-headers/index.js create mode 100644 js/features/security-headers/indicators.js create mode 100644 js/features/security-headers/rules.js create mode 100644 js/features/security-headers/ui.js create mode 100644 tests/security-headers.test.js diff --git a/.gitignore b/.gitignore index 386d6e1..a1680a3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ coverage/ .zip-files.txt rep-plus-extension.zip +# IDE and development directories +.kiro/ +.vscode/ + diff --git a/css/panel.css b/css/panel.css index 4a0b530..7781a3a 100644 --- a/css/panel.css +++ b/css/panel.css @@ -5135,3 +5135,553 @@ pre { } } + + +/* ========================================== + Security Headers Analyzer Styles + ========================================== */ + +/* Security Analysis Container */ +.security-analysis { + padding: var(--spacing-md); + display: flex; + flex-direction: column; + gap: var(--spacing-md); + height: 100%; + overflow-y: auto; +} + +/* Security Score Display */ +.security-score { + background: var(--sidebar-bg); + border-radius: var(--radius-md); + padding: var(--spacing-md); + border: 1px solid var(--border-color); +} + +.security-score .score-display { + display: flex; + align-items: baseline; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-sm); +} + +.security-score .score-grade { + font-size: 2rem; + font-weight: 700; +} + +.security-score .score-value { + font-size: 1rem; + color: var(--text-color); + opacity: 0.7; +} + +.security-score .score-bar { + height: 8px; + background: var(--border-color); + border-radius: var(--radius-full); + overflow: hidden; +} + +.security-score .score-fill { + height: 100%; + border-radius: var(--radius-full); + transition: width 0.3s ease; +} + +/* Grade Colors */ +.security-score.grade-a .score-grade, +.security-score.grade-a .score-fill { + color: var(--success-color); + background: var(--success-color); +} + +.security-score.grade-b .score-grade, +.security-score.grade-b .score-fill { + color: #fdd663; + background: #fdd663; +} + +.security-score.grade-c .score-grade, +.security-score.grade-c .score-fill { + color: #ffa726; + background: #ffa726; +} + +.security-score.grade-d .score-grade, +.security-score.grade-d .score-fill { + color: #ff7043; + background: #ff7043; +} + +.security-score.grade-f .score-grade, +.security-score.grade-f .score-fill { + color: var(--error-color); + background: var(--error-color); +} + +/* Security Findings */ +.security-findings { + flex: 1; + overflow-y: auto; +} + +.security-findings h4 { + margin: 0 0 var(--spacing-sm) 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-color); + opacity: 0.9; +} + +.security-finding { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + padding: var(--spacing-sm); + border-radius: var(--radius-sm); + margin-bottom: var(--spacing-xs); + background: var(--sidebar-bg); + border: 1px solid var(--border-color); +} + +.security-finding .finding-icon { + flex-shrink: 0; + font-size: 0.875rem; +} + +.security-finding .finding-header { + font-weight: 600; + font-size: 0.8125rem; + color: var(--text-color); + min-width: 180px; +} + +.security-finding .finding-value { + font-size: 0.75rem; + color: var(--text-color); + opacity: 0.7; + word-break: break-all; + flex: 1; +} + +.security-finding .finding-message { + font-size: 0.75rem; + color: var(--text-color); + opacity: 0.8; + flex: 1; +} + +.security-finding.secure { + border-left: 3px solid var(--success-color); +} + +.security-finding.warning { + border-left: 3px solid #ffa726; +} + +.security-finding.missing { + border-left: 3px solid var(--error-color); +} + +/* Security Actions */ +.security-actions { + display: flex; + gap: var(--spacing-sm); + padding-top: var(--spacing-sm); + border-top: 1px solid var(--border-color); +} + +.security-actions .action-btn { + padding: var(--spacing-sm) var(--spacing-md); + background: var(--sidebar-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-color); + font-size: 0.8125rem; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.security-actions .action-btn:hover { + background: var(--hover-bg); + border-color: var(--accent-color); +} + +/* Export Dropdown */ +.export-dropdown { + position: relative; +} + +.export-menu { + position: absolute; + bottom: 100%; + left: 0; + background: var(--sidebar-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + display: none; + min-width: 120px; + margin-bottom: var(--spacing-xs); + z-index: 100; +} + +.export-menu.visible { + display: block; +} + +.export-option { + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + font-size: 0.8125rem; + transition: background 0.2s ease; +} + +.export-option:hover { + background: var(--hover-bg); +} + +.export-option:first-child { + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} + +.export-option:last-child { + border-radius: 0 0 var(--radius-sm) var(--radius-sm); +} + +/* Empty State */ +.security-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-color); + opacity: 0.6; + text-align: center; + padding: var(--spacing-xl); +} + +.security-empty-state .empty-icon { + font-size: 3rem; + margin-bottom: var(--spacing-md); +} + +.security-empty-state p { + margin: 0; + font-size: 0.875rem; +} + +/* Security View Content */ +#res-view-security { + flex-direction: column; + height: 100%; +} + +#res-view-security.active { + display: flex; +} + + +/* Security Indicators for Request List */ +.security-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; + padding: 2px 4px; + border-radius: 3px; + margin-left: 4px; + margin-right: 4px; + min-width: 18px; + text-align: center; + flex-shrink: 0; + cursor: default; + white-space: pre-line; +} + +.security-indicator.grade-a { + background: rgba(129, 201, 149, 0.25); + color: #81c995; + border: 1px solid rgba(129, 201, 149, 0.4); +} + +.security-indicator.grade-b { + background: rgba(253, 214, 99, 0.25); + color: #fdd663; + border: 1px solid rgba(253, 214, 99, 0.4); +} + +.security-indicator.grade-c { + background: rgba(255, 167, 38, 0.25); + color: #ffa726; + border: 1px solid rgba(255, 167, 38, 0.4); +} + +.security-indicator.grade-d { + background: rgba(255, 112, 67, 0.25); + color: #ff7043; + border: 1px solid rgba(255, 112, 67, 0.4); +} + +.security-indicator.grade-f { + background: rgba(242, 139, 130, 0.25); + color: #f28b82; + border: 1px solid rgba(242, 139, 130, 0.4); +} + +/* Light theme overrides for security indicators */ +.light-theme .security-indicator.grade-a { + background: #e6ffec; + color: #137333; + border-color: #a8dab5; +} + +.light-theme .security-indicator.grade-b { + background: #fef7e0; + color: #b06000; + border-color: #f5d96a; +} + +.light-theme .security-indicator.grade-c { + background: #fff3e0; + color: #e65100; + border-color: #ffcc80; +} + +.light-theme .security-indicator.grade-d { + background: #fbe9e7; + color: #d84315; + border-color: #ffab91; +} + +.light-theme .security-indicator.grade-f { + background: #fce8e6; + color: #c5221f; + border-color: #f5a9a9; +} + + +/* Bulk Analysis Modal */ +.bulk-analysis-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.bulk-modal-content { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + width: 90%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); +} + +.bulk-modal-content .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-md); + border-bottom: 1px solid var(--border-color); +} + +.bulk-modal-content .modal-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.bulk-modal-content .modal-close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-color); + opacity: 0.6; + line-height: 1; +} + +.bulk-modal-content .modal-close-btn:hover { + opacity: 1; +} + +.bulk-modal-body { + padding: var(--spacing-md); + overflow-y: auto; + flex: 1; +} + +.bulk-modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--spacing-sm); + padding: var(--spacing-md); + border-top: 1px solid var(--border-color); +} + +/* Bulk Progress */ +.bulk-progress-section { + text-align: center; +} + +.bulk-progress-text { + margin: 0 0 var(--spacing-md) 0; + font-size: 0.875rem; +} + +.bulk-progress-bar { + height: 8px; + background: var(--border-color); + border-radius: var(--radius-full); + overflow: hidden; + margin-bottom: var(--spacing-sm); +} + +.bulk-progress-fill { + height: 100%; + background: var(--accent-color); + border-radius: var(--radius-full); + transition: width 0.3s ease; +} + +.bulk-progress-count { + margin: 0; + font-size: 0.75rem; + color: var(--text-color); + opacity: 0.7; +} + +/* Bulk Results */ +.bulk-summary { + display: flex; + gap: var(--spacing-lg); + justify-content: center; + margin-bottom: var(--spacing-lg); +} + +.summary-stat { + text-align: center; +} + +.summary-stat .stat-value { + display: block; + font-size: 2rem; + font-weight: 700; + line-height: 1.2; +} + +.summary-stat .stat-label { + font-size: 0.75rem; + color: var(--text-color); + opacity: 0.7; +} + +.bulk-section { + margin-bottom: var(--spacing-md); +} + +.bulk-section h4 { + margin: 0 0 var(--spacing-sm) 0; + font-size: 0.875rem; + font-weight: 600; +} + +/* Grade Distribution */ +.grade-distribution { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-xs); +} + +.grade-badge { + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; +} + +.grade-badge.grade-a { + background: rgba(76, 175, 80, 0.2); + color: var(--success-color); +} + +.grade-badge.grade-b, +.grade-badge.grade-c { + background: rgba(255, 167, 38, 0.2); + color: #ffa726; +} + +.grade-badge.grade-d, +.grade-badge.grade-f { + background: rgba(244, 67, 54, 0.2); + color: var(--error-color); +} + +/* Common Issues List */ +.common-issues-list { + margin: 0; + padding-left: var(--spacing-lg); + font-size: 0.8125rem; +} + +.common-issues-list li { + margin-bottom: var(--spacing-xs); +} + +/* Performers List */ +.performers-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.performer-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--sidebar-bg); + border-radius: var(--radius-sm); + font-size: 0.8125rem; +} + +.performer-item .performer-grade { + font-weight: 600; + min-width: 60px; +} + +.performer-item .performer-path { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; +} + +.performer-item.grade-a .performer-grade { + color: var(--success-color); +} + +.performer-item.grade-b .performer-grade, +.performer-item.grade-c .performer-grade { + color: #ffa726; +} + +.performer-item.grade-d .performer-grade, +.performer-item.grade-f .performer-grade { + color: var(--error-color); +} diff --git a/js/core/events.js b/js/core/events.js index c63511e..2f69743 100644 --- a/js/core/events.js +++ b/js/core/events.js @@ -124,5 +124,7 @@ export const EVENT_NAMES = { // Export/Import events REQUESTS_EXPORTED: 'requests:exported', REQUESTS_IMPORTED: 'requests:imported', + EXPORT_REQUEST_DATA: 'export:request-data', + IMPORT_REQUEST_DATA: 'import:request-data', }; diff --git a/js/features/security-headers/analyzer.js b/js/features/security-headers/analyzer.js new file mode 100644 index 0000000..f501ad3 --- /dev/null +++ b/js/features/security-headers/analyzer.js @@ -0,0 +1,186 @@ +import { HEADER_RULES, getRemediation } from './rules.js'; +import { getCachedAnalysis, setCachedAnalysis } from './cache.js'; + +/** + * @typedef {Object} Finding + * @property {string} header - Header name + * @property {string} value - Header value (if present) + * @property {'secure' | 'warning' | 'missing'} status + * @property {string} message - Human-readable description + */ + +/** + * @typedef {Object} Findings + * @property {Finding[]} present - Headers present and properly configured + * @property {string[]} missing - Names of missing headers + * @property {Finding[]} warnings - Headers with configuration issues + */ + +/** + * @typedef {Object} Remediation + * @property {string} header - Header name + * @property {'high' | 'medium' | 'low'} priority + * @property {string} fix - Recommended header value + * @property {string} description - Why this fix is recommended + */ + +/** + * @typedef {Object} AnalysisResult + * @property {string} url - Analyzed URL + * @property {string} timestamp - ISO 8601 timestamp + * @property {number} score - Security score (0-100) + * @property {string} grade - Letter grade (A+ to F) + * @property {Findings} findings - Categorized findings + * @property {Remediation[]} recommendations - Ordered remediation list + */ + +/** + * Parse headers array into a normalized map + * @param {Array<{name: string, value: string}>} headers + * @returns {Map} Lowercase header name to value + */ +export function parseHeaders(headers) { + const headerMap = new Map(); + + if (!headers || !Array.isArray(headers)) { + return headerMap; + } + + for (const header of headers) { + if (header && header.name) { + const normalizedName = header.name.toLowerCase(); + // For Set-Cookie, we may have multiple values - concatenate them + if (normalizedName === 'set-cookie' && headerMap.has(normalizedName)) { + headerMap.set(normalizedName, headerMap.get(normalizedName) + '; ' + (header.value || '')); + } else { + headerMap.set(normalizedName, header.value || ''); + } + } + } + + return headerMap; +} + +/** + * Calculate security score from findings + * @param {Findings} findings - Categorized findings + * @returns {{score: number, grade: string}} + */ +export function calculateScore(findings) { + let score = 100; + + // Calculate deductions from missing headers and warnings + // The deductions are tracked in the findings via the rules + for (const headerName of findings.missing) { + const rule = HEADER_RULES.find(r => r.name.toLowerCase() === headerName.toLowerCase()); + if (rule) { + score -= rule.missingWeight; + } + } + + for (const warning of findings.warnings) { + const rule = HEADER_RULES.find(r => r.name.toLowerCase() === warning.header.toLowerCase()); + if (rule && warning.deduction) { + score -= warning.deduction; + } + } + + // Ensure score is within bounds + score = Math.max(0, Math.min(100, score)); + + return { + score, + grade: scoreToGrade(score) + }; +} + +/** + * Convert score to letter grade + * @param {number} score - Score from 0-100 + * @returns {string} Letter grade (A+ to F) + */ +export function scoreToGrade(score) { + if (score >= 95) return 'A+'; + if (score >= 90) return 'A'; + if (score >= 80) return 'B'; + if (score >= 70) return 'C'; + if (score >= 60) return 'D'; + return 'F'; +} + +/** + * Analyze security headers from a response + * @param {Object} options - Analysis options + * @param {Array<{name: string, value: string}>} options.headers - Response headers + * @param {string} options.url - Request URL + * @returns {AnalysisResult} Complete analysis result + */ +export function analyzeSecurityHeaders({ headers, url }) { + const cached = getCachedAnalysis(headers); + if (cached) { + return { + ...cached, + url: url || 'unknown', + timestamp: new Date().toISOString() + }; + } + + const headerMap = parseHeaders(headers); + + const findings = { + present: [], + missing: [], + warnings: [] + }; + + const recommendations = []; + + for (const rule of HEADER_RULES) { + const headerValue = headerMap.get(rule.name.toLowerCase()); + const evaluation = rule.evaluate(headerValue); + + if (evaluation.status === 'secure') { + findings.present.push({ + header: rule.name, + value: headerValue, + status: 'secure', + message: evaluation.message + }); + } else if (evaluation.status === 'missing') { + findings.missing.push(rule.name); + recommendations.push(getRemediation(rule.name)); + } else if (evaluation.status === 'warning') { + findings.warnings.push({ + header: rule.name, + value: headerValue, + status: 'warning', + message: evaluation.message, + deduction: evaluation.deduction + }); + recommendations.push(getRemediation(rule.name)); + } + } + + const priorityOrder = { high: 0, medium: 1, low: 2 }; + recommendations.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + + const { score, grade } = calculateScore(findings); + + const result = { + url: url || 'unknown', + timestamp: new Date().toISOString(), + score, + grade, + findings, + recommendations + }; + + setCachedAnalysis(headers, { + score, + grade, + findings, + recommendations + }); + + return result; +} diff --git a/js/features/security-headers/bulk.js b/js/features/security-headers/bulk.js new file mode 100644 index 0000000..d0ff093 --- /dev/null +++ b/js/features/security-headers/bulk.js @@ -0,0 +1,227 @@ +// Security Headers Analyzer - Bulk Analysis Module + +import { analyzeSecurityHeaders } from './analyzer.js'; + +/** + * @typedef {Object} BulkAnalysisReport + * @property {number} totalRequests - Total number of requests analyzed + * @property {number} averageScore - Average security score across all requests + * @property {Object} gradeDistribution - Count of requests by grade + * @property {Array} commonIssues - Most frequent missing/misconfigured headers + * @property {Array} worstPerformers - Requests with lowest scores + * @property {Array} bestPerformers - Requests with highest scores + * @property {string} timestamp - Report generation timestamp + * @property {Array} results - All analysis results + */ + +/** + * Analyze security headers for multiple requests + * @param {Array} requests - Array of request objects + * @param {function} progressCallback - Progress update callback + * @returns {Promise>} Analysis results + */ +export async function analyzeBulkRequests(requests, progressCallback) { + const results = []; + const batchSize = 10; + + for (let i = 0; i < requests.length; i += batchSize) { + const batch = requests.slice(i, i + batchSize); + + for (const request of batch) { + if (request.responseHeaders && request.responseHeaders.length > 0) { + const result = analyzeSecurityHeaders({ + headers: request.responseHeaders, + url: request.request?.url || 'unknown' + }); + results.push({ + ...result, + requestIndex: requests.indexOf(request), + method: request.request?.method || 'GET', + path: getPathFromUrl(request.request?.url) + }); + } + } + + // Report progress + if (progressCallback) { + progressCallback({ + completed: Math.min(i + batchSize, requests.length), + total: requests.length + }); + } + + // Yield to browser between batches + if (i + batchSize < requests.length) { + await new Promise(resolve => setTimeout(resolve, 16)); + } + } + + return results; +} + +/** + * Generate bulk analysis report from results + * @param {Array} results - Analysis results + * @returns {BulkAnalysisReport} + */ +export function generateBulkReport(results) { + if (!results || results.length === 0) { + return { + totalRequests: 0, + averageScore: 0, + gradeDistribution: {}, + commonIssues: [], + worstPerformers: [], + bestPerformers: [], + timestamp: new Date().toISOString(), + results: [] + }; + } + + // Calculate average score + const totalScore = results.reduce((sum, r) => sum + r.score, 0); + const averageScore = Math.round(totalScore / results.length); + + // Grade distribution + const gradeDistribution = {}; + for (const result of results) { + gradeDistribution[result.grade] = (gradeDistribution[result.grade] || 0) + 1; + } + + // Common issues (missing headers) + const issueCounts = {}; + for (const result of results) { + for (const missing of result.findings.missing) { + issueCounts[`Missing: ${missing}`] = (issueCounts[`Missing: ${missing}`] || 0) + 1; + } + for (const warning of result.findings.warnings) { + issueCounts[`Weak: ${warning.header}`] = (issueCounts[`Weak: ${warning.header}`] || 0) + 1; + } + } + + // Sort issues by frequency + const commonIssues = Object.entries(issueCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([issue, count]) => `${issue} (${count})`); + + // Sort by score for best/worst performers + const sorted = [...results].sort((a, b) => a.score - b.score); + const worstPerformers = sorted.slice(0, 3); + const bestPerformers = sorted.slice(-3).reverse(); + + return { + totalRequests: results.length, + averageScore, + gradeDistribution, + commonIssues, + worstPerformers, + bestPerformers, + timestamp: new Date().toISOString(), + results + }; +} + +/** + * Export bulk report in specified format + * @param {BulkAnalysisReport} report + * @param {'json' | 'markdown' | 'csv'} format + * @returns {string} + */ +export function exportBulkReport(report, format) { + switch (format) { + case 'json': + return JSON.stringify(report, null, 2); + case 'markdown': + return bulkReportToMarkdown(report); + case 'csv': + return bulkReportToCSV(report); + default: + return JSON.stringify(report, null, 2); + } +} + +/** + * Convert bulk report to Markdown + * @param {BulkAnalysisReport} report + * @returns {string} + */ +function bulkReportToMarkdown(report) { + let md = `# Bulk Security Headers Analysis Report\n\n`; + md += `**Generated:** ${report.timestamp}\n`; + md += `**Total Requests Analyzed:** ${report.totalRequests}\n`; + md += `**Average Score:** ${report.averageScore}/100\n\n`; + + md += `## Grade Distribution\n\n`; + md += `| Grade | Count |\n|-------|-------|\n`; + for (const [grade, count] of Object.entries(report.gradeDistribution)) { + md += `| ${grade} | ${count} |\n`; + } + md += '\n'; + + if (report.commonIssues.length > 0) { + md += `## Common Issues\n\n`; + for (const issue of report.commonIssues) { + md += `- ${issue}\n`; + } + md += '\n'; + } + + if (report.worstPerformers.length > 0) { + md += `## Worst Performers\n\n`; + for (const result of report.worstPerformers) { + md += `- **${result.grade} (${result.score})** - ${result.method} ${result.path}\n`; + } + md += '\n'; + } + + if (report.bestPerformers.length > 0) { + md += `## Best Performers\n\n`; + for (const result of report.bestPerformers) { + md += `- **${result.grade} (${result.score})** - ${result.method} ${result.path}\n`; + } + md += '\n'; + } + + return md; +} + +/** + * Convert bulk report to CSV + * @param {BulkAnalysisReport} report + * @returns {string} + */ +function bulkReportToCSV(report) { + const headers = ['url', 'method', 'path', 'score', 'grade', 'present_count', 'missing_count', 'warning_count']; + let csv = headers.join(',') + '\n'; + + for (const result of report.results) { + const values = [ + `"${result.url}"`, + result.method, + `"${result.path}"`, + result.score, + result.grade, + result.findings.present.length, + result.findings.missing.length, + result.findings.warnings.length + ]; + csv += values.join(',') + '\n'; + } + + return csv; +} + +/** + * Extract path from URL + * @param {string} url + * @returns {string} + */ +function getPathFromUrl(url) { + try { + const urlObj = new URL(url); + return urlObj.pathname + urlObj.search; + } catch { + return url || '/'; + } +} diff --git a/js/features/security-headers/cache.js b/js/features/security-headers/cache.js new file mode 100644 index 0000000..a3861ef --- /dev/null +++ b/js/features/security-headers/cache.js @@ -0,0 +1,72 @@ +const MAX_CACHE_SIZE = 100; + +const analysisCache = new Map(); +const cacheAccessOrder = []; + +export function getHeaderHash(headers) { + if (!headers || !Array.isArray(headers) || headers.length === 0) { + return 'empty'; + } + + const normalized = headers + .filter(h => h && h.name) + .map(h => `${h.name.toLowerCase()}:${h.value || ''}`) + .sort() + .join('|'); + + if (!normalized) { + return 'empty'; + } + + return btoa(unescape(encodeURIComponent(normalized))).slice(0, 32); +} + +function updateAccessOrder(key) { + const index = cacheAccessOrder.indexOf(key); + if (index > -1) { + cacheAccessOrder.splice(index, 1); + } + cacheAccessOrder.push(key); +} + +function evictLRU() { + while (cacheAccessOrder.length > MAX_CACHE_SIZE) { + const oldestKey = cacheAccessOrder.shift(); + analysisCache.delete(oldestKey); + } +} + +export function getCachedAnalysis(headers) { + const hash = getHeaderHash(headers); + + if (analysisCache.has(hash)) { + updateAccessOrder(hash); + return analysisCache.get(hash); + } + + return null; +} + +export function setCachedAnalysis(headers, result) { + const hash = getHeaderHash(headers); + + analysisCache.set(hash, result); + updateAccessOrder(hash); + evictLRU(); +} + +export function clearCache() { + analysisCache.clear(); + cacheAccessOrder.length = 0; +} + +export function getCacheSize() { + return analysisCache.size; +} + +export function getCacheStats() { + return { + size: analysisCache.size, + maxSize: MAX_CACHE_SIZE + }; +} diff --git a/js/features/security-headers/export.js b/js/features/security-headers/export.js new file mode 100644 index 0000000..166f837 --- /dev/null +++ b/js/features/security-headers/export.js @@ -0,0 +1,119 @@ +// Security Headers Analyzer - Export Functionality + +/** + * Export analysis result in specified format + * @param {AnalysisResult} result + * @param {'json' | 'markdown' | 'csv'} format + * @returns {string} Formatted export content + */ +export function exportSecurityReport(result, format) { + switch (format) { + case 'json': + return toJSON(result); + case 'markdown': + return toMarkdown(result); + case 'csv': + return toCSV(result); + default: + return toJSON(result); + } +} + +/** + * Generate JSON export + * @param {AnalysisResult} result + * @returns {string} + */ +export function toJSON(result) { + return JSON.stringify(result, null, 2); +} + +/** + * Generate Markdown export + * @param {AnalysisResult} result + * @returns {string} + */ +export function toMarkdown(result) { + let md = `# Security Headers Analysis Report\n\n`; + md += `**URL:** ${result.url}\n`; + md += `**Timestamp:** ${result.timestamp}\n`; + md += `**Score:** ${result.score}/100 (${result.grade})\n\n`; + + md += `## Summary\n\n`; + md += `- ✅ Secure Headers: ${result.findings.present.length}\n`; + md += `- ⚠️ Warnings: ${result.findings.warnings.length}\n`; + md += `- ❌ Missing Headers: ${result.findings.missing.length}\n\n`; + + if (result.findings.present.length > 0) { + md += `## Secure Headers\n\n`; + for (const finding of result.findings.present) { + md += `- ✅ **${finding.header}**: ${finding.value || 'Present'}\n`; + } + md += '\n'; + } + + if (result.findings.warnings.length > 0) { + md += `## Warnings\n\n`; + for (const finding of result.findings.warnings) { + md += `- ⚠️ **${finding.header}**: ${finding.message}\n`; + } + md += '\n'; + } + + if (result.findings.missing.length > 0) { + md += `## Missing Headers\n\n`; + for (const headerName of result.findings.missing) { + md += `- ❌ **${headerName}**\n`; + } + md += '\n'; + } + + if (result.recommendations.length > 0) { + md += `## Recommendations\n\n`; + for (const rec of result.recommendations) { + const priorityIcon = rec.priority === 'high' ? '🔴' : rec.priority === 'medium' ? '🟡' : '🟢'; + md += `### ${priorityIcon} ${rec.header} (${rec.priority} priority)\n\n`; + md += `${rec.description}\n\n`; + md += `**Recommended:**\n\`\`\`\n${rec.fix}\n\`\`\`\n\n`; + } + } + + return md; +} + +/** + * Generate CSV export + * @param {AnalysisResult} result + * @returns {string} + */ +export function toCSV(result) { + const headers = ['url', 'score', 'grade', 'present_count', 'missing_count', 'warning_count']; + const values = [ + `"${result.url}"`, + result.score, + result.grade, + result.findings.present.length, + result.findings.missing.length, + result.findings.warnings.length + ]; + + return headers.join(',') + '\n' + values.join(','); +} + +/** + * Enhance request export data with security analysis + * @param {Object} requestData - Original request export data + * @param {AnalysisResult} securityAnalysis - Security analysis result + * @returns {Object} Enhanced request data + */ +export function enhanceRequestExport(requestData, securityAnalysis) { + return { + ...requestData, + securityAnalysis: { + score: securityAnalysis.score, + grade: securityAnalysis.grade, + findings: securityAnalysis.findings, + recommendations: securityAnalysis.recommendations + } + }; +} diff --git a/js/features/security-headers/index.js b/js/features/security-headers/index.js new file mode 100644 index 0000000..7e9078b --- /dev/null +++ b/js/features/security-headers/index.js @@ -0,0 +1,125 @@ +// Security Headers Analyzer - Entry Point +// Evaluates HTTP response headers against security best practices + +import { events, EVENT_NAMES } from '../../core/events.js'; +import { state } from '../../core/state.js'; +import { analyzeSecurityHeaders } from './analyzer.js'; +import { renderSecurityView, showEmptyState, updateSecurityTab } from './ui.js'; +import { exportSecurityReport, enhanceRequestExport } from './export.js'; +import { createSecurityIndicator, addSecurityIndicatorToItem } from './indicators.js'; +import { clearCache, getCacheStats } from './cache.js'; + +export { analyzeSecurityHeaders } from './analyzer.js'; +export { exportSecurityReport, enhanceRequestExport } from './export.js'; +export { createSecurityIndicator, addSecurityIndicatorToItem } from './indicators.js'; +export { getHeaderHash, getCachedAnalysis, setCachedAnalysis, clearCache, getCacheSize, getCacheStats } from './cache.js'; + +let currentAnalysis = null; + +export function setupSecurityHeaders(elements) { + const securityContainer = document.getElementById('res-view-security'); + + if (!securityContainer) { + console.warn('Security Headers: Security tab container not found'); + return; + } + + events.on('ui:request-selected', ({ request }) => { + if (request && request.responseHeaders && request.responseHeaders.length > 0) { + const result = analyzeSecurityHeaders({ + headers: request.responseHeaders, + url: request.request?.url || 'unknown' + }); + currentAnalysis = result; + + const securityTab = document.querySelector('.view-tab[data-view="security"][data-pane="response"]'); + if (securityTab && securityTab.classList.contains('active')) { + renderSecurityView(result, securityContainer); + } + } else { + currentAnalysis = null; + const securityTab = document.querySelector('.view-tab[data-view="security"][data-pane="response"]'); + if (securityTab && securityTab.classList.contains('active')) { + showEmptyState(securityContainer); + } + } + }); + + events.on(EVENT_NAMES.REQUEST_RENDERED, ({ request, index }) => { + setTimeout(() => { + addSecurityIndicatorToRequestItem(request, index); + }, 0); + }); + + events.on(EVENT_NAMES.UI_VIEW_SWITCHED, ({ pane, view }) => { + if (pane === 'response' && view === 'security') { + if (currentAnalysis) { + renderSecurityView(currentAnalysis, securityContainer); + } else if (state.selectedRequest && state.selectedRequest.responseHeaders) { + const result = analyzeSecurityHeaders({ + headers: state.selectedRequest.responseHeaders, + url: state.selectedRequest.request?.url || 'unknown' + }); + currentAnalysis = result; + renderSecurityView(result, securityContainer); + } else { + showEmptyState(securityContainer); + } + } + }); + + events.on(EVENT_NAMES.UI_UPDATE_RESPONSE_VIEW, () => { + const securityTab = document.querySelector('.view-tab[data-view="security"][data-pane="response"]'); + if (securityTab && securityTab.classList.contains('active') && state.selectedRequest) { + if (state.selectedRequest.responseHeaders && state.selectedRequest.responseHeaders.length > 0) { + const result = analyzeSecurityHeaders({ + headers: state.selectedRequest.responseHeaders, + url: state.selectedRequest.request?.url || 'unknown' + }); + currentAnalysis = result; + renderSecurityView(result, securityContainer); + } else { + currentAnalysis = null; + showEmptyState(securityContainer); + } + } + }); + + events.on(EVENT_NAMES.EXPORT_REQUEST_DATA, (data) => { + const { requestData, originalRequest } = data; + + if (originalRequest && originalRequest.responseHeaders && originalRequest.responseHeaders.length > 0) { + const analysis = analyzeSecurityHeaders({ + headers: originalRequest.responseHeaders, + url: originalRequest.request?.url || requestData.url || 'unknown' + }); + + data.requestData = enhanceRequestExport(requestData, analysis); + } + }); + + events.on(EVENT_NAMES.IMPORT_REQUEST_DATA, ({ importedData, newRequest }) => { + if (importedData.securityAnalysis) { + newRequest.securityAnalysis = importedData.securityAnalysis; + } + }); + + showEmptyState(securityContainer); +} + +function addSecurityIndicatorToRequestItem(request, index) { + if (!request || !request.responseHeaders || request.responseHeaders.length === 0) { + return; + } + + const requestItem = document.querySelector(`.request-item[data-index="${index}"]`); + if (!requestItem) { + return; + } + + addSecurityIndicatorToItem(requestItem, request); +} + +export function getCurrentAnalysis() { + return currentAnalysis; +} diff --git a/js/features/security-headers/indicators.js b/js/features/security-headers/indicators.js new file mode 100644 index 0000000..382af42 --- /dev/null +++ b/js/features/security-headers/indicators.js @@ -0,0 +1,150 @@ +// Security Headers Analyzer - Request List Security Indicators + +import { analyzeSecurityHeaders } from './analyzer.js'; + +/** + * @typedef {Object} SecurityIndicator + * @property {string} grade - Letter grade (A+ to F) + * @property {number} score - Numeric score (0-100) + * @property {string} colorClass - CSS color class + * @property {string} tooltip - Hover text with summary + */ + +/** + * Get CSS class for grade color + * @param {string} grade - Letter grade (A+ to F) + * @returns {string} CSS class name + */ +function getGradeColorClass(grade) { + if (grade === 'A+' || grade === 'A') return 'grade-a'; + if (grade === 'B') return 'grade-b'; + if (grade === 'C') return 'grade-c'; + if (grade === 'D') return 'grade-d'; + return 'grade-f'; +} + +/** + * Generate tooltip text from analysis result + * @param {Object} analysis - Security analysis result + * @returns {string} Tooltip text + */ +function generateTooltip(analysis) { + const { score, grade, findings } = analysis; + const presentCount = findings.present.length; + const missingCount = findings.missing.length; + const warningCount = findings.warnings.length; + + return `Security: ${grade} (${score}/100)\n` + + `✅ ${presentCount} secure | ⚠️ ${warningCount} warnings | ❌ ${missingCount} missing`; +} + +/** + * Generate security indicator for request list item + * @param {Object} analysis - Security analysis result + * @returns {HTMLElement} Security indicator element + */ +export function createSecurityIndicator(analysis) { + if (!analysis || typeof analysis.grade !== 'string') { + return null; + } + + const { grade, score } = analysis; + const colorClass = getGradeColorClass(grade); + const tooltip = generateTooltip(analysis); + + const indicator = document.createElement('span'); + indicator.className = `security-indicator ${colorClass}`; + indicator.textContent = grade; + indicator.title = tooltip; + + // Store score as data attribute for potential sorting/filtering + indicator.dataset.score = score; + indicator.dataset.grade = grade; + + return indicator; +} + +/** + * Analyze request and create security indicator + * @param {Object} request - Request object with responseHeaders + * @returns {HTMLElement|null} Security indicator element or null if no response headers + */ +export function createSecurityIndicatorForRequest(request) { + if (!request || !request.responseHeaders || request.responseHeaders.length === 0) { + return null; + } + + const analysis = analyzeSecurityHeaders({ + headers: request.responseHeaders, + url: request.request?.url || 'unknown' + }); + + return createSecurityIndicator(analysis); +} + +/** + * Add security indicator to a request item element + * @param {HTMLElement} requestItem - The request item DOM element + * @param {Object} request - The request object + * @returns {boolean} True if indicator was added, false otherwise + */ +export function addSecurityIndicatorToItem(requestItem, request) { + if (!requestItem || !request) { + return false; + } + + // Remove existing indicator if present + const existingIndicator = requestItem.querySelector('.security-indicator'); + if (existingIndicator) { + existingIndicator.remove(); + } + + const indicator = createSecurityIndicatorForRequest(request); + if (!indicator) { + return false; + } + + // Insert indicator after the method span + const methodSpan = requestItem.querySelector('.req-method'); + if (methodSpan && methodSpan.nextSibling) { + requestItem.insertBefore(indicator, methodSpan.nextSibling); + } else { + // Fallback: append to the item + requestItem.appendChild(indicator); + } + + return true; +} + +/** + * Update security indicator for a request item + * @param {HTMLElement} requestItem - The request item DOM element + * @param {Object} analysis - Pre-computed analysis result + * @returns {boolean} True if indicator was updated, false otherwise + */ +export function updateSecurityIndicator(requestItem, analysis) { + if (!requestItem || !analysis) { + return false; + } + + // Remove existing indicator + const existingIndicator = requestItem.querySelector('.security-indicator'); + if (existingIndicator) { + existingIndicator.remove(); + } + + const indicator = createSecurityIndicator(analysis); + if (!indicator) { + return false; + } + + // Insert indicator after the method span + const methodSpan = requestItem.querySelector('.req-method'); + if (methodSpan && methodSpan.nextSibling) { + requestItem.insertBefore(indicator, methodSpan.nextSibling); + } else { + requestItem.appendChild(indicator); + } + + return true; +} diff --git a/js/features/security-headers/rules.js b/js/features/security-headers/rules.js new file mode 100644 index 0000000..27fabea --- /dev/null +++ b/js/features/security-headers/rules.js @@ -0,0 +1,381 @@ +// Security Headers Analyzer - Header Rules Definitions + +/** + * @typedef {Object} EvaluationResult + * @property {'secure' | 'warning' | 'missing'} status + * @property {string} message + * @property {number} deduction - Points to deduct from score + */ + +/** + * @typedef {Object} HeaderRule + * @property {string} name - Header name (case-insensitive matching) + * @property {string} description - What this header protects against + * @property {number} missingWeight - Points deducted if missing + * @property {number} weakWeight - Points deducted if misconfigured + * @property {'high' | 'medium' | 'low'} priority + * @property {function(string|null): EvaluationResult} evaluate + * @property {string} recommendedValue - Best practice value + */ + +/** + * Security header rule definitions + * @type {HeaderRule[]} + */ +export const HEADER_RULES = [ + { + name: 'Content-Security-Policy', + description: 'Prevents XSS attacks by controlling resource loading', + missingWeight: 20, + weakWeight: 15, + priority: 'high', + recommendedValue: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'self'", + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'Content-Security-Policy header is missing', deduction: 0 }; + } + + const lowerValue = value.toLowerCase(); + const hasUnsafeInline = lowerValue.includes("'unsafe-inline'"); + const hasUnsafeEval = lowerValue.includes("'unsafe-eval'"); + + if (hasUnsafeInline && hasUnsafeEval) { + return { + status: 'warning', + message: "CSP contains both 'unsafe-inline' and 'unsafe-eval' directives", + deduction: 15 + }; + } + if (hasUnsafeInline) { + return { + status: 'warning', + message: "CSP contains 'unsafe-inline' directive", + deduction: 15 + }; + } + if (hasUnsafeEval) { + return { + status: 'warning', + message: "CSP contains 'unsafe-eval' directive", + deduction: 15 + }; + } + + return { status: 'secure', message: 'Content-Security-Policy is properly configured', deduction: 0 }; + } + }, + { + name: 'Strict-Transport-Security', + description: 'Enforces HTTPS connections', + missingWeight: 15, + weakWeight: 10, + priority: 'high', + recommendedValue: 'max-age=31536000; includeSubDomains; preload', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'Strict-Transport-Security header is missing', deduction: 0 }; + } + + // Extract max-age value + const maxAgeMatch = value.match(/max-age=(\d+)/i); + if (!maxAgeMatch) { + return { + status: 'warning', + message: 'HSTS header missing max-age directive', + deduction: 10 + }; + } + + const maxAge = parseInt(maxAgeMatch[1], 10); + const oneYear = 31536000; + + if (maxAge < oneYear) { + return { + status: 'warning', + message: `HSTS max-age (${maxAge}s) is less than recommended 1 year (${oneYear}s)`, + deduction: 10 + }; + } + + return { status: 'secure', message: 'Strict-Transport-Security is properly configured', deduction: 0 }; + } + }, + { + name: 'X-Frame-Options', + description: 'Prevents clickjacking attacks', + missingWeight: 10, + weakWeight: 5, + priority: 'medium', + recommendedValue: 'DENY', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'X-Frame-Options header is missing', deduction: 0 }; + } + + const upperValue = value.toUpperCase().trim(); + if (upperValue === 'DENY' || upperValue === 'SAMEORIGIN') { + return { status: 'secure', message: `X-Frame-Options is set to ${upperValue}`, deduction: 0 }; + } + + if (upperValue.startsWith('ALLOW-FROM')) { + return { + status: 'warning', + message: 'X-Frame-Options ALLOW-FROM is deprecated and not supported by all browsers', + deduction: 5 + }; + } + + return { + status: 'warning', + message: `X-Frame-Options has unexpected value: ${value}`, + deduction: 5 + }; + } + }, + { + name: 'X-Content-Type-Options', + description: 'Prevents MIME type sniffing', + missingWeight: 5, + weakWeight: 5, + priority: 'medium', + recommendedValue: 'nosniff', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'X-Content-Type-Options header is missing', deduction: 0 }; + } + + if (value.toLowerCase().trim() === 'nosniff') { + return { status: 'secure', message: 'X-Content-Type-Options is set to nosniff', deduction: 0 }; + } + + return { + status: 'warning', + message: `X-Content-Type-Options has unexpected value: ${value}`, + deduction: 5 + }; + } + }, + { + name: 'Referrer-Policy', + description: 'Controls referrer information sent with requests', + missingWeight: 5, + weakWeight: 5, + priority: 'medium', + recommendedValue: 'strict-origin-when-cross-origin', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'Referrer-Policy header is missing', deduction: 0 }; + } + + const secureValues = [ + 'no-referrer', + 'no-referrer-when-downgrade', + 'origin', + 'origin-when-cross-origin', + 'same-origin', + 'strict-origin', + 'strict-origin-when-cross-origin' + ]; + + const lowerValue = value.toLowerCase().trim(); + if (secureValues.includes(lowerValue)) { + return { status: 'secure', message: `Referrer-Policy is set to ${value}`, deduction: 0 }; + } + + if (lowerValue === 'unsafe-url') { + return { + status: 'warning', + message: 'Referrer-Policy is set to unsafe-url which may leak sensitive information', + deduction: 5 + }; + } + + return { + status: 'warning', + message: `Referrer-Policy has unexpected value: ${value}`, + deduction: 5 + }; + } + }, + { + name: 'Permissions-Policy', + description: 'Controls browser features and APIs', + missingWeight: 5, + weakWeight: 5, + priority: 'low', + recommendedValue: 'geolocation=(), microphone=(), camera=()', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'Permissions-Policy header is missing', deduction: 0 }; + } + + return { status: 'secure', message: 'Permissions-Policy is configured', deduction: 0 }; + } + }, + { + name: 'X-XSS-Protection', + description: 'Legacy XSS filter (deprecated but still useful for older browsers)', + missingWeight: 5, + weakWeight: 5, + priority: 'low', + recommendedValue: '1; mode=block', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'X-XSS-Protection header is missing', deduction: 0 }; + } + + // Modern recommendation is to disable it (0) or use mode=block + if (value === '0') { + return { status: 'secure', message: 'X-XSS-Protection is disabled (recommended for modern browsers with CSP)', deduction: 0 }; + } + + if (value.includes('mode=block')) { + return { status: 'secure', message: 'X-XSS-Protection is enabled with mode=block', deduction: 0 }; + } + + if (value === '1') { + return { + status: 'warning', + message: 'X-XSS-Protection is enabled without mode=block, which may introduce vulnerabilities', + deduction: 5 + }; + } + + return { status: 'secure', message: `X-XSS-Protection is set to ${value}`, deduction: 0 }; + } + }, + { + name: 'Set-Cookie', + description: 'Cookie security attributes', + missingWeight: 0, // Not missing if no cookies + weakWeight: 5, + priority: 'medium', + recommendedValue: 'Secure; HttpOnly; SameSite=Strict', + evaluate(value) { + if (!value) { + // No cookies is not a security issue + return { status: 'secure', message: 'No cookies set', deduction: 0 }; + } + + const lowerValue = value.toLowerCase(); + const issues = []; + + if (!lowerValue.includes('secure')) { + issues.push('missing Secure flag'); + } + if (!lowerValue.includes('httponly')) { + issues.push('missing HttpOnly flag'); + } + if (!lowerValue.includes('samesite')) { + issues.push('missing SameSite attribute'); + } + + if (issues.length > 0) { + return { + status: 'warning', + message: `Cookie security issues: ${issues.join(', ')}`, + deduction: 5 + }; + } + + return { status: 'secure', message: 'Cookies have proper security attributes', deduction: 0 }; + } + }, + { + name: 'Cross-Origin-Opener-Policy', + description: 'Isolates browsing context from cross-origin documents', + missingWeight: 5, + weakWeight: 5, + priority: 'low', + recommendedValue: 'same-origin', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'Cross-Origin-Opener-Policy header is missing', deduction: 0 }; + } + + const validValues = ['same-origin', 'same-origin-allow-popups', 'unsafe-none']; + if (validValues.includes(value.toLowerCase().trim())) { + return { status: 'secure', message: `Cross-Origin-Opener-Policy is set to ${value}`, deduction: 0 }; + } + + return { + status: 'warning', + message: `Cross-Origin-Opener-Policy has unexpected value: ${value}`, + deduction: 5 + }; + } + }, + { + name: 'Cross-Origin-Embedder-Policy', + description: 'Controls embedding of cross-origin resources', + missingWeight: 5, + weakWeight: 5, + priority: 'low', + recommendedValue: 'require-corp', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'Cross-Origin-Embedder-Policy header is missing', deduction: 0 }; + } + + const validValues = ['require-corp', 'credentialless', 'unsafe-none']; + if (validValues.includes(value.toLowerCase().trim())) { + return { status: 'secure', message: `Cross-Origin-Embedder-Policy is set to ${value}`, deduction: 0 }; + } + + return { + status: 'warning', + message: `Cross-Origin-Embedder-Policy has unexpected value: ${value}`, + deduction: 5 + }; + } + }, + { + name: 'Cross-Origin-Resource-Policy', + description: 'Controls which origins can load the resource', + missingWeight: 5, + weakWeight: 5, + priority: 'low', + recommendedValue: 'same-origin', + evaluate(value) { + if (!value) { + return { status: 'missing', message: 'Cross-Origin-Resource-Policy header is missing', deduction: 0 }; + } + + const validValues = ['same-origin', 'same-site', 'cross-origin']; + if (validValues.includes(value.toLowerCase().trim())) { + return { status: 'secure', message: `Cross-Origin-Resource-Policy is set to ${value}`, deduction: 0 }; + } + + return { + status: 'warning', + message: `Cross-Origin-Resource-Policy has unexpected value: ${value}`, + deduction: 5 + }; + } + } +]; + +/** + * Get remediation for a specific header + * @param {string} headerName + * @returns {Remediation} + */ +export function getRemediation(headerName) { + const rule = HEADER_RULES.find(r => r.name.toLowerCase() === headerName.toLowerCase()); + + if (!rule) { + return { + header: headerName, + priority: 'low', + fix: '', + description: 'Unknown header' + }; + } + + return { + header: rule.name, + priority: rule.priority, + fix: `${rule.name}: ${rule.recommendedValue}`, + description: rule.description + }; +} diff --git a/js/features/security-headers/ui.js b/js/features/security-headers/ui.js new file mode 100644 index 0000000..0aa8e3d --- /dev/null +++ b/js/features/security-headers/ui.js @@ -0,0 +1,528 @@ +// Security Headers Analyzer - UI Rendering + +import { escapeHtml, copyToClipboard } from '../../core/utils/dom.js'; +import { exportSecurityReport } from './export.js'; +import { analyzeBulkRequests, generateBulkReport, exportBulkReport } from './bulk.js'; +import { state } from '../../core/state.js'; + +/** + * Get CSS class for grade color + * @param {string} grade - Letter grade (A+ to F) + * @returns {string} CSS class name + */ +function getGradeClass(grade) { + if (grade === 'A+' || grade === 'A') return 'grade-a'; + if (grade === 'B') return 'grade-b'; + if (grade === 'C') return 'grade-c'; + if (grade === 'D') return 'grade-d'; + return 'grade-f'; +} + +/** + * Get icon for finding status + * @param {'secure' | 'warning' | 'missing'} status + * @returns {string} Unicode icon + */ +function getStatusIcon(status) { + switch (status) { + case 'secure': return '✅'; + case 'warning': return '⚠️'; + case 'missing': return '❌'; + default: return '❓'; + } +} + +/** + * Render security analysis results in the Security tab + * @param {AnalysisResult} result - Analysis result to render + * @param {HTMLElement} container - Container element + */ +export function renderSecurityView(result, container) { + if (!container) return; + + const gradeClass = getGradeClass(result.grade); + + // Build findings HTML + let findingsHtml = ''; + + // Present (secure) headers + for (const finding of result.findings.present) { + findingsHtml += ` +
+ ${getStatusIcon('secure')} + ${escapeHtml(finding.header)} + ${escapeHtml(finding.value || '')} +
+ `; + } + + // Warnings + for (const finding of result.findings.warnings) { + findingsHtml += ` +
+ ${getStatusIcon('warning')} + ${escapeHtml(finding.header)} + ${escapeHtml(finding.message)} +
+ `; + } + + // Missing headers + for (const headerName of result.findings.missing) { + findingsHtml += ` +
+ ${getStatusIcon('missing')} + ${escapeHtml(headerName)} + Missing +
+ `; + } + + container.innerHTML = ` +
+
+
+ ${escapeHtml(result.grade)} + ${result.score}/100 +
+
+
+
+
+ +
+

Security Headers

+ ${findingsHtml} +
+ +
+ +
+ +
+
JSON
+
Markdown
+
CSV
+
+
+ +
+
+ `; + + // Setup event listeners + setupActionListeners(result, container); +} + +/** + * Setup action button event listeners + * @param {AnalysisResult} result + * @param {HTMLElement} container + */ +function setupActionListeners(result, container) { + // Analyze All button + const analyzeAllBtn = container.querySelector('#security-analyze-all-btn'); + if (analyzeAllBtn) { + analyzeAllBtn.addEventListener('click', () => { + startBulkAnalysis(); + }); + } + + // Export dropdown toggle + const exportBtn = container.querySelector('#security-export-btn'); + const exportMenu = container.querySelector('#security-export-menu'); + + if (exportBtn && exportMenu) { + exportBtn.addEventListener('click', (e) => { + e.stopPropagation(); + exportMenu.classList.toggle('visible'); + }); + + // Close menu when clicking outside + document.addEventListener('click', () => { + exportMenu.classList.remove('visible'); + }); + + // Export options + exportMenu.querySelectorAll('.export-option').forEach(option => { + option.addEventListener('click', (e) => { + e.stopPropagation(); + const format = option.dataset.format; + const content = exportSecurityReport(result, format); + downloadExport(content, format, result.url); + exportMenu.classList.remove('visible'); + }); + }); + } + + // Copy remediation button + const copyBtn = container.querySelector('#security-copy-btn'); + if (copyBtn) { + copyBtn.addEventListener('click', () => { + const remediationText = result.recommendations + .map(r => r.fix) + .join('\n'); + copyToClipboard(remediationText, copyBtn); + }); + } +} + +/** + * Download export content as file + * @param {string} content + * @param {string} format + * @param {string} url + */ +function downloadExport(content, format, url) { + const extensions = { json: 'json', markdown: 'md', csv: 'csv' }; + const mimeTypes = { + json: 'application/json', + markdown: 'text/markdown', + csv: 'text/csv' + }; + + const hostname = new URL(url).hostname || 'unknown'; + const filename = `security-report-${hostname}-${Date.now()}.${extensions[format]}`; + + const blob = new Blob([content], { type: mimeTypes[format] }); + const downloadUrl = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); +} + +/** + * Update the security tab with new analysis + * @param {AnalysisResult} result + */ +export function updateSecurityTab(result) { + const container = document.getElementById('res-view-security'); + if (container) { + renderSecurityView(result, container); + } +} + +/** + * Show empty state when no response available + * @param {HTMLElement} container + */ +export function showEmptyState(container) { + if (!container) return; + + container.innerHTML = ` +
+
🔒
+

Select a request with a response to analyze security headers

+
+ `; +} + + +// Bulk analysis state +let bulkAnalysisModal = null; +let currentBulkReport = null; + +/** + * Start bulk analysis of all captured requests + */ +async function startBulkAnalysis() { + // Get requests with response headers + const requestsWithHeaders = state.requests.filter( + r => r.responseHeaders && r.responseHeaders.length > 0 + ); + + if (requestsWithHeaders.length === 0) { + alert('No requests with response headers to analyze.'); + return; + } + + // Show modal + showBulkAnalysisModal(requestsWithHeaders.length); + + // Run analysis + const results = await analyzeBulkRequests(requestsWithHeaders, (progress) => { + updateBulkProgress(progress.completed, progress.total); + }); + + // Generate report + currentBulkReport = generateBulkReport(results); + + // Show results + showBulkResults(currentBulkReport); +} + +/** + * Show the bulk analysis modal + * @param {number} totalRequests + */ +function showBulkAnalysisModal(totalRequests) { + // Remove existing modal if any + if (bulkAnalysisModal) { + bulkAnalysisModal.remove(); + } + + bulkAnalysisModal = document.createElement('div'); + bulkAnalysisModal.className = 'modal bulk-analysis-modal'; + bulkAnalysisModal.style.display = 'flex'; + bulkAnalysisModal.innerHTML = ` + + `; + + document.body.appendChild(bulkAnalysisModal); + + // Setup close button + const closeBtn = bulkAnalysisModal.querySelector('#bulk-modal-close'); + if (closeBtn) { + closeBtn.addEventListener('click', closeBulkModal); + } +} + +/** + * Update bulk analysis progress + * @param {number} completed + * @param {number} total + */ +function updateBulkProgress(completed, total) { + if (!bulkAnalysisModal) return; + + const progressFill = bulkAnalysisModal.querySelector('.bulk-progress-fill'); + const progressCount = bulkAnalysisModal.querySelector('.bulk-progress-count'); + + const percent = Math.round((completed / total) * 100); + + if (progressFill) { + progressFill.style.width = `${percent}%`; + } + if (progressCount) { + progressCount.textContent = `${completed} / ${total}`; + } +} + +/** + * Show bulk analysis results + * @param {BulkAnalysisReport} report + */ +function showBulkResults(report) { + if (!bulkAnalysisModal) return; + + const progressSection = bulkAnalysisModal.querySelector('.bulk-progress-section'); + const resultsSection = bulkAnalysisModal.querySelector('.bulk-results-section'); + const footer = bulkAnalysisModal.querySelector('.bulk-modal-footer'); + + if (progressSection) progressSection.style.display = 'none'; + if (resultsSection) resultsSection.style.display = 'block'; + if (footer) footer.style.display = 'flex'; + + // Build grade distribution HTML + let gradeDistHtml = ''; + const gradeOrder = ['A+', 'A', 'B', 'C', 'D', 'F']; + for (const grade of gradeOrder) { + const count = report.gradeDistribution[grade] || 0; + if (count > 0) { + gradeDistHtml += `${grade}: ${count}`; + } + } + + // Build common issues HTML + let issuesHtml = ''; + for (const issue of report.commonIssues) { + issuesHtml += `
  • ${escapeHtml(issue)}
  • `; + } + + // Build worst performers HTML + let worstHtml = ''; + for (const result of report.worstPerformers) { + worstHtml += ` +
    + ${result.grade} (${result.score}) + ${escapeHtml(result.method)} ${escapeHtml(result.path)} +
    + `; + } + + // Build best performers HTML + let bestHtml = ''; + for (const result of report.bestPerformers) { + bestHtml += ` +
    + ${result.grade} (${result.score}) + ${escapeHtml(result.method)} ${escapeHtml(result.path)} +
    + `; + } + + resultsSection.innerHTML = ` +
    +
    + ${report.totalRequests} + Requests Analyzed +
    +
    + ${report.averageScore} + Average Score +
    +
    + +
    +

    Grade Distribution

    +
    ${gradeDistHtml}
    +
    + + ${report.commonIssues.length > 0 ? ` +
    +

    Common Issues

    +
      ${issuesHtml}
    +
    + ` : ''} + + ${report.worstPerformers.length > 0 ? ` +
    +

    Worst Performers

    +
    ${worstHtml}
    +
    + ` : ''} + + ${report.bestPerformers.length > 0 ? ` +
    +

    Best Performers

    +
    ${bestHtml}
    +
    + ` : ''} + `; + + // Setup export buttons + setupBulkExportListeners(); +} + +/** + * Convert score to grade (duplicated for use in bulk UI) + * @param {number} score + * @returns {string} + */ +function scoreToGrade(score) { + if (score >= 95) return 'A+'; + if (score >= 90) return 'A'; + if (score >= 80) return 'B'; + if (score >= 70) return 'C'; + if (score >= 60) return 'D'; + return 'F'; +} + +/** + * Setup bulk export button listeners + */ +function setupBulkExportListeners() { + if (!bulkAnalysisModal) return; + + const exportBtn = bulkAnalysisModal.querySelector('#bulk-export-btn'); + const exportMenu = bulkAnalysisModal.querySelector('#bulk-export-menu'); + const closeBtn = bulkAnalysisModal.querySelector('#bulk-close-btn'); + + if (exportBtn && exportMenu) { + exportBtn.addEventListener('click', (e) => { + e.stopPropagation(); + exportMenu.classList.toggle('visible'); + }); + + exportMenu.querySelectorAll('.export-option').forEach(option => { + option.addEventListener('click', (e) => { + e.stopPropagation(); + const format = option.dataset.format; + if (currentBulkReport) { + const content = exportBulkReport(currentBulkReport, format); + downloadBulkExport(content, format); + } + exportMenu.classList.remove('visible'); + }); + }); + } + + if (closeBtn) { + closeBtn.addEventListener('click', closeBulkModal); + } + + // Close menu when clicking outside + document.addEventListener('click', () => { + if (exportMenu) exportMenu.classList.remove('visible'); + }); +} + +/** + * Download bulk export content as file + * @param {string} content + * @param {string} format + */ +function downloadBulkExport(content, format) { + const extensions = { json: 'json', markdown: 'md', csv: 'csv' }; + const mimeTypes = { + json: 'application/json', + markdown: 'text/markdown', + csv: 'text/csv' + }; + + const filename = `bulk-security-report-${Date.now()}.${extensions[format]}`; + + const blob = new Blob([content], { type: mimeTypes[format] }); + const downloadUrl = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(downloadUrl); +} + +/** + * Close the bulk analysis modal + */ +function closeBulkModal() { + if (bulkAnalysisModal) { + bulkAnalysisModal.remove(); + bulkAnalysisModal = null; + } + currentBulkReport = null; +} + +// Export for external use +export { startBulkAnalysis, showBulkAnalysisModal }; diff --git a/js/main.js b/js/main.js index e39d7a1..afbff5d 100644 --- a/js/main.js +++ b/js/main.js @@ -19,6 +19,7 @@ import { initMultiTabCapture } from './network/multi-tab.js'; import { initExtractorUI } from './features/extractors/index.js'; import { setupAIFeatures } from './features/ai/index.js'; import { setupLLMChat } from './features/llm-chat/index.js'; +import { setupSecurityHeaders } from './features/security-headers/index.js'; import { handleSendRequest } from './network/handler.js'; import { initSearch } from './search/index.js'; @@ -45,6 +46,7 @@ document.addEventListener('DOMContentLoaded', () => { setupBulkReplay(); setupAIFeatures(elements); setupLLMChat(elements); + setupSecurityHeaders(elements); initSearch(); // Promotional Banner diff --git a/js/ui/request-editor.js b/js/ui/request-editor.js index d027211..f8a453e 100644 --- a/js/ui/request-editor.js +++ b/js/ui/request-editor.js @@ -444,7 +444,7 @@ export function switchResponseView(view) { }); // Update Content Visibility - ['pretty', 'raw', 'hex', 'render', 'json', 'preview'].forEach(v => { + ['pretty', 'raw', 'hex', 'render', 'json', 'preview', 'security'].forEach(v => { const el = document.getElementById(`res-view-${v}`); if (el) { el.style.display = v === view ? 'flex' : 'none'; diff --git a/js/ui/ui-utils.js b/js/ui/ui-utils.js index 730fb26..6164179 100644 --- a/js/ui/ui-utils.js +++ b/js/ui/ui-utils.js @@ -1704,7 +1704,7 @@ export function exportRequests() { req.response.headers.forEach(h => resHeadersObj[h.name] = h.value); } - return { + let requestData = { id: `req_${index + 1}`, method: req.request.method, url: req.request.url, @@ -1717,6 +1717,11 @@ export function exportRequests() { }, timestamp: req.capturedAt }; + + const enhancedData = { requestData, originalRequest: req }; + events.emit(EVENT_NAMES.EXPORT_REQUEST_DATA, enhancedData); + + return enhancedData.requestData; }) }; @@ -1769,11 +1774,16 @@ export function importRequests(file) { headers: resHeadersArr, content: { text: item.response ? item.response.body : '' } }, + responseHeaders: resHeadersArr, capturedAt: item.timestamp || Date.now(), starred: false }; - // Use action to add request (automatically emits events) + events.emit(EVENT_NAMES.IMPORT_REQUEST_DATA, { + importedData: item, + newRequest: newReq + }); + actions.request.add(newReq); }); diff --git a/package-lock.json b/package-lock.json index 7f5d368..e4082e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -126,6 +126,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -149,6 +150,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1647,6 +1649,7 @@ "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^2.0.1", "cssstyle": "^4.0.1", diff --git a/panel.html b/panel.html index 69f73f9..47b969d 100644 --- a/panel.html +++ b/panel.html @@ -362,6 +362,7 @@

    Response

    +
    @@ -381,6 +382,9 @@

    Response

    
                                 
    +