module EhcHardenProbe
EhcHardenProbe — passive security findings from HTTP headers and robots.txt. Public: analyze Depends: EhcFetch, EhcProbe Tests: test/lib/ehc_harden_probe_test.rb
Constants
- SENSITIVE_ROBOTS_RE
Public Instance Methods
Source
# File lib/ehc_harden_probe.rb, line 16 def analyze(page:, url:, robots_body: nil) headers = page.is_a?(Hash) ? page[:headers] || page["headers"] || {} : {} body = page.is_a?(Hash) ? page[:body] || page["body"] : nil normalized = normalize_headers(headers) issues = [] issues << issue( code: "missing_hsts", title: "Missing Strict-Transport-Security", summary: "No HSTS header — browsers may fall back to plain HTTP.", severity: "medium", detail_lines: [ "Response from #{url} lacks Strict-Transport-Security.", "Add a long max-age HSTS policy on HTTPS vhosts." ] ) if https_url?(url) && normalized["strict-transport-security"].to_s.strip.empty? issues << issue( code: "missing_csp", title: "Missing Content-Security-Policy", summary: "No CSP header — XSS impact is harder to contain.", severity: "medium", detail_lines: [ "No Content-Security-Policy header was returned.", "Define default-src and script-src policies for web apps." ] ) if normalized["content-security-policy"].to_s.strip.empty? issues << issue( code: "missing_xfo", title: "Missing X-Frame-Options", summary: "Clickjacking protection header is absent.", severity: "low", detail_lines: [ "X-Frame-Options or frame-ancestors CSP is not set.", "Use DENY or SAMEORIGIN unless embedding is required." ] ) if normalized["x-frame-options"].to_s.strip.empty? issues << issue( code: "missing_xcto", title: "Missing X-Content-Type-Options", summary: "MIME sniffing is not disabled.", severity: "low", detail_lines: [ "X-Content-Type-Options: nosniff was not sent.", "Set nosniff on HTML and API responses." ] ) if normalized["x-content-type-options"].to_s.strip.empty? if (server = normalized["server"].to_s.strip) && !server.empty? issues << issue( code: "server_disclosure", title: "Server version disclosed", summary: "Server header reveals #{server}.", severity: "info", detail_lines: [ "Server: #{server}", "Strip or genericize the Server banner on edge proxies." ] ) end if (powered = normalized["x-powered-by"].to_s.strip) && !powered.empty? issues << issue( code: "powered_by_disclosure", title: "X-Powered-By disclosed", summary: "Stack fingerprint: #{powered}.", severity: "info", detail_lines: [ "X-Powered-By: #{powered}", "Remove X-Powered-By from application and proxy layers." ] ) end issues.concat(cookie_issues(normalized["set-cookie"], https: https_url?(url))) issues.concat(robots_issues(robots_body)) end
Source
# File lib/ehc_harden_probe.rb, line 174 def https_url?(url) url.to_s.match?(%r{\Ahttps://}i) end
Source
# File lib/ehc_harden_probe.rb, line 155 def issue(code:, title:, summary:, severity:, detail_lines:) { code: code, title: title, summary: summary, severity: severity, detail_lines: Array(detail_lines).map(&:to_s).reject(&:empty?) } end
Source
# File lib/ehc_harden_probe.rb, line 165 def normalize_headers(headers) data = headers.is_a?(Hash) ? headers : {} normalized = {} data.each do |key, value| normalized[key.to_s.downcase] = value end normalized end
Source
# File lib/ehc_harden_probe.rb, line 135 def robots_issues(body) text = body.to_s return [] if text.strip.empty? sensitive = text.lines.map(&:strip).grep(/\A(?:Disallow|Allow):/i).select do |line| line.match?(SENSITIVE_ROBOTS_RE) end return [] if sensitive.empty? [ issue( code: "robots_sensitive_path", title: "robots.txt lists sensitive paths", summary: "Crawler rules expose restricted locations.", severity: "low", detail_lines: sensitive.first(4) + ["Review whether listed admin or backup paths should be public."] ) ] end