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.
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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hedra
4
+ VERSION = '1.0.0'
5
+ 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