gsc-cli 2.0.2 → 2.2.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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/AUTH.md +205 -0
  3. data/FUNDING.md +120 -0
  4. data/README.md +463 -299
  5. data/bin/gsc +29158 -4921
  6. data/dist/gsc +29158 -4921
  7. data/lib/gsc/aio_hunter.rb +343 -0
  8. data/lib/gsc/answer_synthesizer.rb +157 -0
  9. data/lib/gsc/api.rb +53 -1
  10. data/lib/gsc/auth.rb +26 -0
  11. data/lib/gsc/backlinks_manager.rb +96 -0
  12. data/lib/gsc/brand_segmenter.rb +140 -0
  13. data/lib/gsc/cache_manager.rb +806 -0
  14. data/lib/gsc/cannibalization_analyzer.rb +141 -0
  15. data/lib/gsc/canonical_chains.rb +367 -0
  16. data/lib/gsc/citation_simulator.rb +339 -0
  17. data/lib/gsc/cli/aio_hunter.rb +154 -0
  18. data/lib/gsc/cli/analytics.rb +788 -0
  19. data/lib/gsc/cli/audit.rb +1976 -0
  20. data/lib/gsc/cli/base.rb +384 -0
  21. data/lib/gsc/cli/cache.rb +266 -0
  22. data/lib/gsc/cli/canonical.rb +223 -0
  23. data/lib/gsc/cli/citation_simulator.rb +152 -0
  24. data/lib/gsc/cli/dashboard.rb +354 -0
  25. data/lib/gsc/cli/doctor.rb +129 -0
  26. data/lib/gsc/cli/eeat.rb +125 -0
  27. data/lib/gsc/cli/ga4.rb +852 -0
  28. data/lib/gsc/cli/growth.rb +650 -0
  29. data/lib/gsc/cli/hreflang.rb +164 -0
  30. data/lib/gsc/cli/image_seo.rb +162 -0
  31. data/lib/gsc/cli/indexing.rb +458 -0
  32. data/lib/gsc/cli/intent_shift.rb +125 -0
  33. data/lib/gsc/cli/keyword_value.rb +134 -0
  34. data/lib/gsc/cli/keywords.rb +795 -0
  35. data/lib/gsc/cli/landing_roi.rb +308 -0
  36. data/lib/gsc/cli/low_ctr.rb +213 -0
  37. data/lib/gsc/cli/mobile_parity.rb +150 -0
  38. data/lib/gsc/cli/report.rb +100 -0
  39. data/lib/gsc/cli/rich_results.rb +172 -0
  40. data/lib/gsc/cli/schema_generate.rb +149 -0
  41. data/lib/gsc/cli/seasonal.rb +232 -0
  42. data/lib/gsc/cli/security.rb +153 -0
  43. data/lib/gsc/cli/setup.rb +1291 -0
  44. data/lib/gsc/cli/sitemap_tree.rb +143 -0
  45. data/lib/gsc/cli/skill_pack.rb +62 -0
  46. data/lib/gsc/cli/soft_404.rb +199 -0
  47. data/lib/gsc/cli/sparkline.rb +227 -0
  48. data/lib/gsc/cli/watchdog.rb +150 -0
  49. data/lib/gsc/cli/zombie_purger.rb +208 -0
  50. data/lib/gsc/cli.rb +706 -5111
  51. data/lib/gsc/cli_advanced.rb +1513 -0
  52. data/lib/gsc/client.rb +17 -2
  53. data/lib/gsc/color.rb +16 -1
  54. data/lib/gsc/command_registry.rb +47 -9
  55. data/lib/gsc/config.rb +11 -2
  56. data/lib/gsc/content_gap.rb +112 -0
  57. data/lib/gsc/ctr_curve.rb +115 -0
  58. data/lib/gsc/decay_predictor.rb +322 -0
  59. data/lib/gsc/doctor.rb +434 -0
  60. data/lib/gsc/eeat_auditor.rb +428 -0
  61. data/lib/gsc/entity_auditor.rb +229 -0
  62. data/lib/gsc/firewall_scanner.rb +733 -0
  63. data/lib/gsc/geo_auditor.rb +368 -0
  64. data/lib/gsc/google_suggest.rb +109 -0
  65. data/lib/gsc/google_trends.rb +8 -1
  66. data/lib/gsc/heading_validator.rb +283 -0
  67. data/lib/gsc/hreflang_validator.rb +412 -0
  68. data/lib/gsc/image_seo.rb +286 -0
  69. data/lib/gsc/indexing_queue.rb +179 -0
  70. data/lib/gsc/indexnow.rb +93 -0
  71. data/lib/gsc/intent_shift.rb +188 -0
  72. data/lib/gsc/internal_links.rb +249 -0
  73. data/lib/gsc/keyword_value.rb +191 -0
  74. data/lib/gsc/landing_roi.rb +195 -0
  75. data/lib/gsc/llms_generator.rb +425 -0
  76. data/lib/gsc/low_ctr_rewriter.rb +408 -0
  77. data/lib/gsc/mobile_parity.rb +222 -0
  78. data/lib/gsc/network_tracer.rb +93 -0
  79. data/lib/gsc/open_page_rank.rb +72 -0
  80. data/lib/gsc/page_analyzer.rb +47 -7
  81. data/lib/gsc/page_comparator.rb +108 -0
  82. data/lib/gsc/page_speed.rb +110 -0
  83. data/lib/gsc/prompts.rb +38 -29
  84. data/lib/gsc/questions_harvester.rb +178 -0
  85. data/lib/gsc/report_generator.rb +461 -0
  86. data/lib/gsc/rich_results.rb +388 -0
  87. data/lib/gsc/robots_checker.rb +114 -0
  88. data/lib/gsc/schema_generator.rb +788 -0
  89. data/lib/gsc/schema_validator.rb +120 -0
  90. data/lib/gsc/seasonal_predictor.rb +381 -0
  91. data/lib/gsc/security_scanner.rb +496 -0
  92. data/lib/gsc/serp_feature_detector.rb +359 -0
  93. data/lib/gsc/serp_preview.rb +152 -0
  94. data/lib/gsc/site_crawler.rb +113 -21
  95. data/lib/gsc/sitemap_loader.rb +15 -4
  96. data/lib/gsc/sitemap_tree.rb +301 -0
  97. data/lib/gsc/skill_pack.rb +195 -0
  98. data/lib/gsc/soft_404_analyzer.rb +385 -0
  99. data/lib/gsc/sparkline.rb +171 -0
  100. data/lib/gsc/speed_correlator.rb +416 -0
  101. data/lib/gsc/striking_playbook.rb +190 -0
  102. data/lib/gsc/title_optimizer.rb +420 -0
  103. data/lib/gsc/vault.rb +260 -0
  104. data/lib/gsc/version.rb +1 -1
  105. data/lib/gsc/watchdog.rb +235 -0
  106. data/lib/gsc/zombie_purger.rb +366 -0
  107. data/lib/gsc.rb +144 -0
  108. metadata +91 -2
