hedra 1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/bin/hedra +14 -0
- data/config/example_config.yml +17 -0
- data/config/example_rules.yml +16 -0
- data/lib/hedra/analyzer.rb +216 -0
- data/lib/hedra/cli.rb +336 -0
- data/lib/hedra/config.rb +46 -0
- data/lib/hedra/exporter.rb +49 -0
- data/lib/hedra/http_client.rb +69 -0
- data/lib/hedra/plugin_manager.rb +70 -0
- data/lib/hedra/scorer.rb +54 -0
- data/lib/hedra/version.rb +5 -0
- data/lib/hedra.rb +16 -0
- metadata +141 -0
data/lib/hedra/cli.rb
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
require 'pastel'
|
|
7
|
+
require 'tty-table'
|
|
8
|
+
|
|
9
|
+
module Hedra
|
|
10
|
+
class PluginCLI < Thor
|
|
11
|
+
desc 'list', 'List installed plugins'
|
|
12
|
+
def list
|
|
13
|
+
manager = PluginManager.new
|
|
14
|
+
plugins = manager.list_plugins
|
|
15
|
+
|
|
16
|
+
if plugins.empty?
|
|
17
|
+
puts 'No plugins installed.'
|
|
18
|
+
else
|
|
19
|
+
puts 'Installed plugins:'
|
|
20
|
+
plugins.each { |p| puts " - #{p}" }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc 'install PATH', 'Install a plugin from path'
|
|
25
|
+
def install(path)
|
|
26
|
+
manager = PluginManager.new
|
|
27
|
+
manager.install(path)
|
|
28
|
+
puts "Plugin installed: #{path}"
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
warn "Failed to install plugin: #{e.message}"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc 'remove NAME', 'Remove an installed plugin'
|
|
35
|
+
def remove(name)
|
|
36
|
+
manager = PluginManager.new
|
|
37
|
+
manager.remove(name)
|
|
38
|
+
puts "Plugin removed: #{name}"
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
warn "Failed to remove plugin: #{e.message}"
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class CLI < Thor
|
|
46
|
+
class_option :verbose, type: :boolean, aliases: '-v', desc: 'Verbose output'
|
|
47
|
+
class_option :quiet, type: :boolean, aliases: '-q', desc: 'Quiet mode'
|
|
48
|
+
class_option :debug, type: :boolean, desc: 'Enable debug logging'
|
|
49
|
+
|
|
50
|
+
def self.exit_on_failure?
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc 'scan URL_OR_FILE', 'Scan one or multiple URLs for security headers'
|
|
55
|
+
option :file, type: :boolean, aliases: '-f', desc: 'Treat argument as file with URLs'
|
|
56
|
+
option :concurrency, type: :numeric, aliases: '-c', default: 10, desc: 'Concurrent requests'
|
|
57
|
+
option :timeout, type: :numeric, aliases: '-t', default: 10, desc: 'Request timeout in seconds'
|
|
58
|
+
option :rate, type: :string, desc: 'Rate limit (e.g., 5/s)'
|
|
59
|
+
option :proxy, type: :string, desc: 'HTTP/SOCKS proxy URL'
|
|
60
|
+
option :user_agent, type: :string, desc: 'Custom User-Agent header'
|
|
61
|
+
option :follow_redirects, type: :boolean, default: false, desc: 'Follow redirects'
|
|
62
|
+
option :output, type: :string, aliases: '-o', desc: 'Output file'
|
|
63
|
+
option :format, type: :string, default: 'table', desc: 'Output format (table, json, csv)'
|
|
64
|
+
def scan(target)
|
|
65
|
+
setup_logging
|
|
66
|
+
urls = options[:file] ? read_urls_from_file(target) : [target]
|
|
67
|
+
|
|
68
|
+
client = build_http_client
|
|
69
|
+
analyzer = Analyzer.new
|
|
70
|
+
results = []
|
|
71
|
+
|
|
72
|
+
with_concurrency(urls, options[:concurrency]) do |url|
|
|
73
|
+
response = client.get(url)
|
|
74
|
+
result = analyzer.analyze(url, response.headers.to_h)
|
|
75
|
+
results << result
|
|
76
|
+
print_result(result) unless options[:quiet] || options[:output]
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
log_error("Failed to scan #{url}: #{e.message}")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
export_results(results) if options[:output]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
desc 'audit URL', 'Deep security header audit for a single URL'
|
|
85
|
+
option :json, type: :boolean, desc: 'Output as JSON'
|
|
86
|
+
option :output, type: :string, aliases: '-o', desc: 'Output file'
|
|
87
|
+
option :proxy, type: :string, desc: 'HTTP/SOCKS proxy URL'
|
|
88
|
+
option :user_agent, type: :string, desc: 'Custom User-Agent header'
|
|
89
|
+
option :timeout, type: :numeric, aliases: '-t', default: 10, desc: 'Request timeout'
|
|
90
|
+
def audit(url)
|
|
91
|
+
setup_logging
|
|
92
|
+
client = build_http_client
|
|
93
|
+
analyzer = Analyzer.new
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
response = client.get(url)
|
|
97
|
+
result = analyzer.analyze(url, response.headers.to_h)
|
|
98
|
+
|
|
99
|
+
if options[:json]
|
|
100
|
+
output = JSON.pretty_generate(result)
|
|
101
|
+
if options[:output]
|
|
102
|
+
File.write(options[:output], output)
|
|
103
|
+
say "Audit saved to #{options[:output]}", :green unless options[:quiet]
|
|
104
|
+
else
|
|
105
|
+
puts output
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
print_detailed_result(result)
|
|
109
|
+
end
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
log_error("Audit failed: #{e.message}")
|
|
112
|
+
exit 1
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
desc 'watch URL', 'Periodically monitor security headers'
|
|
117
|
+
option :interval, type: :numeric, default: 3600, desc: 'Check interval in seconds'
|
|
118
|
+
option :proxy, type: :string, desc: 'HTTP/SOCKS proxy URL'
|
|
119
|
+
option :user_agent, type: :string, desc: 'Custom User-Agent header'
|
|
120
|
+
def watch(url)
|
|
121
|
+
setup_logging
|
|
122
|
+
client = build_http_client
|
|
123
|
+
analyzer = Analyzer.new
|
|
124
|
+
|
|
125
|
+
say "Watching #{url} every #{options[:interval]} seconds. Press Ctrl+C to stop.", :cyan
|
|
126
|
+
|
|
127
|
+
loop do
|
|
128
|
+
begin
|
|
129
|
+
response = client.get(url)
|
|
130
|
+
result = analyzer.analyze(url, response.headers.to_h)
|
|
131
|
+
print_result(result)
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
log_error("Watch check failed: #{e.message}")
|
|
134
|
+
end
|
|
135
|
+
sleep options[:interval]
|
|
136
|
+
end
|
|
137
|
+
rescue Interrupt
|
|
138
|
+
say "\nStopped watching.", :yellow
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
desc 'compare URL1 URL2', 'Compare security headers between two URLs'
|
|
142
|
+
option :output, type: :string, aliases: '-o', desc: 'Output file'
|
|
143
|
+
def compare(url1, url2)
|
|
144
|
+
setup_logging
|
|
145
|
+
client = build_http_client
|
|
146
|
+
analyzer = Analyzer.new
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
response1 = client.get(url1)
|
|
150
|
+
response2 = client.get(url2)
|
|
151
|
+
|
|
152
|
+
result1 = analyzer.analyze(url1, response1.headers.to_h)
|
|
153
|
+
result2 = analyzer.analyze(url2, response2.headers.to_h)
|
|
154
|
+
|
|
155
|
+
print_comparison(result1, result2)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
log_error("Comparison failed: #{e.message}")
|
|
158
|
+
exit 1
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
desc 'export FORMAT', 'Export previous results in specified format'
|
|
163
|
+
option :output, type: :string, aliases: '-o', required: true, desc: 'Output file'
|
|
164
|
+
option :input, type: :string, aliases: '-i', desc: 'Input JSON file with results'
|
|
165
|
+
def export(format)
|
|
166
|
+
unless %w[json csv].include?(format)
|
|
167
|
+
say "Invalid format: #{format}. Use json or csv.", :red
|
|
168
|
+
exit 1
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
results = options[:input] ? JSON.parse(File.read(options[:input])) : []
|
|
172
|
+
exporter = Exporter.new
|
|
173
|
+
exporter.export(results, format, options[:output])
|
|
174
|
+
say "Exported to #{options[:output]}", :green unless options[:quiet]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
desc 'plugin SUBCOMMAND', 'Manage plugins'
|
|
178
|
+
subcommand 'plugin', Hedra::PluginCLI
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def setup_logging
|
|
183
|
+
@pastel = Pastel.new
|
|
184
|
+
@verbose = options[:verbose]
|
|
185
|
+
@debug = options[:debug]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def build_http_client
|
|
189
|
+
HttpClient.new(
|
|
190
|
+
timeout: options[:timeout] || 10,
|
|
191
|
+
proxy: options[:proxy],
|
|
192
|
+
user_agent: options[:user_agent],
|
|
193
|
+
follow_redirects: options[:follow_redirects] || false,
|
|
194
|
+
verbose: @verbose
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def read_urls_from_file(file)
|
|
199
|
+
File.readlines(file).map(&:strip).reject(&:empty?)
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
log_error("Failed to read file #{file}: #{e.message}")
|
|
202
|
+
exit 1
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def with_concurrency(items, concurrency)
|
|
206
|
+
require 'concurrent'
|
|
207
|
+
pool = Concurrent::FixedThreadPool.new(concurrency)
|
|
208
|
+
|
|
209
|
+
items.each do |item|
|
|
210
|
+
pool.post { yield item }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
pool.shutdown
|
|
214
|
+
pool.wait_for_termination
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def print_result(result)
|
|
218
|
+
pastel = Pastel.new
|
|
219
|
+
|
|
220
|
+
puts "\n#{pastel.bold(result[:url])}"
|
|
221
|
+
puts "Score: #{score_color(result[:score])}/100"
|
|
222
|
+
puts "Timestamp: #{result[:timestamp]}"
|
|
223
|
+
|
|
224
|
+
if result[:findings].any?
|
|
225
|
+
table = TTY::Table.new(
|
|
226
|
+
header: %w[Header Issue Severity],
|
|
227
|
+
rows: result[:findings].map do |f|
|
|
228
|
+
[f[:header], f[:issue], severity_badge(f[:severity])]
|
|
229
|
+
end
|
|
230
|
+
)
|
|
231
|
+
puts table.render(:unicode)
|
|
232
|
+
else
|
|
233
|
+
puts pastel.green('✓ All security headers properly configured')
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def print_detailed_result(result)
|
|
238
|
+
pastel = Pastel.new
|
|
239
|
+
|
|
240
|
+
puts pastel.bold.cyan("\n═══ Security Header Audit ═══")
|
|
241
|
+
puts "URL: #{result[:url]}"
|
|
242
|
+
puts "Timestamp: #{result[:timestamp]}"
|
|
243
|
+
puts "Security Score: #{score_color(result[:score])}/100\n"
|
|
244
|
+
|
|
245
|
+
puts pastel.bold("\nHeaders Present:")
|
|
246
|
+
result[:headers].each do |name, value|
|
|
247
|
+
puts " #{pastel.cyan(name)}: #{value[0..80]}#{'...' if value.length > 80}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if result[:findings].any?
|
|
251
|
+
puts pastel.bold("\nFindings:")
|
|
252
|
+
result[:findings].group_by { |f| f[:severity] }.each do |severity, findings|
|
|
253
|
+
puts "\n#{severity_badge(severity)} #{severity.upcase}"
|
|
254
|
+
findings.each do |f|
|
|
255
|
+
puts " • #{f[:header]}: #{f[:issue]}"
|
|
256
|
+
puts " Fix: #{f[:recommended_fix]}" if f[:recommended_fix]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
else
|
|
260
|
+
puts pastel.green("\n✓ No issues found")
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def print_comparison(result1, result2)
|
|
265
|
+
pastel = Pastel.new
|
|
266
|
+
|
|
267
|
+
puts pastel.bold.cyan("\n═══ Header Comparison ═══")
|
|
268
|
+
puts "URL 1: #{result1[:url]} (Score: #{result1[:score]})"
|
|
269
|
+
puts "URL 2: #{result2[:url]} (Score: #{result2[:score]})"
|
|
270
|
+
|
|
271
|
+
headers1 = result1[:headers].keys.map(&:downcase)
|
|
272
|
+
headers2 = result2[:headers].keys.map(&:downcase)
|
|
273
|
+
|
|
274
|
+
only_in_1 = headers1 - headers2
|
|
275
|
+
only_in_2 = headers2 - headers1
|
|
276
|
+
common = headers1 & headers2
|
|
277
|
+
|
|
278
|
+
if only_in_1.any?
|
|
279
|
+
puts pastel.yellow("\nOnly in URL 1:")
|
|
280
|
+
only_in_1.each { |h| puts " - #{h}" }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if only_in_2.any?
|
|
284
|
+
puts pastel.yellow("\nOnly in URL 2:")
|
|
285
|
+
only_in_2.each { |h| puts " - #{h}" }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
puts pastel.cyan("\nCommon headers: #{common.size}")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def export_results(results)
|
|
292
|
+
format = options[:format] || 'json'
|
|
293
|
+
exporter = Exporter.new
|
|
294
|
+
exporter.export(results, format, options[:output])
|
|
295
|
+
say "Results exported to #{options[:output]}", :green unless options[:quiet]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def severity_badge(severity)
|
|
299
|
+
pastel = Pastel.new
|
|
300
|
+
case severity.to_s
|
|
301
|
+
when 'critical'
|
|
302
|
+
pastel.red.bold('● CRITICAL')
|
|
303
|
+
when 'warning'
|
|
304
|
+
pastel.yellow.bold('● WARNING')
|
|
305
|
+
when 'info'
|
|
306
|
+
pastel.blue('● INFO')
|
|
307
|
+
else
|
|
308
|
+
severity.to_s
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def score_color(score)
|
|
313
|
+
pastel = Pastel.new
|
|
314
|
+
if score >= 80
|
|
315
|
+
pastel.green.bold(score.to_s)
|
|
316
|
+
elsif score >= 60
|
|
317
|
+
pastel.yellow(score.to_s)
|
|
318
|
+
else
|
|
319
|
+
pastel.red(score.to_s)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def log_error(message)
|
|
324
|
+
return if options[:quiet]
|
|
325
|
+
|
|
326
|
+
warn Pastel.new.red("ERROR: #{message}")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def say(message, color = nil)
|
|
330
|
+
return if options[:quiet]
|
|
331
|
+
|
|
332
|
+
output = color ? Pastel.new.send(color, message) : message
|
|
333
|
+
puts output
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
data/lib/hedra/config.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Hedra
|
|
7
|
+
class Config
|
|
8
|
+
CONFIG_DIR = File.expand_path('~/.hedra')
|
|
9
|
+
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml')
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG = {
|
|
12
|
+
'timeout' => 10,
|
|
13
|
+
'concurrency' => 10,
|
|
14
|
+
'follow_redirects' => false,
|
|
15
|
+
'user_agent' => "Hedra/#{VERSION}",
|
|
16
|
+
'proxy' => nil,
|
|
17
|
+
'output_format' => 'table'
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.load
|
|
21
|
+
ensure_config_dir
|
|
22
|
+
|
|
23
|
+
if File.exist?(CONFIG_FILE)
|
|
24
|
+
YAML.load_file(CONFIG_FILE)
|
|
25
|
+
else
|
|
26
|
+
DEFAULT_CONFIG
|
|
27
|
+
end
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
warn "Failed to load config: #{e.message}"
|
|
30
|
+
DEFAULT_CONFIG
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.save(config)
|
|
34
|
+
ensure_config_dir
|
|
35
|
+
File.write(CONFIG_FILE, YAML.dump(config))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.ensure_config_dir
|
|
39
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.plugin_dir
|
|
43
|
+
File.join(CONFIG_DIR, 'plugins')
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'csv'
|
|
5
|
+
|
|
6
|
+
module Hedra
|
|
7
|
+
class Exporter
|
|
8
|
+
def export(results, format, output_file)
|
|
9
|
+
case format
|
|
10
|
+
when 'json'
|
|
11
|
+
export_json(results, output_file)
|
|
12
|
+
when 'csv'
|
|
13
|
+
export_csv(results, output_file)
|
|
14
|
+
else
|
|
15
|
+
raise Error, "Unsupported format: #{format}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def export_json(results, output_file)
|
|
22
|
+
File.write(output_file, JSON.pretty_generate(results))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def export_csv(results, output_file)
|
|
26
|
+
CSV.open(output_file, 'w') do |csv|
|
|
27
|
+
csv << %w[URL Timestamp Score Header Issue Severity Fix]
|
|
28
|
+
|
|
29
|
+
results.each do |result|
|
|
30
|
+
if result[:findings].empty?
|
|
31
|
+
csv << [result[:url], result[:timestamp], result[:score], '', 'No issues', '', '']
|
|
32
|
+
else
|
|
33
|
+
result[:findings].each do |finding|
|
|
34
|
+
csv << [
|
|
35
|
+
result[:url],
|
|
36
|
+
result[:timestamp],
|
|
37
|
+
result[:score],
|
|
38
|
+
finding[:header],
|
|
39
|
+
finding[:issue],
|
|
40
|
+
finding[:severity],
|
|
41
|
+
finding[:recommended_fix]
|
|
42
|
+
]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Hedra
|
|
7
|
+
class HttpClient
|
|
8
|
+
DEFAULT_USER_AGENT = "Hedra/#{VERSION} Security Header Analyzer".freeze
|
|
9
|
+
MAX_RETRIES = 3
|
|
10
|
+
RETRY_DELAY = 1
|
|
11
|
+
|
|
12
|
+
def initialize(timeout: 10, proxy: nil, user_agent: nil, follow_redirects: false, verbose: false)
|
|
13
|
+
@timeout = timeout
|
|
14
|
+
@proxy = proxy
|
|
15
|
+
@user_agent = user_agent || DEFAULT_USER_AGENT
|
|
16
|
+
@follow_redirects = follow_redirects
|
|
17
|
+
@verbose = verbose
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get(url)
|
|
21
|
+
retries = 0
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
log "Fetching #{url}..."
|
|
25
|
+
|
|
26
|
+
client = build_client
|
|
27
|
+
response = client.get(url)
|
|
28
|
+
|
|
29
|
+
if @follow_redirects && response.status.redirect?
|
|
30
|
+
location = response.headers['Location']
|
|
31
|
+
log "Following redirect to #{location}"
|
|
32
|
+
return get(location)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
raise NetworkError, "HTTP #{response.status}: #{response.status.reason}" unless response.status.success?
|
|
36
|
+
|
|
37
|
+
log "Success: #{response.status}"
|
|
38
|
+
response
|
|
39
|
+
rescue HTTP::Error, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
40
|
+
retries += 1
|
|
41
|
+
raise NetworkError, "Failed after #{MAX_RETRIES} retries: #{e.message}" unless retries <= MAX_RETRIES
|
|
42
|
+
|
|
43
|
+
delay = RETRY_DELAY * (2**(retries - 1))
|
|
44
|
+
log "Retry #{retries}/#{MAX_RETRIES} after #{delay}s: #{e.message}"
|
|
45
|
+
sleep delay
|
|
46
|
+
retry
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def build_client
|
|
53
|
+
client = HTTP
|
|
54
|
+
.timeout(connect: @timeout, read: @timeout)
|
|
55
|
+
.headers('User-Agent' => @user_agent)
|
|
56
|
+
|
|
57
|
+
if @proxy
|
|
58
|
+
proxy_uri = URI.parse(@proxy)
|
|
59
|
+
client = client.via(proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
client
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def log(message)
|
|
66
|
+
puts "[HTTP] #{message}" if @verbose
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Hedra
|
|
6
|
+
class PluginManager
|
|
7
|
+
def initialize
|
|
8
|
+
@plugin_dir = Config.plugin_dir
|
|
9
|
+
FileUtils.mkdir_p(@plugin_dir)
|
|
10
|
+
load_plugins
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def list_plugins
|
|
14
|
+
Dir.glob(File.join(@plugin_dir, '*.rb')).map { |f| File.basename(f, '.rb') }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def install(path)
|
|
18
|
+
raise Error, "Plugin file not found: #{path}" unless File.exist?(path)
|
|
19
|
+
|
|
20
|
+
plugin_name = File.basename(path)
|
|
21
|
+
dest = File.join(@plugin_dir, plugin_name)
|
|
22
|
+
FileUtils.cp(path, dest)
|
|
23
|
+
load_plugin(dest)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def remove(name)
|
|
27
|
+
plugin_file = File.join(@plugin_dir, "#{name}.rb")
|
|
28
|
+
raise Error, "Plugin not found: #{name}" unless File.exist?(plugin_file)
|
|
29
|
+
|
|
30
|
+
FileUtils.rm(plugin_file)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_checks(headers)
|
|
34
|
+
findings = []
|
|
35
|
+
|
|
36
|
+
@plugins.each do |plugin|
|
|
37
|
+
result = plugin.check(headers)
|
|
38
|
+
findings.concat(result) if result.is_a?(Array)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
warn "Plugin #{plugin.class.name} failed: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
findings
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def load_plugins
|
|
49
|
+
@plugins = []
|
|
50
|
+
|
|
51
|
+
Dir.glob(File.join(@plugin_dir, '*.rb')).each do |file|
|
|
52
|
+
load_plugin(file)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def load_plugin(file)
|
|
57
|
+
require file
|
|
58
|
+
# Plugins should define classes that respond to .check(headers)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
warn "Failed to load plugin #{file}: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Base class for plugins
|
|
65
|
+
class Plugin
|
|
66
|
+
def self.check(headers)
|
|
67
|
+
raise NotImplementedError, 'Plugins must implement .check(headers)'
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/hedra/scorer.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hedra
|
|
4
|
+
class Scorer
|
|
5
|
+
HEADER_WEIGHTS = {
|
|
6
|
+
'content-security-policy' => 25,
|
|
7
|
+
'strict-transport-security' => 25,
|
|
8
|
+
'x-frame-options' => 15,
|
|
9
|
+
'x-content-type-options' => 10,
|
|
10
|
+
'referrer-policy' => 10,
|
|
11
|
+
'permissions-policy' => 5,
|
|
12
|
+
'cross-origin-opener-policy' => 5,
|
|
13
|
+
'cross-origin-embedder-policy' => 3,
|
|
14
|
+
'cross-origin-resource-policy' => 2
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
SEVERITY_PENALTIES = {
|
|
18
|
+
critical: 20,
|
|
19
|
+
warning: 10,
|
|
20
|
+
info: 5
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def calculate(headers, findings)
|
|
24
|
+
base_score = calculate_base_score(headers)
|
|
25
|
+
penalty = calculate_penalty(findings)
|
|
26
|
+
|
|
27
|
+
score = [base_score - penalty, 0].max
|
|
28
|
+
score.round
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def calculate_base_score(headers)
|
|
34
|
+
score = 0
|
|
35
|
+
|
|
36
|
+
HEADER_WEIGHTS.each do |header, weight|
|
|
37
|
+
score += weight if headers.key?(header)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
score
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def calculate_penalty(findings)
|
|
44
|
+
penalty = 0
|
|
45
|
+
|
|
46
|
+
findings.each do |finding|
|
|
47
|
+
severity = finding[:severity].to_sym
|
|
48
|
+
penalty += SEVERITY_PENALTIES[severity] || 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
penalty
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/hedra.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'hedra/version'
|
|
4
|
+
require_relative 'hedra/cli'
|
|
5
|
+
require_relative 'hedra/analyzer'
|
|
6
|
+
require_relative 'hedra/http_client'
|
|
7
|
+
require_relative 'hedra/config'
|
|
8
|
+
require_relative 'hedra/plugin_manager'
|
|
9
|
+
require_relative 'hedra/exporter'
|
|
10
|
+
require_relative 'hedra/scorer'
|
|
11
|
+
|
|
12
|
+
module Hedra
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class NetworkError < Error; end
|
|
15
|
+
class ConfigError < Error; end
|
|
16
|
+
end
|