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.
data/lib/hedra/cli.rb CHANGED
@@ -42,7 +42,111 @@ module Hedra
42
42
  end
43
43
  end
44
44
 
45
- class CLI < Thor
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: false, desc: 'Follow redirects'
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
- def scan(target)
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
- 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}")
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 csv.", :red
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] || false,
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
@@ -11,7 +11,7 @@ module Hedra
11
11
  DEFAULT_CONFIG = {
12
12
  'timeout' => 10,
13
13
  'concurrency' => 10,
14
- 'follow_redirects' => false,
14
+ 'follow_redirects' => true,
15
15
  'user_agent' => "Hedra/#{VERSION}",
16
16
  'proxy' => nil,
17
17
  'output_format' => 'table'
@@ -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
@@ -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(timeout: 10, proxy: nil, user_agent: nil, follow_redirects: false, verbose: false)
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
- raise NetworkError, "Failed after #{MAX_RETRIES} retries: #{e.message}" unless retries <= MAX_RETRIES
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
- delay = RETRY_DELAY * (2**(retries - 1))
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