@@ -0,0 +1,496 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'json'
7
+ require 'openssl'
8
+ require 'socket'
9
+ require 'time'
10
+ require 'date'
11
+
12
+ module GSC
13
+ class SecurityScanner
14
+ RECOMMENDED_HEADERS = {
15
+ 'strict-transport-security' => {
16
+ name: 'Strict-Transport-Security',
17
+ weight: 20,
18
+ desc: 'Enforces HTTPS connections and prevents SSL stripping attacks (HSTS).'
19
+ },
20
+ 'content-security-policy' => {
21
+ name: 'Content-Security-Policy',
22
+ weight: 20,
23
+ desc: 'Restricts script, stylesheet, and frame sources to prevent XSS.'
24
+ },
25
+ 'x-frame-options' => {
26
+ name: 'X-Frame-Options',
27
+ weight: 15,
28
+ desc: 'Prevents clickjacking by disabling embedding in external iframes.'
29
+ },
30
+ 'x-content-type-options' => {
31
+ name: 'X-Content-Type-Options',
32
+ weight: 10,
33
+ desc: 'Prevents MIME-sniffing vulnerabilities (must be "nosniff").'
34
+ },
35
+ 'referrer-policy' => {
36
+ name: 'Referrer-Policy',
37
+ weight: 10,
38
+ desc: 'Controls referrer data sent in outgoing links (e.g. strict-origin-when-cross-origin).'
39
+ },
40
+ 'permissions-policy' => {
41
+ name: 'Permissions-Policy',
42
+ weight: 10,
43
+ desc: 'Restricts browser hardware features (camera, mic, geolocation, sensors).'
44
+ }
45
+ }.freeze
46
+
47
+ LEAK_HEADERS = %w[server x-powered-by x-aspnet-version x-runtime].freeze
48
+
49
+ attr_reader :target, :options
50
+
51
+ def self.audit(url_or_domain, options = {})
52
+ new(url_or_domain, options).audit
53
+ end
54
+
55
+ def initialize(target, options = {})
56
+ raw = target.to_s.strip
57
+ raw = "https://#{raw}" unless raw =~ %r{^https?://}
58
+ @target = raw
59
+ @options = options
60
+ end
61
+
62
+ def audit
63
+ uri = URI.parse(@target) rescue nil
64
+ return error_result("Invalid URL target: #{@target}") unless uri && uri.host
65
+
66
+ # 1. Fetch live HTTP/HTTPS headers and HTML body
67
+ fetch_result = fetch_page(uri)
68
+ return error_result(fetch_result[:error]) if fetch_result[:error]
69
+
70
+ headers = fetch_result[:headers]
71
+ body = fetch_result[:body]
72
+ effective_url = fetch_result[:effective_url]
73
+
74
+ # 2. Inspect SSL/TLS certificate
75
+ ssl_info = inspect_ssl(uri)
76
+
77
+ # 3. Audit Security Headers
78
+ header_audit = audit_security_headers(headers)
79
+
80
+ # 4. Check for Info-Leakage Headers
81
+ leak_audit = audit_leak_headers(headers)
82
+
83
+ # 5. Scan for Active & Passive Mixed Content
84
+ mixed_content = scan_mixed_content(body, effective_url)
85
+
86
+ # 6. Check HSTS Preload Eligibility
87
+ hsts_preload = evaluate_hsts_preload(headers['strict-transport-security'])
88
+
89
+ # 7. Calculate Security Score & Grade
90
+ scoring = calculate_security_score(header_audit, leak_audit, mixed_content, ssl_info)
91
+
92
+ # 8. Generate 1-Click Server Directives
93
+ server_rules = generate_server_rules(header_audit[:missing_headers])
94
+
95
+ {
96
+ target: @target,
97
+ effective_url: effective_url,
98
+ timestamp: Time.now.utc.iso8601,
99
+ score: scoring[:score],
100
+ grade: scoring[:grade],
101
+ verdict: scoring[:verdict],
102
+ ssl: ssl_info,
103
+ headers: {
104
+ present: header_audit[:present],
105
+ missing: header_audit[:missing],
106
+ leakage: leak_audit
107
+ },
108
+ hsts_preload: hsts_preload,
109
+ mixed_content: mixed_content,
110
+ recommendations: scoring[:recommendations],
111
+ server_rules: server_rules
112
+ }
113
+ end
114
+
115
+ private
116
+
117
+ def fetch_page(uri, redirect_limit = 5, visited = Set.new)
118
+ return { error: 'Redirect loop or excessive redirects detected' } if redirect_limit <= 0 || visited.include?(uri.to_s)
119
+ visited.add(uri.to_s)
120
+
121
+ http = Net::HTTP.new(uri.host, uri.port)
122
+ http.use_ssl = (uri.scheme == 'https')
123
+ http.open_timeout = 5
124
+ http.read_timeout = 8
125
+
126
+ req = Net::HTTP::Get.new(uri.request_uri.empty? ? '/' : uri.request_uri)
127
+ req['User-Agent'] = "Mozilla/5.0 (compatible; GSC-SecurityScanner/#{GSC::VERSION}; +https://apolloswave.com)"
128
+
129
+ res = http.request(req)
130
+
131
+ # Handle redirects up to redirect_limit
132
+ if res.is_a?(Net::HTTPRedirection) && res['location']
133
+ redirect_uri = URI.join(uri.to_s, res['location']) rescue nil
134
+ if redirect_uri && redirect_uri.scheme =~ /^https?/
135
+ return fetch_page(redirect_uri, redirect_limit - 1, visited)
136
+ end
137
+ end
138
+
139
+ headers_hash = {}
140
+ res.each_header { |k, v| headers_hash[k.downcase] = v }
141
+
142
+ {
143
+ headers: headers_hash,
144
+ body: res.body.to_s.dup.force_encoding('UTF-8').scrub,
145
+ effective_url: uri.to_s
146
+ }
147
+ rescue StandardError => e
148
+ { error: "Failed to connect to #{uri}: #{e.message}" }
149
+ end
150
+
151
+ def inspect_ssl(uri)
152
+ return { status: :not_https, message: 'URL is served over plain HTTP' } unless uri.scheme == 'https'
153
+
154
+ port = uri.port || 443
155
+ tcp_sock = TCPSocket.new(uri.host, port)
156
+ ssl_ctx = OpenSSL::SSL::SSLContext.new
157
+ ssl_sock = OpenSSL::SSL::SSLSocket.new(tcp_sock, ssl_ctx)
158
+ ssl_sock.hostname = uri.host
159
+ ssl_sock.connect
160
+
161
+ cert = ssl_sock.peer_cert
162
+ ssl_sock.close
163
+ tcp_sock.close
164
+
165
+ return { status: :error, message: 'No certificate presented' } unless cert
166
+
167
+ valid_from = cert.not_before
168
+ valid_to = cert.not_after
169
+ days_remaining = [((valid_to - Time.now) / 86400).round, 0].max
170
+
171
+ status = if days_remaining <= 0
172
+ :expired
173
+ elsif days_remaining <= 14
174
+ :critical_expiry
175
+ elsif days_remaining <= 30
176
+ :expiring_soon
177
+ else
178
+ :valid
179
+ end
180
+
181
+ issuer = cert.issuer.to_a.map { |part| "#{part[0]}=#{part[1]}" }.join(', ')
182
+ subject = cert.subject.to_a.map { |part| "#{part[0]}=#{part[1]}" }.join(', ')
183
+
184
+ # Extract SAN domains
185
+ san_ext = cert.extensions.find { |e| e.oid == 'subjectAltName' }
186
+ sans = san_ext ? san_ext.value.split(',').map(&:strip) : []
187
+
188
+ {
189
+ status: status,
190
+ days_remaining: days_remaining,
191
+ valid_from: valid_from.iso8601,
192
+ valid_to: valid_to.iso8601,
193
+ issuer: issuer,
194
+ subject: subject,
195
+ san_count: sans.size,
196
+ sans: sans.first(5),
197
+ is_wildcard: sans.any? { |s| s.start_with?('DNS:*.') }
198
+ }
199
+ rescue StandardError => e
200
+ { status: :error, message: "SSL Handshake Error: #{e.message}" }
201
+ end
202
+
203
+ def audit_security_headers(headers)
204
+ present = []
205
+ missing = []
206
+ missing_headers = []
207
+
208
+ RECOMMENDED_HEADERS.each do |key, spec|
209
+ val = headers[key]
210
+ if val && !val.strip.empty?
211
+ quality, details = assess_header_quality(key, val)
212
+ present << {
213
+ header: spec[:name],
214
+ value: val,
215
+ quality: quality, # :optimal, :acceptable, :suboptimal
216
+ details: details
217
+ }
218
+ else
219
+ missing << {
220
+ header: spec[:name],
221
+ desc: spec[:desc],
222
+ weight: spec[:weight]
223
+ }
224
+ missing_headers << spec[:name]
225
+ end
226
+ end
227
+
228
+ {
229
+ present: present,
230
+ missing: missing,
231
+ missing_headers: missing_headers
232
+ }
233
+ end
234
+
235
+ def assess_header_quality(key, value)
236
+ val = value.to_s.strip.downcase
237
+ case key
238
+ when 'strict-transport-security'
239
+ if val.include?('includeSubDomains'.downcase) && val.include?('preload') && val =~ /max-age=(\d+)/ && $1.to_i >= 31536000
240
+ [:optimal, 'Preload ready (1yr+ with subdomains and preload token)']
241
+ elsif val =~ /max-age=(\d+)/ && $1.to_i >= 15552000
242
+ [:acceptable, 'Valid HSTS (6+ months), but missing preload token or subdomains']
243
+ else
244
+ [:suboptimal, 'Short max-age (<6 months) or weak parameters']
245
+ end
246
+ when 'content-security-policy'
247
+ if val.include?("'unsafe-inline'") || val.include?("'unsafe-eval'")
248
+ [:suboptimal, "Contains 'unsafe-inline' or 'unsafe-eval' relaxation"]
249
+ else
250
+ [:optimal, 'Restricted directives without unsafe relaxations']
251
+ end
252
+ when 'x-frame-options'
253
+ if %w[deny sameorigin].include?(val)
254
+ [:optimal, "Configured to #{val.upcase}"]
255
+ else
256
+ [:suboptimal, "Non-standard value: #{value}"]
257
+ end
258
+ when 'x-content-type-options'
259
+ if val == 'nosniff'
260
+ [:optimal, 'nosniff properly configured']
261
+ else
262
+ [:suboptimal, "Expected 'nosniff', got: #{value}"]
263
+ end
264
+ when 'referrer-policy'
265
+ if %w[strict-origin-when-cross-origin no-referrer strict-origin].include?(val)
266
+ [:optimal, "Safe modern policy (#{value})"]
267
+ else
268
+ [:acceptable, "Standard policy (#{value})"]
269
+ end
270
+ when 'permissions-policy'
271
+ [:optimal, 'Restricted feature permissions defined']
272
+ else
273
+ [:optimal, 'Present']
274
+ end
275
+ end
276
+
277
+ def audit_leak_headers(headers)
278
+ leaks = []
279
+ LEAK_HEADERS.each do |key|
280
+ val = headers[key]
281
+ if val && !val.strip.empty?
282
+ leaks << { header: key, value: val }
283
+ end
284
+ end
285
+ leaks
286
+ end
287
+
288
+ def scan_mixed_content(html, base_url)
289
+ return { active: [], passive: [], total: 0 } if html.to_s.empty?
290
+
291
+ active = []
292
+ passive = []
293
+
294
+ # Active mixed content (Hard blocked by browsers)
295
+ html.scan(/<(?:script|iframe|embed|object)\b[^>]*?(?:src|data)=["'](http:\/\/[^"']+)["']/i).flatten.each do |src|
296
+ active << { type: 'script_or_frame', url: src }
297
+ end
298
+ html.scan(/<link\b[^>]*?rel=["']stylesheet["'][^>]*?href=["'](http:\/\/[^"']+)["']/i).flatten.each do |href|
299
+ active << { type: 'stylesheet', url: href }
300
+ end
301
+ html.scan(/<link\b[^>]*?href=["'](http:\/\/[^"']+)["'][^>]*?rel=["']stylesheet["']/i).flatten.each do |href|
302
+ active << { type: 'stylesheet', url: href }
303
+ end
304
+
305
+ # Passive mixed content (Degrades lock icon / warnings)
306
+ html.scan(/<(?:img|audio|video|source)\b[^>]*?src=["'](http:\/\/[^"']+)["']/i).flatten.each do |src|
307
+ passive << { type: 'media_or_image', url: src }
308
+ end
309
+
310
+ active.uniq! { |i| i[:url] }
311
+ passive.uniq! { |i| i[:url] }
312
+
313
+ {
314
+ active: active,
315
+ passive: passive,
316
+ total: active.size + passive.size,
317
+ has_critical_active_blocks: active.any?
318
+ }
319
+ end
320
+
321
+ def evaluate_hsts_preload(hsts_header)
322
+ return { eligible: false, reasons: ['Strict-Transport-Security header is missing'] } unless hsts_header
323
+
324
+ val = hsts_header.to_s.downcase
325
+ reasons = []
326
+
327
+ # Check max-age >= 31536000
328
+ if val =~ /max-age=(\d+)/
329
+ age = $1.to_i
330
+ reasons << "max-age is #{age}s (must be at least 31536000s / 1 year)" if age < 31536000
331
+ else
332
+ reasons << 'Missing max-age directive'
333
+ end
334
+
335
+ reasons << 'Missing includeSubDomains directive' unless val.include?('includesubdomains')
336
+ reasons << 'Missing preload token' unless val.include?('preload')
337
+
338
+ {
339
+ eligible: reasons.empty?,
340
+ current_header: hsts_header,
341
+ reasons: reasons
342
+ }
343
+ end
344
+
345
+ def calculate_security_score(headers_audit, leak_audit, mixed_content, ssl_info)
346
+ score = 100
347
+ recommendations = []
348
+
349
+ # Headers deductions
350
+ headers_audit[:missing].each do |m|
351
+ score -= m[:weight]
352
+ recommendations << "Add '#{m[:header]}' response header: #{m[:desc]}"
353
+ end
354
+
355
+ # Suboptimal headers
356
+ headers_audit[:present].each do |p|
357
+ if p[:quality] == :suboptimal
358
+ score -= 5
359
+ recommendations << "Strengthen '#{p[:header]}': #{p[:details]}"
360
+ end
361
+ end
362
+
363
+ # Leaks
364
+ if leak_audit.any?
365
+ score -= 5
366
+ leaks_list = leak_audit.map { |l| l[:header] }.join(', ')
367
+ recommendations << "Hide server fingerprinting headers: #{leaks_list}"
368
+ end
369
+
370
+ # Mixed content
371
+ if mixed_content[:active].any?
372
+ score -= [mixed_content[:active].size * 15, 30].min
373
+ recommendations << "CRITICAL: Fix #{mixed_content[:active].size} active mixed content HTTP scripts/frames (browsers actively block these)"
374
+ end
375
+
376
+ if mixed_content[:passive].any?
377
+ score -= [mixed_content[:passive].size * 5, 15].min
378
+ recommendations << "Replace #{mixed_content[:passive].size} passive mixed content HTTP images/media with HTTPS URLs"
379
+ end
380
+
381
+ # SSL
382
+ if ssl_info[:status] == :expired
383
+ score -= 50
384
+ recommendations << 'CRITICAL: SSL certificate is EXPIRED!'
385
+ elsif ssl_info[:status] == :critical_expiry
386
+ score -= 25
387
+ recommendations << "URGENT: SSL certificate expires in #{ssl_info[:days_remaining]} days. Renew immediately!"
388
+ elsif ssl_info[:status] == :expiring_soon
389
+ score -= 10
390
+ recommendations << "Notice: SSL certificate expires in #{ssl_info[:days_remaining]} days."
391
+ end
392
+
393
+ score = [[score, 0].max, 100].min
394
+
395
+ grade = case score
396
+ when 95..100 then 'A+'
397
+ when 85..94 then 'A'
398
+ when 70..84 then 'B'
399
+ when 55..69 then 'C'
400
+ when 40..54 then 'D'
401
+ else 'F'
402
+ end
403
+
404
+ verdict = case grade
405
+ when 'A+', 'A'
406
+ 'EXCELLENT: Enterprise-grade security headers & clean HTTPS encryption'
407
+ when 'B'
408
+ 'GOOD: Minor security header omissions or mild policy optimizations needed'
409
+ when 'C'
410
+ 'MODERATE: Multiple standard security headers missing; potential clickjacking or MIME risks'
411
+ when 'D'
412
+ 'POOR: Weak security posture; mixed content or critical headers missing'
413
+ else
414
+ 'CRITICAL: Severe vulnerabilities (Active mixed content, expired cert, or missing HSTS)'
415
+ end
416
+
417
+ {
418
+ score: score,
419
+ grade: grade,
420
+ verdict: verdict,
421
+ recommendations: recommendations
422
+ }
423
+ end
424
+
425
+ def generate_server_rules(missing_headers = [])
426
+ nginx_lines = []
427
+ apache_lines = []
428
+ vercel_lines = []
429
+
430
+ nginx_lines << '# Recommended Security Headers (Nginx)'
431
+ apache_lines << '<IfModule mod_headers.c>'
432
+ vercel_lines << '/*'
433
+
434
+ if missing_headers.include?('Strict-Transport-Security')
435
+ nginx_lines << 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;'
436
+ apache_lines << ' Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"'
437
+ vercel_lines << ' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload'
438
+ end
439
+
440
+ if missing_headers.include?('X-Frame-Options')
441
+ nginx_lines << 'add_header X-Frame-Options "SAMEORIGIN" always;'
442
+ apache_lines << ' Header always set X-Frame-Options "SAMEORIGIN"'
443
+ vercel_lines << ' X-Frame-Options: SAMEORIGIN'
444
+ end
445
+
446
+ if missing_headers.include?('X-Content-Type-Options')
447
+ nginx_lines << 'add_header X-Content-Type-Options "nosniff" always;'
448
+ apache_lines << ' Header always set X-Content-Type-Options "nosniff"'
449
+ vercel_lines << ' X-Content-Type-Options: nosniff'
450
+ end
451
+
452
+ if missing_headers.include?('Referrer-Policy')
453
+ nginx_lines << 'add_header Referrer-Policy "strict-origin-when-cross-origin" always;'
454
+ apache_lines << ' Header always set Referrer-Policy "strict-origin-when-cross-origin"'
455
+ vercel_lines << ' Referrer-Policy: strict-origin-when-cross-origin'
456
+ end
457
+
458
+ if missing_headers.include?('Permissions-Policy')
459
+ nginx_lines << 'add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;'
460
+ apache_lines << ' Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"'
461
+ vercel_lines << ' Permissions-Policy: camera=(), microphone=(), geolocation=()'
462
+ end
463
+
464
+ if missing_headers.include?('Content-Security-Policy')
465
+ nginx_lines << 'add_header Content-Security-Policy "default-src \'self\'; script-src \'self\' https:; style-src \'self\' \'unsafe-inline\' https:; img-src \'self\' data: https:;" always;'
466
+ apache_lines << ' Header always set Content-Security-Policy "default-src \'self\'; script-src \'self\' https:; style-src \'self\' \'unsafe-inline\' https:; img-src \'self\' data: https:;"'
467
+ vercel_lines << ' Content-Security-Policy: default-src \'self\'; script-src \'self\' https:; style-src \'self\' \'unsafe-inline\' https:; img-src \'self\' data: https:;'
468
+ end
469
+
470
+ nginx_lines << 'server_tokens off;'
471
+ apache_lines << '</IfModule>'
472
+
473
+ {
474
+ nginx: nginx_lines.join("\n"),
475
+ apache: apache_lines.join("\n"),
476
+ vercel_netlify: vercel_lines.join("\n")
477
+ }
478
+ end
479
+
480
+ def error_result(msg)
481
+ {
482
+ target: @target,
483
+ error: msg,
484
+ score: 0,
485
+ grade: 'F',
486
+ verdict: "Error: #{msg}",
487
+ ssl: { status: :error, message: msg },
488
+ headers: { present: [], missing: [], leakage: [] },
489
+ hsts_preload: { eligible: false, reasons: [msg] },
490
+ mixed_content: { active: [], passive: [], total: 0 },
491
+ recommendations: [msg],
492
+ server_rules: { nginx: '', apache: '', vercel_netlify: '' }
493
+ }
494
+ end
495
+ end
496
+ end