hedra 1.0.1 → 2.0.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 +395 -107
- data/config/example_config.yml +88 -10
- data/lib/hedra/analyzer.rb +10 -2
- data/lib/hedra/baseline.rb +83 -0
- data/lib/hedra/cache.rb +67 -0
- data/lib/hedra/certificate_checker.rb +94 -0
- data/lib/hedra/circuit_breaker.rb +80 -0
- data/lib/hedra/cli.rb +232 -16
- data/lib/hedra/config.rb +1 -1
- data/lib/hedra/exporter.rb +7 -0
- data/lib/hedra/html_reporter.rb +136 -0
- data/lib/hedra/http_client.rb +46 -9
- data/lib/hedra/progress_tracker.rb +45 -0
- data/lib/hedra/rate_limiter.rb +60 -0
- data/lib/hedra/security_txt_checker.rb +93 -0
- data/lib/hedra/version.rb +1 -1
- data/lib/hedra.rb +17 -9
- metadata +10 -2
data/lib/hedra/cli.rb
CHANGED
|
@@ -42,7 +42,111 @@ module Hedra
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
class
|
|
45
|
+
class BaselineCLI < Thor
|
|
46
|
+
desc 'list', 'List saved baselines'
|
|
47
|
+
def list
|
|
48
|
+
baseline = Baseline.new
|
|
49
|
+
baselines = baseline.list
|
|
50
|
+
|
|
51
|
+
if baselines.empty?
|
|
52
|
+
puts 'No baselines saved.'
|
|
53
|
+
else
|
|
54
|
+
puts 'Saved baselines:'
|
|
55
|
+
baselines.each do |b|
|
|
56
|
+
puts " - #{b[:name]} (#{b[:url_count]} URLs, created: #{b[:created_at]})"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
desc 'compare NAME URL_OR_FILE', 'Compare current results against baseline'
|
|
62
|
+
option :file, type: :boolean, aliases: '-f', desc: 'Treat argument as file with URLs'
|
|
63
|
+
option :output, type: :string, aliases: '-o', desc: 'Output file'
|
|
64
|
+
def compare(name, target)
|
|
65
|
+
baseline = Baseline.new
|
|
66
|
+
urls = options[:file] ? File.readlines(target).map(&:strip).reject(&:empty?) : [target]
|
|
67
|
+
|
|
68
|
+
client = HttpClient.new
|
|
69
|
+
analyzer = Analyzer.new
|
|
70
|
+
current_results = []
|
|
71
|
+
|
|
72
|
+
urls.each do |url|
|
|
73
|
+
response = client.get(url)
|
|
74
|
+
result = analyzer.analyze(url, response.headers.to_h)
|
|
75
|
+
current_results << result
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
warn "Failed to scan #{url}: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
comparisons = baseline.compare(name, current_results)
|
|
81
|
+
print_comparisons(comparisons)
|
|
82
|
+
|
|
83
|
+
if options[:output]
|
|
84
|
+
File.write(options[:output], JSON.pretty_generate(comparisons))
|
|
85
|
+
puts "Comparison saved to #{options[:output]}"
|
|
86
|
+
end
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
warn "Comparison failed: #{e.message}"
|
|
89
|
+
exit 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
desc 'delete NAME', 'Delete a baseline'
|
|
93
|
+
def delete(name)
|
|
94
|
+
baseline = Baseline.new
|
|
95
|
+
baseline.delete(name)
|
|
96
|
+
puts "Baseline deleted: #{name}"
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
warn "Failed to delete baseline: #{e.message}"
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def print_comparisons(comparisons)
|
|
105
|
+
pastel = Pastel.new
|
|
106
|
+
|
|
107
|
+
comparisons.each do |comp|
|
|
108
|
+
puts "\n#{pastel.bold(comp[:url])}"
|
|
109
|
+
puts "Baseline Score: #{comp[:baseline_score]} | Current Score: #{comp[:current_score]}"
|
|
110
|
+
|
|
111
|
+
change = comp[:score_change]
|
|
112
|
+
if change.positive?
|
|
113
|
+
puts pastel.green("Score improved by #{change} points")
|
|
114
|
+
elsif change.negative?
|
|
115
|
+
puts pastel.red("Score decreased by #{change.abs} points")
|
|
116
|
+
else
|
|
117
|
+
puts 'Score unchanged'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if comp[:new_findings].any?
|
|
121
|
+
puts pastel.yellow("\nNew findings:")
|
|
122
|
+
comp[:new_findings].each { |f| puts " - #{f[:header]}: #{f[:issue]}" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if comp[:resolved_findings].any?
|
|
126
|
+
puts pastel.green("\nResolved findings:")
|
|
127
|
+
comp[:resolved_findings].each { |f| puts " - #{f[:header]}: #{f[:issue]}" }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class CacheCLI < Thor
|
|
134
|
+
desc 'clear', 'Clear response cache'
|
|
135
|
+
def clear
|
|
136
|
+
cache = Cache.new
|
|
137
|
+
cache.clear
|
|
138
|
+
puts 'Cache cleared.'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
desc 'clear-expired', 'Clear expired cache entries'
|
|
142
|
+
def clear_expired
|
|
143
|
+
cache = Cache.new
|
|
144
|
+
cache.clear_expired
|
|
145
|
+
puts 'Expired cache entries cleared.'
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
class CLI < Thor # rubocop:disable Metrics/ClassLength
|
|
46
150
|
class_option :verbose, type: :boolean, aliases: '-v', desc: 'Verbose output'
|
|
47
151
|
class_option :quiet, type: :boolean, aliases: '-q', desc: 'Quiet mode'
|
|
48
152
|
class_option :debug, type: :boolean, desc: 'Enable debug logging'
|
|
@@ -58,24 +162,69 @@ module Hedra
|
|
|
58
162
|
option :rate, type: :string, desc: 'Rate limit (e.g., 5/s)'
|
|
59
163
|
option :proxy, type: :string, desc: 'HTTP/SOCKS proxy URL'
|
|
60
164
|
option :user_agent, type: :string, desc: 'Custom User-Agent header'
|
|
61
|
-
option :follow_redirects, type: :boolean, default:
|
|
165
|
+
option :follow_redirects, type: :boolean, default: true, desc: 'Follow redirects'
|
|
62
166
|
option :output, type: :string, aliases: '-o', desc: 'Output file'
|
|
63
|
-
option :format, type: :string, default: 'table', desc: 'Output format (table, json, csv)'
|
|
64
|
-
|
|
167
|
+
option :format, type: :string, default: 'table', desc: 'Output format (table, json, csv, html)'
|
|
168
|
+
option :cache, type: :boolean, default: false, desc: 'Enable response caching'
|
|
169
|
+
option :cache_ttl, type: :numeric, default: 3600, desc: 'Cache TTL in seconds'
|
|
170
|
+
option :check_certificates, type: :boolean, default: true, desc: 'Check SSL certificates'
|
|
171
|
+
option :check_security_txt, type: :boolean, default: false, desc: 'Check for security.txt'
|
|
172
|
+
option :save_baseline, type: :string, desc: 'Save results as baseline'
|
|
173
|
+
option :progress, type: :boolean, default: true, desc: 'Show progress bar'
|
|
174
|
+
def scan(target) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
65
175
|
setup_logging
|
|
66
176
|
urls = options[:file] ? read_urls_from_file(target) : [target]
|
|
67
177
|
|
|
68
178
|
client = build_http_client
|
|
69
|
-
analyzer = Analyzer.new
|
|
179
|
+
analyzer = Analyzer.new(
|
|
180
|
+
check_certificates: options[:check_certificates],
|
|
181
|
+
check_security_txt: options[:check_security_txt]
|
|
182
|
+
)
|
|
183
|
+
cache = options[:cache] ? Cache.new(ttl: options[:cache_ttl]) : nil
|
|
184
|
+
rate_limiter = options[:rate] ? RateLimiter.new(options[:rate]) : nil
|
|
185
|
+
circuit_breakers = {}
|
|
70
186
|
results = []
|
|
71
187
|
|
|
188
|
+
progress = options[:progress] && !options[:quiet] ? ProgressTracker.new(urls.length, quiet: options[:quiet]) : nil
|
|
189
|
+
|
|
72
190
|
with_concurrency(urls, options[:concurrency]) do |url|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
191
|
+
rate_limiter&.acquire
|
|
192
|
+
|
|
193
|
+
# Get or create circuit breaker for this domain
|
|
194
|
+
domain = URI.parse(url).host
|
|
195
|
+
circuit_breakers[domain] ||= CircuitBreaker.new
|
|
196
|
+
|
|
197
|
+
begin
|
|
198
|
+
circuit_breakers[domain].call do
|
|
199
|
+
# Check cache first
|
|
200
|
+
cached = cache&.get(url)
|
|
201
|
+
if cached
|
|
202
|
+
result = cached
|
|
203
|
+
log_info("Cache hit: #{url}") if @verbose
|
|
204
|
+
else
|
|
205
|
+
response = client.get(url)
|
|
206
|
+
result = analyzer.analyze(url, response.headers.to_h, http_client: client)
|
|
207
|
+
cache&.set(url, result)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
results << result
|
|
211
|
+
print_result(result) unless options[:quiet] || options[:output]
|
|
212
|
+
end
|
|
213
|
+
rescue CircuitOpenError
|
|
214
|
+
log_error("Circuit breaker open for #{domain}, skipping #{url}")
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
log_error("Failed to scan #{url}: #{e.message}")
|
|
217
|
+
ensure
|
|
218
|
+
progress&.increment
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
progress&.finish
|
|
223
|
+
|
|
224
|
+
if options[:save_baseline]
|
|
225
|
+
baseline = Baseline.new
|
|
226
|
+
baseline.save(options[:save_baseline], results)
|
|
227
|
+
say "Baseline saved: #{options[:save_baseline]}", :green unless options[:quiet]
|
|
79
228
|
end
|
|
80
229
|
|
|
81
230
|
export_results(results) if options[:output]
|
|
@@ -87,14 +236,19 @@ module Hedra
|
|
|
87
236
|
option :proxy, type: :string, desc: 'HTTP/SOCKS proxy URL'
|
|
88
237
|
option :user_agent, type: :string, desc: 'Custom User-Agent header'
|
|
89
238
|
option :timeout, type: :numeric, aliases: '-t', default: 10, desc: 'Request timeout'
|
|
239
|
+
option :check_certificates, type: :boolean, default: true, desc: 'Check SSL certificates'
|
|
240
|
+
option :check_security_txt, type: :boolean, default: true, desc: 'Check for security.txt'
|
|
90
241
|
def audit(url)
|
|
91
242
|
setup_logging
|
|
92
243
|
client = build_http_client
|
|
93
|
-
analyzer = Analyzer.new
|
|
244
|
+
analyzer = Analyzer.new(
|
|
245
|
+
check_certificates: options[:check_certificates],
|
|
246
|
+
check_security_txt: options[:check_security_txt]
|
|
247
|
+
)
|
|
94
248
|
|
|
95
249
|
begin
|
|
96
250
|
response = client.get(url)
|
|
97
|
-
result = analyzer.analyze(url, response.headers.to_h)
|
|
251
|
+
result = analyzer.analyze(url, response.headers.to_h, http_client: client)
|
|
98
252
|
|
|
99
253
|
if options[:json]
|
|
100
254
|
output = JSON.pretty_generate(result)
|
|
@@ -163,8 +317,8 @@ module Hedra
|
|
|
163
317
|
option :output, type: :string, aliases: '-o', required: true, desc: 'Output file'
|
|
164
318
|
option :input, type: :string, aliases: '-i', desc: 'Input JSON file with results'
|
|
165
319
|
def export(format)
|
|
166
|
-
unless %w[json csv].include?(format)
|
|
167
|
-
say "Invalid format: #{format}. Use json or
|
|
320
|
+
unless %w[json csv html].include?(format)
|
|
321
|
+
say "Invalid format: #{format}. Use json, csv, or html.", :red
|
|
168
322
|
exit 1
|
|
169
323
|
end
|
|
170
324
|
|
|
@@ -177,6 +331,62 @@ module Hedra
|
|
|
177
331
|
desc 'plugin SUBCOMMAND', 'Manage plugins'
|
|
178
332
|
subcommand 'plugin', Hedra::PluginCLI
|
|
179
333
|
|
|
334
|
+
desc 'baseline SUBCOMMAND', 'Manage security baselines'
|
|
335
|
+
subcommand 'baseline', Hedra::BaselineCLI
|
|
336
|
+
|
|
337
|
+
desc 'cache SUBCOMMAND', 'Manage response cache'
|
|
338
|
+
subcommand 'cache', Hedra::CacheCLI
|
|
339
|
+
|
|
340
|
+
desc 'ci_check URL_OR_FILE', 'CI/CD friendly check (exit code based on score threshold)'
|
|
341
|
+
option :file, type: :boolean, aliases: '-f', desc: 'Treat argument as file with URLs'
|
|
342
|
+
option :threshold, type: :numeric, default: 80, desc: 'Minimum score threshold'
|
|
343
|
+
option :fail_on_critical, type: :boolean, default: true, desc: 'Fail if critical issues found'
|
|
344
|
+
option :output, type: :string, aliases: '-o', desc: 'Output file'
|
|
345
|
+
option :format, type: :string, default: 'json', desc: 'Output format (json, csv, html)'
|
|
346
|
+
def ci_check(target) # rubocop:disable Metrics/AbcSize
|
|
347
|
+
setup_logging
|
|
348
|
+
urls = options[:file] ? read_urls_from_file(target) : [target]
|
|
349
|
+
|
|
350
|
+
client = build_http_client
|
|
351
|
+
analyzer = Analyzer.new
|
|
352
|
+
results = []
|
|
353
|
+
failed = false
|
|
354
|
+
|
|
355
|
+
urls.each do |url|
|
|
356
|
+
response = client.get(url)
|
|
357
|
+
result = analyzer.analyze(url, response.headers.to_h, http_client: client)
|
|
358
|
+
results << result
|
|
359
|
+
|
|
360
|
+
if result[:score] < options[:threshold]
|
|
361
|
+
say "FAIL: #{url} - Score #{result[:score]} below threshold #{options[:threshold]}", :red
|
|
362
|
+
failed = true
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
if options[:fail_on_critical] && result[:findings].any? { |f| f[:severity] == :critical }
|
|
366
|
+
say "FAIL: #{url} - Critical security issues found", :red
|
|
367
|
+
failed = true
|
|
368
|
+
end
|
|
369
|
+
rescue StandardError => e
|
|
370
|
+
log_error("Failed to check #{url}: #{e.message}")
|
|
371
|
+
failed = true
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Export results if output specified
|
|
375
|
+
if options[:output]
|
|
376
|
+
exporter = Exporter.new
|
|
377
|
+
exporter.export(results, options[:format], options[:output])
|
|
378
|
+
say "Results exported to #{options[:output]}", :green unless options[:quiet]
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
if failed
|
|
382
|
+
say "\nCI check failed", :red
|
|
383
|
+
exit 1
|
|
384
|
+
else
|
|
385
|
+
say "\nCI check passed", :green
|
|
386
|
+
exit 0
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
180
390
|
private
|
|
181
391
|
|
|
182
392
|
def setup_logging
|
|
@@ -190,7 +400,7 @@ module Hedra
|
|
|
190
400
|
timeout: options[:timeout] || 10,
|
|
191
401
|
proxy: options[:proxy],
|
|
192
402
|
user_agent: options[:user_agent],
|
|
193
|
-
follow_redirects: options[:follow_redirects]
|
|
403
|
+
follow_redirects: options.key?(:follow_redirects) ? options[:follow_redirects] : true,
|
|
194
404
|
verbose: @verbose
|
|
195
405
|
)
|
|
196
406
|
end
|
|
@@ -295,6 +505,12 @@ module Hedra
|
|
|
295
505
|
say "Results exported to #{options[:output]}", :green unless options[:quiet]
|
|
296
506
|
end
|
|
297
507
|
|
|
508
|
+
def log_info(message)
|
|
509
|
+
return unless @verbose
|
|
510
|
+
|
|
511
|
+
puts Pastel.new.cyan("INFO: #{message}")
|
|
512
|
+
end
|
|
513
|
+
|
|
298
514
|
def severity_badge(severity)
|
|
299
515
|
pastel = Pastel.new
|
|
300
516
|
case severity.to_s
|
data/lib/hedra/config.rb
CHANGED
data/lib/hedra/exporter.rb
CHANGED
|
@@ -11,6 +11,8 @@ module Hedra
|
|
|
11
11
|
export_json(results, output_file)
|
|
12
12
|
when 'csv'
|
|
13
13
|
export_csv(results, output_file)
|
|
14
|
+
when 'html'
|
|
15
|
+
export_html(results, output_file)
|
|
14
16
|
else
|
|
15
17
|
raise Error, "Unsupported format: #{format}"
|
|
16
18
|
end
|
|
@@ -45,5 +47,10 @@ module Hedra
|
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
end
|
|
50
|
+
|
|
51
|
+
def export_html(results, output_file)
|
|
52
|
+
reporter = HtmlReporter.new
|
|
53
|
+
reporter.generate(results, output_file)
|
|
54
|
+
end
|
|
48
55
|
end
|
|
49
56
|
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Hedra
|
|
7
|
+
# Generate HTML reports
|
|
8
|
+
class HtmlReporter
|
|
9
|
+
TEMPLATE = <<~HTML.freeze
|
|
10
|
+
<!DOCTYPE html>
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="UTF-8">
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
15
|
+
<title>Hedra Security Report</title>
|
|
16
|
+
<style>
|
|
17
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
18
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
|
|
19
|
+
.container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
20
|
+
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 8px 8px 0 0; }
|
|
21
|
+
.header h1 { font-size: 32px; margin-bottom: 10px; }
|
|
22
|
+
.header .meta { opacity: 0.9; font-size: 14px; }
|
|
23
|
+
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; padding: 30px; border-bottom: 1px solid #eee; }
|
|
24
|
+
.summary-card { text-align: center; padding: 20px; background: #f9f9f9; border-radius: 8px; }
|
|
25
|
+
.summary-card .value { font-size: 36px; font-weight: bold; margin: 10px 0; }
|
|
26
|
+
.summary-card .label { color: #666; font-size: 14px; }
|
|
27
|
+
.score-a { color: #10b981; }
|
|
28
|
+
.score-b { color: #f59e0b; }
|
|
29
|
+
.score-c { color: #ef4444; }
|
|
30
|
+
.results { padding: 30px; }
|
|
31
|
+
.result-item { margin-bottom: 30px; border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
|
|
32
|
+
.result-header { background: #f9f9f9; padding: 20px; border-bottom: 1px solid #eee; }
|
|
33
|
+
.result-header h2 { font-size: 18px; margin-bottom: 10px; word-break: break-all; }
|
|
34
|
+
.result-header .score { display: inline-block; padding: 5px 15px; border-radius: 20px; font-weight: bold; font-size: 14px; }
|
|
35
|
+
.result-body { padding: 20px; }
|
|
36
|
+
.finding { padding: 15px; margin-bottom: 10px; border-left: 4px solid; border-radius: 4px; background: #f9f9f9; }
|
|
37
|
+
.finding.critical { border-color: #ef4444; background: #fef2f2; }
|
|
38
|
+
.finding.warning { border-color: #f59e0b; background: #fffbeb; }
|
|
39
|
+
.finding.info { border-color: #3b82f6; background: #eff6ff; }
|
|
40
|
+
.finding-header { font-weight: bold; margin-bottom: 5px; }
|
|
41
|
+
.finding-issue { margin-bottom: 5px; }
|
|
42
|
+
.finding-fix { font-size: 14px; color: #666; font-style: italic; }
|
|
43
|
+
.badge { display: inline-block; padding: 3px 8px; border-radius: 3px; font-size: 12px; font-weight: bold; text-transform: uppercase; }
|
|
44
|
+
.badge.critical { background: #ef4444; color: white; }
|
|
45
|
+
.badge.warning { background: #f59e0b; color: white; }
|
|
46
|
+
.badge.info { background: #3b82f6; color: white; }
|
|
47
|
+
.no-findings { text-align: center; padding: 40px; color: #10b981; font-size: 18px; }
|
|
48
|
+
.footer { text-align: center; padding: 20px; color: #666; font-size: 14px; border-top: 1px solid #eee; }
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<div class="container">
|
|
53
|
+
<div class="header">
|
|
54
|
+
<h1>🛡️ Hedra Security Report</h1>
|
|
55
|
+
<div class="meta">Generated: <%= Time.now.strftime('%Y-%m-%d %H:%M:%S %Z') %></div>
|
|
56
|
+
</div>
|
|
57
|
+
#{' '}
|
|
58
|
+
<div class="summary">
|
|
59
|
+
<div class="summary-card">
|
|
60
|
+
<div class="label">URLs Scanned</div>
|
|
61
|
+
<div class="value"><%= results.length %></div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="summary-card">
|
|
64
|
+
<div class="label">Average Score</div>
|
|
65
|
+
<div class="value <%= score_class(avg_score) %>"><%= avg_score.round %>/100</div>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="summary-card">
|
|
68
|
+
<div class="label">Total Findings</div>
|
|
69
|
+
<div class="value"><%= total_findings %></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="summary-card">
|
|
72
|
+
<div class="label">Critical Issues</div>
|
|
73
|
+
<div class="value" style="color: #ef4444;"><%= critical_count %></div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
#{' '}
|
|
77
|
+
<div class="results">
|
|
78
|
+
<% results.each do |result| %>
|
|
79
|
+
<div class="result-item">
|
|
80
|
+
<div class="result-header">
|
|
81
|
+
<h2><%= result[:url] %></h2>
|
|
82
|
+
<span class="score <%= score_class(result[:score]) %>">Score: <%= result[:score] %>/100</span>
|
|
83
|
+
<span style="color: #666; margin-left: 15px; font-size: 14px;"><%= result[:timestamp] %></span>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="result-body">
|
|
86
|
+
<% if result[:findings].empty? %>
|
|
87
|
+
<div class="no-findings">✓ All security headers properly configured</div>
|
|
88
|
+
<% else %>
|
|
89
|
+
<% result[:findings].each do |finding| %>
|
|
90
|
+
<div class="finding <%= finding[:severity] %>">
|
|
91
|
+
<div class="finding-header">
|
|
92
|
+
<span class="badge <%= finding[:severity] %>"><%= finding[:severity] %></span>
|
|
93
|
+
<%= finding[:header] %>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="finding-issue"><%= finding[:issue] %></div>
|
|
96
|
+
<% if finding[:recommended_fix] %>
|
|
97
|
+
<div class="finding-fix">💡 <%= finding[:recommended_fix] %></div>
|
|
98
|
+
<% end %>
|
|
99
|
+
</div>
|
|
100
|
+
<% end %>
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<% end %>
|
|
105
|
+
</div>
|
|
106
|
+
#{' '}
|
|
107
|
+
<div class="footer">
|
|
108
|
+
Generated by Hedra <%= Hedra::VERSION %> | <a href="https://github.com/blackstack/hedra">GitHub</a>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</body>
|
|
112
|
+
</html>
|
|
113
|
+
HTML
|
|
114
|
+
|
|
115
|
+
def generate(results, output_file)
|
|
116
|
+
avg_score = results.sum { |r| r[:score] }.to_f / results.length
|
|
117
|
+
total_findings = results.sum { |r| r[:findings].length }
|
|
118
|
+
critical_count = results.sum { |r| r[:findings].count { |f| f[:severity] == :critical } }
|
|
119
|
+
|
|
120
|
+
html = ERB.new(TEMPLATE).result(binding)
|
|
121
|
+
File.write(output_file, html)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def score_class(score)
|
|
127
|
+
if score >= 80
|
|
128
|
+
'score-a'
|
|
129
|
+
elsif score >= 60
|
|
130
|
+
'score-b'
|
|
131
|
+
else
|
|
132
|
+
'score-c'
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/hedra/http_client.rb
CHANGED
|
@@ -8,16 +8,20 @@ module Hedra
|
|
|
8
8
|
DEFAULT_USER_AGENT = "Hedra/#{VERSION} Security Header Analyzer".freeze
|
|
9
9
|
MAX_RETRIES = 3
|
|
10
10
|
RETRY_DELAY = 1
|
|
11
|
+
MAX_REDIRECTS = 10
|
|
11
12
|
|
|
12
|
-
def initialize(
|
|
13
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
14
|
+
timeout: 10, proxy: nil, user_agent: nil, follow_redirects: true, verbose: false, max_retries: MAX_RETRIES
|
|
15
|
+
)
|
|
13
16
|
@timeout = timeout
|
|
14
17
|
@proxy = proxy
|
|
15
18
|
@user_agent = user_agent || DEFAULT_USER_AGENT
|
|
16
19
|
@follow_redirects = follow_redirects
|
|
17
20
|
@verbose = verbose
|
|
21
|
+
@max_retries = max_retries
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
def get(url)
|
|
24
|
+
def get(url, redirect_count: 0)
|
|
21
25
|
retries = 0
|
|
22
26
|
|
|
23
27
|
begin
|
|
@@ -27,23 +31,28 @@ module Hedra
|
|
|
27
31
|
response = client.get(url)
|
|
28
32
|
|
|
29
33
|
if @follow_redirects && response.status.redirect?
|
|
34
|
+
raise NetworkError, "Too many redirects (#{MAX_REDIRECTS})" if redirect_count >= MAX_REDIRECTS
|
|
35
|
+
|
|
30
36
|
location = response.headers['Location']
|
|
37
|
+
location = resolve_redirect_url(url, location)
|
|
31
38
|
log "Following redirect to #{location}"
|
|
32
|
-
return get(location)
|
|
39
|
+
return get(location, redirect_count: redirect_count + 1)
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
raise NetworkError, "HTTP #{response.status}: #{response.status.reason}" unless response.status.success?
|
|
36
43
|
|
|
37
44
|
log "Success: #{response.status}"
|
|
38
45
|
response
|
|
39
|
-
rescue HTTP::Error, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
46
|
+
rescue HTTP::Error, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH => e
|
|
40
47
|
retries += 1
|
|
41
|
-
|
|
48
|
+
if retries <= @max_retries && retryable_error?(e)
|
|
49
|
+
delay = RETRY_DELAY * (2**(retries - 1))
|
|
50
|
+
log "Retry #{retries}/#{@max_retries} after #{delay}s: #{e.message}"
|
|
51
|
+
sleep delay
|
|
52
|
+
retry
|
|
53
|
+
end
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
log "Retry #{retries}/#{MAX_RETRIES} after #{delay}s: #{e.message}"
|
|
45
|
-
sleep delay
|
|
46
|
-
retry
|
|
55
|
+
raise NetworkError, "Failed after #{@max_retries} retries: #{e.message}"
|
|
47
56
|
end
|
|
48
57
|
end
|
|
49
58
|
|
|
@@ -62,6 +71,34 @@ module Hedra
|
|
|
62
71
|
client
|
|
63
72
|
end
|
|
64
73
|
|
|
74
|
+
def resolve_redirect_url(base_url, location)
|
|
75
|
+
# Handle relative redirects
|
|
76
|
+
return location if location.start_with?('http://', 'https://')
|
|
77
|
+
|
|
78
|
+
base_uri = URI.parse(base_url)
|
|
79
|
+
port_part = if base_uri.port && ![80, 443].include?(base_uri.port)
|
|
80
|
+
":#{base_uri.port}"
|
|
81
|
+
else
|
|
82
|
+
''
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if location.start_with?('/')
|
|
86
|
+
"#{base_uri.scheme}://#{base_uri.host}#{port_part}#{location}"
|
|
87
|
+
else
|
|
88
|
+
# Relative to current path
|
|
89
|
+
base_path = base_uri.path.split('/')[0..-2].join('/')
|
|
90
|
+
"#{base_uri.scheme}://#{base_uri.host}#{port_part}#{base_path}/#{location}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def retryable_error?(error)
|
|
95
|
+
# Retry on network errors, timeouts, but not on HTTP errors like 404
|
|
96
|
+
error.is_a?(HTTP::TimeoutError) ||
|
|
97
|
+
error.is_a?(Errno::ECONNREFUSED) ||
|
|
98
|
+
error.is_a?(Errno::ETIMEDOUT) ||
|
|
99
|
+
error.is_a?(Errno::EHOSTUNREACH)
|
|
100
|
+
end
|
|
101
|
+
|
|
65
102
|
def log(message)
|
|
66
103
|
puts "[HTTP] #{message}" if @verbose
|
|
67
104
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hedra
|
|
4
|
+
# Track and display progress for batch operations
|
|
5
|
+
class ProgressTracker
|
|
6
|
+
def initialize(total, quiet: false)
|
|
7
|
+
@total = total
|
|
8
|
+
@current = 0
|
|
9
|
+
@quiet = quiet
|
|
10
|
+
@start_time = Time.now
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def increment
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
@current += 1
|
|
17
|
+
update_display unless @quiet
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def finish
|
|
22
|
+
return if @quiet
|
|
23
|
+
|
|
24
|
+
puts "\n"
|
|
25
|
+
elapsed = Time.now - @start_time
|
|
26
|
+
puts "Completed #{@total} items in #{elapsed.round(2)}s"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def update_display
|
|
32
|
+
percentage = (@current.to_f / @total * 100).round(1)
|
|
33
|
+
bar_width = 40
|
|
34
|
+
filled = (bar_width * @current / @total).round
|
|
35
|
+
bar = ('█' * filled) + ('░' * (bar_width - filled))
|
|
36
|
+
|
|
37
|
+
elapsed = Time.now - @start_time
|
|
38
|
+
rate = @current / elapsed
|
|
39
|
+
eta = (@total - @current) / rate
|
|
40
|
+
|
|
41
|
+
print "\r[#{bar}] #{percentage}% (#{@current}/#{@total}) ETA: #{eta.round}s"
|
|
42
|
+
$stdout.flush
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hedra
|
|
4
|
+
# Token bucket rate limiter
|
|
5
|
+
class RateLimiter
|
|
6
|
+
def initialize(rate_string)
|
|
7
|
+
@requests, period = parse_rate(rate_string)
|
|
8
|
+
@period = period_to_seconds(period)
|
|
9
|
+
@tokens = @requests
|
|
10
|
+
@last_refill = Time.now
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def acquire
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
refill_tokens
|
|
17
|
+
|
|
18
|
+
if @tokens >= 1
|
|
19
|
+
@tokens -= 1
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Wait until we have tokens
|
|
24
|
+
sleep_time = @period / @requests
|
|
25
|
+
sleep(sleep_time)
|
|
26
|
+
refill_tokens
|
|
27
|
+
@tokens -= 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def parse_rate(rate_string)
|
|
34
|
+
# Format: "10/s", "100/m", "1000/h"
|
|
35
|
+
match = rate_string.match(%r{^(\d+)/([smh])$})
|
|
36
|
+
raise Error, "Invalid rate format: #{rate_string}" unless match
|
|
37
|
+
|
|
38
|
+
[match[1].to_i, match[2]]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def period_to_seconds(period)
|
|
42
|
+
case period
|
|
43
|
+
when 's' then 1
|
|
44
|
+
when 'm' then 60
|
|
45
|
+
when 'h' then 3600
|
|
46
|
+
else
|
|
47
|
+
raise Error, "Invalid period: #{period}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def refill_tokens
|
|
52
|
+
now = Time.now
|
|
53
|
+
elapsed = now - @last_refill
|
|
54
|
+
|
|
55
|
+
tokens_to_add = (elapsed / @period) * @requests
|
|
56
|
+
@tokens = [@tokens + tokens_to_add, @requests].min
|
|
57
|
+
@last_refill = now
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|