""" HTTP Header Security Scanner. This module analyzes HTTP response headers for security best practices and common misconfigurations. """ import logging from typing import Any, Dict, List, Optional, Tuple import httpx from .base import ( BaseScanner, ScannerResult, ScannerStatus, IssueData, MetricData, ) logger = logging.getLogger(__name__) # Security header definitions with expected values and severity SECURITY_HEADERS = { 'Strict-Transport-Security': { 'severity': 'high', 'description': 'HTTP Strict Transport Security (HSTS) forces browsers to use HTTPS.', 'remediation': ( 'Add the header: Strict-Transport-Security: max-age=31536000; includeSubDomains; preload' ), 'check_value': lambda v: 'max-age' in v.lower() and int( v.lower().split('max-age=')[1].split(';')[0].strip() ) >= 31536000 if 'max-age=' in v.lower() else False, }, 'Content-Security-Policy': { 'severity': 'high', 'description': 'Content Security Policy (CSP) helps prevent XSS and data injection attacks.', 'remediation': ( "Implement a Content-Security-Policy header that restricts sources for scripts, " "styles, and other resources. Start with a report-only policy to identify issues." ), 'check_value': lambda v: "default-src" in v.lower() or "script-src" in v.lower(), }, 'X-Content-Type-Options': { 'severity': 'medium', 'description': 'Prevents browsers from MIME-sniffing responses.', 'remediation': 'Add the header: X-Content-Type-Options: nosniff', 'check_value': lambda v: v.lower() == 'nosniff', }, 'X-Frame-Options': { 'severity': 'medium', 'description': 'Protects against clickjacking by controlling page framing.', 'remediation': 'Add the header: X-Frame-Options: DENY or SAMEORIGIN', 'check_value': lambda v: v.upper() in ['DENY', 'SAMEORIGIN'], }, 'Referrer-Policy': { 'severity': 'low', 'description': 'Controls how much referrer information is sent with requests.', 'remediation': ( 'Add the header: Referrer-Policy: strict-origin-when-cross-origin ' 'or no-referrer-when-downgrade' ), 'check_value': lambda v: v.lower() in [ 'no-referrer', 'no-referrer-when-downgrade', 'strict-origin', 'strict-origin-when-cross-origin', 'same-origin', 'origin', 'origin-when-cross-origin' ], }, 'Permissions-Policy': { 'severity': 'low', 'description': 'Controls which browser features can be used.', 'remediation': ( 'Add a Permissions-Policy header to restrict access to sensitive browser APIs ' 'like geolocation, camera, and microphone.' ), 'check_value': lambda v: len(v) > 0, }, 'X-XSS-Protection': { 'severity': 'info', 'description': 'Legacy XSS filter (deprecated in modern browsers, CSP is preferred).', 'remediation': 'While deprecated, you can add: X-XSS-Protection: 1; mode=block', 'check_value': lambda v: '1' in v, }, } # CORS security checks CORS_CHECKS = { 'permissive_origin': { 'severity': 'high', 'title': 'Overly permissive CORS (Access-Control-Allow-Origin: *)', 'description': ( 'The server allows requests from any origin. This can expose sensitive data ' 'to malicious websites if combined with credentials.' ), 'remediation': ( 'Restrict Access-Control-Allow-Origin to specific trusted domains instead of using *. ' 'Never use * with Access-Control-Allow-Credentials: true.' ), }, 'credentials_with_wildcard': { 'severity': 'critical', 'title': 'CORS allows credentials with wildcard origin', 'description': ( 'The server has Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *. ' 'This is a severe misconfiguration that can allow credential theft.' ), 'remediation': ( 'Never combine Access-Control-Allow-Credentials: true with a wildcard origin. ' 'Implement a whitelist of allowed origins.' ), }, } class HeaderScanner(BaseScanner): """ Scanner for HTTP security headers. Checks for: - Missing security headers - Improperly configured headers - CORS misconfigurations - Cookie security flags """ name = "header_check" def __init__(self, config: Optional[Dict[str, Any]] = None): super().__init__(config) self.timeout = self.config.get('timeout', 30) def run(self, url: str) -> ScannerResult: """ Run header security analysis on the URL. Args: url: The URL to analyze Returns: ScannerResult with header findings """ self.logger.info(f"Starting header scan for {url}") try: # Make both GET and HEAD requests headers_data = self._fetch_headers(url) issues = [] metrics = [] # Check security headers header_issues, header_score = self._check_security_headers( headers_data['headers'] ) issues.extend(header_issues) # Check CORS configuration cors_issues = self._check_cors(headers_data['headers'], url) issues.extend(cors_issues) # Check cookies cookie_issues = self._check_cookies(headers_data['headers'], url) issues.extend(cookie_issues) # Create metrics metrics.append(MetricData( name='security_headers_score', display_name='Security Headers Score', value=float(header_score), unit='percent', source='header_check' )) metrics.append(MetricData( name='headers_missing_count', display_name='Missing Security Headers', value=float(len([i for i in header_issues if 'missing' in i.title.lower()])), unit='count', source='header_check' )) self.logger.info( f"Header scan complete: {len(issues)} issues, score: {header_score}" ) return ScannerResult( scanner_name=self.name, status=ScannerStatus.SUCCESS, issues=issues, metrics=metrics, raw_data=headers_data ) except httpx.TimeoutException: return self._create_error_result(Exception("Header check timed out")) except Exception as e: return self._create_error_result(e) def _fetch_headers(self, url: str) -> Dict[str, Any]: """Fetch headers from the URL.""" with httpx.Client( timeout=self.timeout, follow_redirects=True, verify=True ) as client: # GET request get_response = client.get(url) # HEAD request head_response = client.head(url) return { 'url': str(get_response.url), 'status_code': get_response.status_code, 'headers': dict(get_response.headers), 'head_headers': dict(head_response.headers), 'redirected': str(get_response.url) != url, 'redirect_history': [str(r.url) for r in get_response.history], } def _check_security_headers( self, headers: Dict[str, str] ) -> Tuple[List[IssueData], int]: """ Check for security headers. Returns: Tuple of (list of issues, security score 0-100) """ issues = [] score = 100 headers_lower = {k.lower(): v for k, v in headers.items()} for header_name, config in SECURITY_HEADERS.items(): header_key = header_name.lower() if header_key not in headers_lower: # Missing header severity = config['severity'] deduction = {'critical': 20, 'high': 15, 'medium': 10, 'low': 5, 'info': 2} score -= deduction.get(severity, 5) issues.append(IssueData( category='headers', severity=severity, title=f'Missing security header: {header_name}', description=config['description'], tool='header_check', remediation=config['remediation'], raw_data={'header': header_name, 'status': 'missing'} )) else: # Header present, check value value = headers_lower[header_key] check_func = config.get('check_value') if check_func and not check_func(value): issues.append(IssueData( category='headers', severity='low', title=f'Weak configuration: {header_name}', description=( f"{config['description']} " f"Current value may not provide optimal protection: {value}" ), tool='header_check', remediation=config['remediation'], raw_data={'header': header_name, 'value': value, 'status': 'weak'} )) score -= 3 return issues, max(0, score) def _check_cors(self, headers: Dict[str, str], url: str) -> List[IssueData]: """Check CORS configuration for issues.""" issues = [] headers_lower = {k.lower(): v for k, v in headers.items()} acao = headers_lower.get('access-control-allow-origin', '') acac = headers_lower.get('access-control-allow-credentials', '') if acao == '*': if acac.lower() == 'true': # Critical: credentials with wildcard check = CORS_CHECKS['credentials_with_wildcard'] issues.append(IssueData( category='cors', severity=check['severity'], title=check['title'], description=check['description'], tool='header_check', affected_url=url, remediation=check['remediation'], raw_data={ 'Access-Control-Allow-Origin': acao, 'Access-Control-Allow-Credentials': acac } )) else: # Warning: permissive origin check = CORS_CHECKS['permissive_origin'] issues.append(IssueData( category='cors', severity='medium', # Lower severity without credentials title=check['title'], description=check['description'], tool='header_check', affected_url=url, remediation=check['remediation'], raw_data={'Access-Control-Allow-Origin': acao} )) return issues def _check_cookies(self, headers: Dict[str, str], url: str) -> List[IssueData]: """Check Set-Cookie headers for security flags.""" issues = [] headers_lower = {k.lower(): v for k, v in headers.items()} # Get all Set-Cookie headers set_cookies = [] for key, value in headers.items(): if key.lower() == 'set-cookie': set_cookies.append(value) is_https = url.startswith('https://') for cookie in set_cookies: cookie_lower = cookie.lower() cookie_name = cookie.split('=')[0] if '=' in cookie else 'unknown' cookie_issues = [] # Check Secure flag on HTTPS if is_https and 'secure' not in cookie_lower: cookie_issues.append({ 'flag': 'Secure', 'description': ( 'Cookie is set without Secure flag on HTTPS site. ' 'This allows the cookie to be sent over unencrypted connections.' ), 'severity': 'high' }) # Check HttpOnly flag (important for session cookies) if 'httponly' not in cookie_lower: # Check if it might be a session cookie if any(term in cookie_name.lower() for term in ['session', 'auth', 'token', 'user']): cookie_issues.append({ 'flag': 'HttpOnly', 'description': ( 'Session-like cookie is set without HttpOnly flag. ' 'This allows JavaScript access, increasing XSS risk.' ), 'severity': 'high' }) else: cookie_issues.append({ 'flag': 'HttpOnly', 'description': ( 'Cookie is set without HttpOnly flag. ' 'Consider adding it unless JavaScript needs access.' ), 'severity': 'low' }) # Check SameSite attribute if 'samesite' not in cookie_lower: cookie_issues.append({ 'flag': 'SameSite', 'description': ( 'Cookie is set without SameSite attribute. ' 'This can enable CSRF attacks in some scenarios.' ), 'severity': 'medium' }) elif 'samesite=none' in cookie_lower and 'secure' not in cookie_lower: cookie_issues.append({ 'flag': 'SameSite=None without Secure', 'description': ( 'Cookie has SameSite=None but no Secure flag. ' 'Modern browsers will reject this cookie.' ), 'severity': 'medium' }) # Create issues for this cookie for ci in cookie_issues: issues.append(IssueData( category='security', severity=ci['severity'], title=f"Cookie '{cookie_name}' missing {ci['flag']} flag", description=ci['description'], tool='header_check', affected_url=url, remediation=( f"Add the {ci['flag']} flag to the Set-Cookie header. " f"Example: Set-Cookie: {cookie_name}=value; Secure; HttpOnly; SameSite=Strict" ), raw_data={'cookie': cookie[:200]} # Truncate for storage )) return issues