gsc-cli 2.1.0 → 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.
- checksums.yaml +4 -4
- data/README.md +410 -414
- data/bin/gsc +28067 -5661
- data/dist/gsc +29121 -5046
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +707 -5265
- data/lib/gsc/cli_advanced.rb +987 -44
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +2 -2
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +153 -36
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +343 -22
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +8 -1
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +46 -15
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +36 -38
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +108 -22
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +118 -0
- metadata +75 -1
|
@@ -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
|