hedra 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +115 -2
- data/lib/hedra/analyzer.rb +54 -15
- data/lib/hedra/banner.rb +55 -0
- data/lib/hedra/cache.rb +58 -22
- data/lib/hedra/circuit_breaker.rb +24 -15
- data/lib/hedra/cli.rb +67 -13
- data/lib/hedra/config.rb +11 -2
- data/lib/hedra/cors_checker.rb +251 -0
- data/lib/hedra/ct_checker.rb +286 -0
- data/lib/hedra/dns_checker.rb +274 -0
- data/lib/hedra/plugin_manager.rb +23 -1
- data/lib/hedra/progress_tracker.rb +6 -1
- data/lib/hedra/protocol_checker.rb +228 -0
- data/lib/hedra/rate_limiter.rb +6 -0
- data/lib/hedra/scorer.rb +24 -2
- data/lib/hedra/security_txt_checker.rb +25 -2
- data/lib/hedra/sri_checker.rb +151 -0
- data/lib/hedra/url_validator.rb +94 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +7 -0
- metadata +22 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module Hedra
|
|
7
|
+
# Check for Subresource Integrity (SRI) on external resources
|
|
8
|
+
class SriChecker
|
|
9
|
+
EXTERNAL_RESOURCE_PATTERN = /<(script|link)\s+[^>]*>/i.freeze
|
|
10
|
+
SRC_PATTERN = /(?:src|href)=["']([^"']+)["']/i.freeze
|
|
11
|
+
INTEGRITY_PATTERN = /integrity=["']([^"']+)["']/i.freeze
|
|
12
|
+
CROSSORIGIN_PATTERN = /crossorigin(?:=["']([^"']+)["'])?/i.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(http_client: nil)
|
|
15
|
+
@http_client = http_client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check(url, html_content = nil)
|
|
19
|
+
findings = []
|
|
20
|
+
|
|
21
|
+
# Fetch HTML if not provided
|
|
22
|
+
html_content ||= fetch_html(url)
|
|
23
|
+
return findings unless html_content
|
|
24
|
+
|
|
25
|
+
base_uri = URI.parse(url)
|
|
26
|
+
external_resources = extract_external_resources(html_content, base_uri)
|
|
27
|
+
|
|
28
|
+
return findings if external_resources.empty?
|
|
29
|
+
|
|
30
|
+
external_resources.each do |resource|
|
|
31
|
+
next if resource[:has_integrity]
|
|
32
|
+
|
|
33
|
+
findings << {
|
|
34
|
+
header: 'subresource-integrity',
|
|
35
|
+
issue: "External #{resource[:type]} missing SRI: #{truncate_url(resource[:url])}",
|
|
36
|
+
severity: :warning,
|
|
37
|
+
recommended_fix: "Add integrity attribute with hash: integrity=\"sha384-...\" crossorigin=\"anonymous\""
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check for crossorigin without integrity
|
|
42
|
+
external_resources.each do |resource|
|
|
43
|
+
if resource[:has_integrity] && !resource[:has_crossorigin]
|
|
44
|
+
findings << {
|
|
45
|
+
header: 'subresource-integrity',
|
|
46
|
+
issue: "SRI without crossorigin attribute: #{truncate_url(resource[:url])}",
|
|
47
|
+
severity: :info,
|
|
48
|
+
recommended_fix: 'Add crossorigin="anonymous" when using integrity attribute'
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
findings
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
warn "SRI check failed: #{e.message}" if ENV['DEBUG']
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def fetch_html(url)
|
|
62
|
+
return nil unless @http_client
|
|
63
|
+
|
|
64
|
+
response = @http_client.get(url)
|
|
65
|
+
content_type = response.headers['Content-Type'].to_s
|
|
66
|
+
|
|
67
|
+
# Only process HTML content
|
|
68
|
+
return nil unless content_type.include?('text/html')
|
|
69
|
+
|
|
70
|
+
response.body.to_s
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
warn "Failed to fetch HTML for SRI check: #{e.message}" if ENV['DEBUG']
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_external_resources(html, base_uri)
|
|
77
|
+
resources = []
|
|
78
|
+
|
|
79
|
+
html.scan(EXTERNAL_RESOURCE_PATTERN) do |match|
|
|
80
|
+
tag = match[0]
|
|
81
|
+
full_tag = ::Regexp.last_match(0)
|
|
82
|
+
|
|
83
|
+
# Extract src/href
|
|
84
|
+
src_match = full_tag.match(SRC_PATTERN)
|
|
85
|
+
next unless src_match
|
|
86
|
+
|
|
87
|
+
resource_url = src_match[1]
|
|
88
|
+
next if resource_url.nil? || resource_url.empty?
|
|
89
|
+
|
|
90
|
+
# Skip data URIs, inline scripts, and relative URLs to same origin
|
|
91
|
+
next if resource_url.start_with?('data:', 'blob:', '#', 'javascript:')
|
|
92
|
+
|
|
93
|
+
# Resolve relative URLs
|
|
94
|
+
absolute_url = resolve_url(resource_url, base_uri)
|
|
95
|
+
next unless absolute_url
|
|
96
|
+
|
|
97
|
+
# Check if external
|
|
98
|
+
resource_uri = URI.parse(absolute_url)
|
|
99
|
+
next if same_origin?(base_uri, resource_uri)
|
|
100
|
+
|
|
101
|
+
# Check for integrity and crossorigin attributes
|
|
102
|
+
has_integrity = full_tag.match?(INTEGRITY_PATTERN)
|
|
103
|
+
has_crossorigin = full_tag.match?(CROSSORIGIN_PATTERN)
|
|
104
|
+
|
|
105
|
+
resources << {
|
|
106
|
+
type: tag.downcase,
|
|
107
|
+
url: absolute_url,
|
|
108
|
+
has_integrity: has_integrity,
|
|
109
|
+
has_crossorigin: has_crossorigin
|
|
110
|
+
}
|
|
111
|
+
rescue URI::InvalidURIError
|
|
112
|
+
# Skip invalid URIs
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
resources.uniq { |r| r[:url] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resolve_url(url, base_uri)
|
|
120
|
+
return url if url.match?(%r{^https?://})
|
|
121
|
+
|
|
122
|
+
if url.start_with?('//')
|
|
123
|
+
"#{base_uri.scheme}:#{url}"
|
|
124
|
+
elsif url.start_with?('/')
|
|
125
|
+
"#{base_uri.scheme}://#{base_uri.host}#{base_uri.port && ![80, 443].include?(base_uri.port) ? ":#{base_uri.port}" : ''}#{url}"
|
|
126
|
+
else
|
|
127
|
+
# Relative URL
|
|
128
|
+
base_path = base_uri.path.split('/')[0..-2].join('/')
|
|
129
|
+
"#{base_uri.scheme}://#{base_uri.host}#{base_uri.port && ![80, 443].include?(base_uri.port) ? ":#{base_uri.port}" : ''}#{base_path}/#{url}"
|
|
130
|
+
end
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def same_origin?(uri1, uri2)
|
|
136
|
+
uri1.scheme == uri2.scheme &&
|
|
137
|
+
uri1.host == uri2.host &&
|
|
138
|
+
(uri1.port || default_port(uri1.scheme)) == (uri2.port || default_port(uri2.scheme))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def default_port(scheme)
|
|
142
|
+
scheme == 'https' ? 443 : 80
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def truncate_url(url, max_length = 60)
|
|
146
|
+
return url if url.length <= max_length
|
|
147
|
+
|
|
148
|
+
"#{url[0...max_length]}..."
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Hedra
|
|
6
|
+
# URL validation and sanitization
|
|
7
|
+
class UrlValidator
|
|
8
|
+
ALLOWED_SCHEMES = %w[http https].freeze
|
|
9
|
+
MAX_URL_LENGTH = 2048
|
|
10
|
+
DANGEROUS_CHARS = ['<', '>', '"', '{', '}', '|', '\\', '^', '`', '[', ']'].freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def valid?(url)
|
|
14
|
+
return false if url.nil? || url.empty?
|
|
15
|
+
return false if url.length > MAX_URL_LENGTH
|
|
16
|
+
|
|
17
|
+
uri = parse_url(url)
|
|
18
|
+
return false unless uri
|
|
19
|
+
return false unless ALLOWED_SCHEMES.include?(uri.scheme)
|
|
20
|
+
return false unless valid_host?(uri.host)
|
|
21
|
+
return false if contains_dangerous_chars?(url)
|
|
22
|
+
|
|
23
|
+
true
|
|
24
|
+
rescue StandardError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate!(url)
|
|
29
|
+
raise Error, 'URL cannot be empty' if url.nil? || url.empty?
|
|
30
|
+
raise Error, "URL too long (max #{MAX_URL_LENGTH} characters)" if url.length > MAX_URL_LENGTH
|
|
31
|
+
|
|
32
|
+
uri = parse_url(url)
|
|
33
|
+
raise Error, 'Invalid URL format' unless uri
|
|
34
|
+
raise Error, "Invalid scheme: #{uri.scheme}. Only HTTP/HTTPS allowed" unless ALLOWED_SCHEMES.include?(uri.scheme)
|
|
35
|
+
raise Error, 'Invalid or missing host' unless valid_host?(uri.host)
|
|
36
|
+
raise Error, 'URL contains dangerous characters' if contains_dangerous_chars?(url)
|
|
37
|
+
|
|
38
|
+
uri
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sanitize(url)
|
|
42
|
+
url = url.strip
|
|
43
|
+
url = "https://#{url}" unless url.start_with?('http://', 'https://')
|
|
44
|
+
url
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize(url)
|
|
48
|
+
uri = parse_url(url)
|
|
49
|
+
return url unless uri
|
|
50
|
+
|
|
51
|
+
# Remove default ports
|
|
52
|
+
port = if (uri.scheme == 'http' && uri.port == 80) || (uri.scheme == 'https' && uri.port == 443)
|
|
53
|
+
nil
|
|
54
|
+
else
|
|
55
|
+
uri.port
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Rebuild URL
|
|
59
|
+
normalized = "#{uri.scheme}://#{uri.host}"
|
|
60
|
+
normalized += ":#{port}" if port
|
|
61
|
+
normalized += uri.path if uri.path && uri.path != '/'
|
|
62
|
+
normalized += "?#{uri.query}" if uri.query
|
|
63
|
+
normalized
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def parse_url(url)
|
|
69
|
+
URI.parse(url)
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_host?(host)
|
|
75
|
+
return false if host.nil? || host.empty?
|
|
76
|
+
return false if host.length > 253 # Max domain length
|
|
77
|
+
|
|
78
|
+
# Check for valid hostname format
|
|
79
|
+
return false if host.start_with?('.') || host.end_with?('.')
|
|
80
|
+
return false if host.include?('..')
|
|
81
|
+
|
|
82
|
+
# Check for localhost/private IPs in production
|
|
83
|
+
return false if host == 'localhost'
|
|
84
|
+
return false if host.start_with?('127.', '10.', '192.168.', '172.')
|
|
85
|
+
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def contains_dangerous_chars?(url)
|
|
90
|
+
DANGEROUS_CHARS.any? { |char| url.include?(char) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/hedra/version.rb
CHANGED
data/lib/hedra.rb
CHANGED
|
@@ -7,6 +7,8 @@ module Hedra
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
require_relative 'hedra/version'
|
|
10
|
+
require_relative 'hedra/banner'
|
|
11
|
+
require_relative 'hedra/url_validator'
|
|
10
12
|
require_relative 'hedra/config'
|
|
11
13
|
require_relative 'hedra/scorer'
|
|
12
14
|
require_relative 'hedra/circuit_breaker'
|
|
@@ -20,5 +22,10 @@ require_relative 'hedra/http_client'
|
|
|
20
22
|
require_relative 'hedra/plugin_manager'
|
|
21
23
|
require_relative 'hedra/exporter'
|
|
22
24
|
require_relative 'hedra/html_reporter'
|
|
25
|
+
require_relative 'hedra/sri_checker'
|
|
26
|
+
require_relative 'hedra/cors_checker'
|
|
27
|
+
require_relative 'hedra/protocol_checker'
|
|
28
|
+
require_relative 'hedra/ct_checker'
|
|
29
|
+
require_relative 'hedra/dns_checker'
|
|
23
30
|
require_relative 'hedra/analyzer'
|
|
24
31
|
require_relative 'hedra/cli'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hedra
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- bl4ckstack
|
|
@@ -79,6 +79,20 @@ dependencies:
|
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
81
|
version: '0.8'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: resolv
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 0.2.0
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 0.2.0
|
|
82
96
|
- !ruby/object:Gem::Dependency
|
|
83
97
|
name: thor
|
|
84
98
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -123,20 +137,27 @@ files:
|
|
|
123
137
|
- config/example_rules.yml
|
|
124
138
|
- lib/hedra.rb
|
|
125
139
|
- lib/hedra/analyzer.rb
|
|
140
|
+
- lib/hedra/banner.rb
|
|
126
141
|
- lib/hedra/baseline.rb
|
|
127
142
|
- lib/hedra/cache.rb
|
|
128
143
|
- lib/hedra/certificate_checker.rb
|
|
129
144
|
- lib/hedra/circuit_breaker.rb
|
|
130
145
|
- lib/hedra/cli.rb
|
|
131
146
|
- lib/hedra/config.rb
|
|
147
|
+
- lib/hedra/cors_checker.rb
|
|
148
|
+
- lib/hedra/ct_checker.rb
|
|
149
|
+
- lib/hedra/dns_checker.rb
|
|
132
150
|
- lib/hedra/exporter.rb
|
|
133
151
|
- lib/hedra/html_reporter.rb
|
|
134
152
|
- lib/hedra/http_client.rb
|
|
135
153
|
- lib/hedra/plugin_manager.rb
|
|
136
154
|
- lib/hedra/progress_tracker.rb
|
|
155
|
+
- lib/hedra/protocol_checker.rb
|
|
137
156
|
- lib/hedra/rate_limiter.rb
|
|
138
157
|
- lib/hedra/scorer.rb
|
|
139
158
|
- lib/hedra/security_txt_checker.rb
|
|
159
|
+
- lib/hedra/sri_checker.rb
|
|
160
|
+
- lib/hedra/url_validator.rb
|
|
140
161
|
- lib/hedra/version.rb
|
|
141
162
|
homepage: https://github.com/bl4ckstack/hedra
|
|
142
163
|
licenses:
|