bluewall 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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +109 -0
  3. data/bin/bluewall +21 -0
  4. data/lib/bluewall.rb +1400 -0
  5. metadata +78 -0
data/lib/bluewall.rb ADDED
@@ -0,0 +1,1400 @@
1
+ # frozen_string_literal: true
2
+ # BlueWall Firewall Auditor (pfSense/OpenSense - Full XML Support)
3
+ # Created by :cillia
4
+ require 'yaml'
5
+ require 'nokogiri'
6
+ require 'set'
7
+ require 'json'
8
+
9
+ class BlueWall
10
+ Rule = Struct.new(:id, :action, :interface, :direction, :protocol, :source, :destination, :dport, :comment, :quick, :schedule, :gateway, :state_type, :nat_rule) do
11
+ def to_s
12
+ parts = ["[#{id}] #{action} #{direction} on #{interface}"]
13
+ parts << "proto=#{protocol}" if protocol && protocol != 'any'
14
+ parts << "src=#{source}" if source && source != 'any'
15
+ parts << "dst=#{destination}" if destination && destination != 'any'
16
+ parts << "dport=#{dport}" if dport
17
+ parts << "(Comment: #{comment})" if comment
18
+ parts << "(Quick)" if quick
19
+ parts << "(Schedule: #{schedule})" if schedule
20
+ parts << "(Gateway: #{gateway})" if gateway
21
+ parts << "(State: #{state_type})"
22
+ parts << "(NAT Rule)" if nat_rule
23
+ parts.join(" ")
24
+ end
25
+ end
26
+
27
+ AuditResult = Struct.new(:firewall_type, :rules, :strengths, :weaknesses, :score, :details, :simulated_attacks, :framework_assessments) do
28
+ def to_s
29
+ blue_wall_logo = <<~LOGO.chomp
30
+ ██████╗ ██╗ ██╗ ██╗███████╗██╗ ██╗ █████╗ ██╗ ██╗ ███╗███╗
31
+ ██╔══██╗██║ ██║ ██║██╔════╝██║ ██║██╔══██╗██║ ██║ ██╔╝╚██║
32
+ ██████╔╝██║ ██║ ██║█████╗ ██║ █╗ ██║███████║██║ ██║ ██║ ██║
33
+ ██╔══██╗██║ ██║ ██║██╔══╝ ██║███╗██║██╔══██║██║ ██║ ██║ ██║
34
+ ██████╔╝███████╗╚██████╔╝███████╗╚███╔███╔╝██║ ██║███████╗███████╗ ███╗███║
35
+ ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚══╝╚══╝
36
+ created by :cillia
37
+ LOGO
38
+
39
+ <<~AUDIT_REPORT
40
+ #{blue_wall_logo}
41
+ --- BlueWall Audit Report ---
42
+ Firewall Type: #{firewall_type}
43
+ ---------------------------
44
+ Strengths:
45
+ #{strengths.empty? ? ' None identified.' : strengths.map { |s| " - #{s}" }.join("\n")}
46
+ Weaknesses:
47
+ #{weaknesses.empty? ? ' None identified.' : weaknesses.map { |w| " - #{w}" }.join("\n")}
48
+ Overall Security Score (1-10): #{score.round(3)}
49
+ Details: #{details}
50
+ ---------------------------
51
+ Simulated Attack Scenarios:
52
+ #{simulated_attacks.empty? ? ' No scenarios tested or results found.' : simulated_attacks.map { |s| " - #{s}" }.join("\n")}
53
+ AUDIT_REPORT
54
+ end
55
+ end
56
+
57
+ def initialize
58
+ @supported_firewall_types = {
59
+ 'PFSENSE_LIKE' => {
60
+ indicators: ['/pfsense/interfaces', '/pfsense/filter/rule'],
61
+ description: 'A pfSense-like XML structure or configuration.'
62
+ },
63
+ 'OPENSENSE_LIKE' => {
64
+ indicators: ['/opnsense/interfaces', '/opnsense/filter/rule'],
65
+ description: 'An OpenSense-like XML configuration.'
66
+ }
67
+ }
68
+
69
+ @weights = {
70
+ strength_explicit_wan_deny: 2.5,
71
+ strength_restricted_mgmt_access: 4.0,
72
+ strength_restricted_ssh_access: 4.0,
73
+ strength_no_insecure_services_allowed: 2.5,
74
+ strength_granular_lan_outbound: 1.5,
75
+ strength_specific_wan_inbound_rule: 0.3,
76
+ strength_simulated_attack_explicitly_blocked: 1.5,
77
+ strength_simulated_exfiltration_blocked: 1.5,
78
+ strength_simulated_legitimate_allowed: 0.8,
79
+ strength_all_external_attacks_prevented_overall: 2.0,
80
+
81
+ weakness_no_explicit_wan_deny: -1.5,
82
+ weakness_wan_mgmt_from_any: -15.0,
83
+ weakness_wan_ssh_from_any: -15.0,
84
+ weakness_overly_permissive_wan: -20.0,
85
+ weakness_insecure_service_allowed: -4.0,
86
+ weakness_broad_lan_outbound: -1.0,
87
+ weakness_simulated_attack_allowed: -12.0,
88
+ weakness_simulated_attack_implicitly_blocked: -1.0,
89
+ weakness_simulated_exfiltration_allowed: -10.0,
90
+ weakness_simulated_legitimate_blocked: -3.0,
91
+ weakness_no_rules_found: -25.0,
92
+ weakness_stateless_rule: -1.8,
93
+ weakness_nat_insecure_service: -6.0
94
+ }
95
+
96
+ @max_raw_score_contribution = @weights.values.select { |v| v > 0 }.sum
97
+ @min_raw_score_contribution = @weights.values.select { |v| v < 0 }.sum
98
+ @score_range_buffer = 0.5
99
+ end
100
+
101
+ def detect_firewall_type_from_xml(xml_doc)
102
+ @supported_firewall_types.each do |type, info|
103
+ if info[:indicators].all? { |xpath| xml_doc.at_xpath(xpath) }
104
+ return type
105
+ end
106
+ end
107
+ 'UNKNOWN'
108
+ end
109
+
110
+ def extract_interfaces_from_xml(xml_doc)
111
+ interfaces = {}
112
+ xml_doc.xpath('//interfaces/*/ipaddr').each do |ip_node|
113
+ interface_name = ip_node.parent.name
114
+ interfaces[interface_name.to_sym] = {
115
+ ip: ip_node.content || '',
116
+ net: ip_node.parent.at_xpath('subnet')&.content || ''
117
+ }
118
+ end
119
+ interfaces
120
+ end
121
+
122
+ def extract_system_ip_from_xml(xml_doc)
123
+ (xml_doc.at_xpath('//system/wan/ipaddr')&.content ||
124
+ xml_doc.at_xpath('//system/general/hostname')&.content || '')
125
+ end
126
+
127
+ def extract_aliases_from_xml(xml_doc)
128
+ aliases = {}
129
+ xml_doc.xpath('//aliases/alias').each do |a|
130
+ name = a.at_xpath('name')&.content
131
+ type = a.at_xpath('type')&.content
132
+ address = a.at_xpath('address')&.content || ''
133
+ descr = a.at_xpath('descr')&.content || ''
134
+ next unless name
135
+ aliases[name] = { type: type, address: address.split(/\s+/), description: descr }
136
+ end
137
+ aliases
138
+ end
139
+
140
+ def extract_schedules_from_xml(xml_doc)
141
+ schedules = {}
142
+ xml_doc.xpath('//schedules/schedule').each do |s|
143
+ name = s.at_xpath('name')&.content
144
+ descr = s.at_xpath('descr')&.content || ''
145
+ times = s.at_xpath('times')&.content || ''
146
+ weekdays = s.at_xpath('weekdays')&.content || ''
147
+ months = s.at_xpath('months')&.content || ''
148
+ next unless name
149
+ schedules[name] = { descr: descr, times: times, weekdays: weekdays, months: months }
150
+ end
151
+ schedules
152
+ end
153
+
154
+ def extract_nat_rules_from_xml(xml_doc, aliases)
155
+ nat_rules = []
156
+ xml_doc.xpath('//nat/rule').each_with_index do |rule_node, index|
157
+ rule_id = rule_node.at_xpath('id')&.content || "nat_rule_#{index + 1}"
158
+ action = 'ALLOW'
159
+ interface = rule_node.at_xpath('interface')&.content || 'any'
160
+ direction = 'in'
161
+ protocol = rule_node.at_xpath('protocol')&.content || 'any'
162
+
163
+ source_node = rule_node.at_xpath('source')
164
+ source = resolve_alias(source_node&.at_xpath('address')&.content, aliases) || 'any'
165
+
166
+ destination_node = rule_node.at_xpath('destination')
167
+ destination = resolve_alias(destination_node&.at_xpath('address')&.content, aliases) || 'any'
168
+
169
+ dport = rule_node.at_xpath('destination/port')&.content&.to_i
170
+ dport = resolve_alias(dport.to_s, aliases).to_i if dport && aliases.key?(dport.to_s)
171
+ comment = rule_node.at_xpath('descr')&.content
172
+ quick = true
173
+ schedule = nil
174
+ gateway = rule_node.at_xpath('gateway')&.content
175
+ state_type = 'keep state'
176
+
177
+ nat_rules << Rule.new(rule_id, action, interface, direction, protocol, source, destination, dport, comment, quick, schedule, gateway, state_type, true)
178
+ end
179
+ nat_rules
180
+ end
181
+
182
+ def resolve_alias(value, aliases)
183
+ return value unless value && aliases.key?(value)
184
+ aliases[value][:address].first
185
+ end
186
+
187
+ def parse_config_from_xml(xml_doc, aliases)
188
+ rules = []
189
+ xml_doc.xpath('//filter/rule').each_with_index do |rule_node, index|
190
+ rule_id = rule_node.at_xpath('id')&.content || rule_node.at_xpath('descr')&.content || "xml_rule_#{index + 1}"
191
+ action = rule_node.at_xpath('type')&.content == 'block' ? 'DENY' : 'ALLOW'
192
+ interface = rule_node.at_xpath('interface')&.content || 'any'
193
+ direction = rule_node.at_xpath('direction')&.content || 'in'
194
+ protocol = rule_node.at_xpath('protocol')&.content || 'any'
195
+
196
+ source_node = rule_node.at_xpath('source/network') || rule_node.at_xpath('source/address')
197
+ source = resolve_alias(source_node&.content, aliases) || 'any'
198
+ source = 'any' if rule_node.at_xpath('source/any')
199
+
200
+ destination_node = rule_node.at_xpath('destination/network') || rule_node.at_xpath('destination/address')
201
+ destination = resolve_alias(destination_node&.content, aliases) || 'any'
202
+ destination = 'any' if rule_node.at_xpath('destination/any')
203
+
204
+ dport = rule_node.at_xpath('destination/port')&.content&.to_i
205
+ dport = resolve_alias(dport.to_s, aliases).to_i if dport && aliases.key?(dport.to_s)
206
+ comment = rule_node.at_xpath('descr')&.content
207
+ quick = rule_node.at_xpath('quick')&.content == 'on'
208
+ schedule = rule_node.at_xpath('sched')&.content
209
+ gateway = rule_node.at_xpath('gateway')&.content
210
+ state_type = rule_node.at_xpath('statetype')&.content || 'keep state'
211
+
212
+ rules << Rule.new(rule_id, action, interface, direction, protocol, source, destination, dport, comment, quick, schedule, gateway, state_type, false)
213
+ end
214
+ rules
215
+ end
216
+
217
+ def match_ip_or_network(packet_ip_str, rule_ip_or_network_str, interface_ips)
218
+ packet_ip_str = packet_ip_str.to_s
219
+ rule_ip_or_network_str = rule_ip_or_network_str.to_s
220
+ return true if rule_ip_or_network_str == 'any'
221
+ return true if packet_ip_str == rule_ip_or_network_str
222
+
223
+ if rule_ip_or_network_str == 'self'
224
+ return true if packet_ip_str == interface_ips[:wan_ip] || packet_ip_str == interface_ips[:lan_ip]
225
+ end
226
+
227
+ if rule_ip_or_network_str.include?('/')
228
+ rule_base_ip, rule_cidr_mask = rule_ip_or_network_str.split('/')
229
+ if rule_cidr_mask == '24' && !rule_base_ip.empty?
230
+ packet_octets = packet_ip_str.split('.')
231
+ rule_octets = rule_base_ip.split('.')
232
+ return packet_octets.size >= 3 && rule_octets.size >= 3 && packet_octets[0..2].join('.') == rule_octets[0..2].join('.')
233
+ end
234
+ end
235
+
236
+ false
237
+ end
238
+
239
+ def simulate_connection_attempt(rules, packet, system_meta)
240
+ interface_ips = {
241
+ wan_ip: system_meta[:interfaces][:wan][:ip],
242
+ lan_ip: system_meta[:interfaces][:lan][:ip]
243
+ }
244
+
245
+ rules.each do |rule|
246
+ next unless rule.interface == 'any' || rule.interface == packet[:interface]
247
+ next unless rule.direction == 'any' || rule.direction == packet[:direction]
248
+ next unless rule.protocol == 'any' || rule.protocol == packet[:protocol]
249
+ next unless match_ip_or_network(packet[:src_ip], rule.source, interface_ips)
250
+ next unless match_ip_or_network(packet[:dst_ip], rule.destination, interface_ips)
251
+
252
+ if rule.dport && packet[:dst_port]
253
+ next unless rule.dport == packet[:dst_port]
254
+ elsif rule.dport && !packet[:dst_port]
255
+ next
256
+ end
257
+
258
+ return rule.action if rule.quick
259
+ return rule.action
260
+ end
261
+
262
+ 'IMPLICITLY_BLOCKED'
263
+ end
264
+
265
+ def calculate_entropy(strings)
266
+ text = strings.join('').downcase
267
+ freq = Hash.new(0)
268
+ text.each_char { |c| freq[c] += 1 }
269
+ total = text.length.to_f
270
+ return 0 if total == 0
271
+ -freq.values.map { |count| (count / total) * Math.log2(count / total) }.sum
272
+ end
273
+
274
+ def calculate_defense_depth(rules)
275
+ interface_count = rules.map(&:interface).uniq.size
276
+ action_diversity = rules.map(&:action).uniq.size
277
+ protocol_diversity = rules.map(&:protocol).uniq.size
278
+ port_specificity = rules.count { |r| r.dport && r.dport > 0 } / rules.size.to_f
279
+
280
+ score = (interface_count / 5.0) * 1.0 +
281
+ (action_diversity / 2.0) * 1.0 +
282
+ (protocol_diversity / 5.0) * 1.0 +
283
+ port_specificity * 2.0
284
+
285
+ [[score, 5.0].min, 0.0].max
286
+ end
287
+
288
+ def audit_rules(rules, firewall_type, system_meta, aliases, nat_rules)
289
+ strengths = []
290
+ weaknesses = []
291
+ raw_score = 0.0
292
+ simulated_attacks = []
293
+
294
+ system_ip = system_meta[:system_ip].to_s.downcase
295
+ lan_net = system_meta[:interfaces][:lan][:net].to_s.downcase rescue nil
296
+ wan_ip = system_meta[:interfaces][:wan][:ip].to_s.downcase rescue nil
297
+
298
+ # --- Explicit WAN Deny ---
299
+ wan_explicit_block_all = rules.any? do |r|
300
+ r.interface == 'wan' && r.direction == 'in' && r.action == 'DENY' &&
301
+ r.protocol == 'any' && r.source == 'any' && r.destination == 'any'
302
+ end
303
+
304
+ if wan_explicit_block_all
305
+ strengths << "Explicit 'DENY all' inbound rule on WAN detected, enhancing clarity and reinforcing default deny."
306
+ raw_score += @weights[:strength_explicit_wan_deny]
307
+ else
308
+ weaknesses << "No explicit 'DENY all' inbound rule on WAN. Relying on implicit deny can lead to oversight."
309
+ raw_score += @weights[:weakness_no_explicit_wan_deny]
310
+ end
311
+
312
+ broad_lan_outbound_weakness_added = false
313
+ critical_weakness_types_found = Set.new
314
+
315
+ rules.each do |rule|
316
+ if rule.action == 'ALLOW' && rule.direction == 'in' &&
317
+ (rule.destination == 'self' || rule.destination == system_ip || rule.destination == wan_ip) &&
318
+ [80, 443].include?(rule.dport)
319
+
320
+ if rule.interface == 'wan' && rule.source == 'any'
321
+ weaknesses << "Rule [ID:#{rule.id}] allows firewall management (HTTP/HTTPS) from 'any' source on WAN. **Critical risk!**"
322
+ raw_score += @weights[:weakness_wan_mgmt_from_any]
323
+ critical_weakness_types_found << :wan_mgmt_from_any
324
+ elsif rule.interface == 'wan' && rule.source != 'any'
325
+ strengths << "Rule [ID:#{rule.id}] restricts firewall management access to specific trusted sources on WAN."
326
+ raw_score += @weights[:strength_restricted_mgmt_access]
327
+ end
328
+ end
329
+
330
+ if rule.action == 'ALLOW' && rule.direction == 'in' &&
331
+ (rule.destination == 'self' || rule.destination == system_ip || rule.destination == wan_ip) &&
332
+ rule.dport == 22
333
+
334
+ if rule.interface == 'wan' && rule.source == 'any'
335
+ weaknesses << "Rule [ID:#{rule.id}] allows SSH access to firewall from 'any' source on WAN. **Critical risk!**"
336
+ raw_score += @weights[:weakness_wan_ssh_from_any]
337
+ critical_weakness_types_found << :wan_ssh_from_any
338
+ elsif rule.interface == 'wan' && rule.source != 'any'
339
+ strengths << "Rule [ID:#{rule.id}] restricts SSH access to firewall to specific trusted sources on WAN."
340
+ raw_score += @weights[:strength_restricted_ssh_access]
341
+ end
342
+ end
343
+
344
+ if rule.action == 'ALLOW' && rule.interface == 'wan' && rule.direction == 'in' &&
345
+ (rule.source == 'any' || rule.source.nil?) && (rule.destination == 'any' || rule.destination.nil?) &&
346
+ rule.protocol == 'any'
347
+ weaknesses << "Rule [ID:#{rule.id}] is an overly permissive 'ALLOW' rule from WAN to 'any' destination. **Major vulnerability!**"
348
+ raw_score += @weights[:weakness_overly_permissive_wan]
349
+ critical_weakness_types_found << :overly_permissive_wan
350
+ end
351
+
352
+ if rule.action == 'ALLOW' && rule.direction == 'in' && [21, 23, 445, 139].include?(rule.dport)
353
+ weaknesses << "Rule [ID:#{rule.id}] on interface '#{rule.interface}' allows insecure service (port #{rule.dport}). Consider disabling or securing alternatives."
354
+ raw_score += @weights[:weakness_insecure_service_allowed]
355
+ critical_weakness_types_found << :insecure_service_allowed
356
+ end
357
+
358
+ if rule.action == 'ALLOW' && rule.interface == 'lan' && rule.direction == 'out' &&
359
+ (rule.source == lan_net || rule.source == 'any') && rule.destination == 'any' && rule.protocol == 'any'
360
+ unless broad_lan_outbound_weakness_added
361
+ weaknesses << "A broad 'ALLOW all' outbound rule from LAN exists. Review to ensure no unnecessary egress traffic."
362
+ raw_score += @weights[:weakness_broad_lan_outbound]
363
+ broad_lan_outbound_weakness_added = true
364
+ end
365
+ end
366
+
367
+ if rule.action == 'ALLOW' && rule.interface == 'wan' && rule.direction == 'in' && rule.dport &&
368
+ ![80, 443, 22, 21, 23, 445, 139].include?(rule.dport)
369
+ if rule.source != 'any' && rule.destination != 'any' && rule.protocol != 'any'
370
+ strengths << "Rule [ID:#{rule.id}] provides granular access for a specific service (Port #{rule.dport})."
371
+ raw_score += @weights[:strength_specific_wan_inbound_rule]
372
+ end
373
+ end
374
+ end
375
+
376
+ unless broad_lan_outbound_weakness_added
377
+ strengths << "LAN outbound rules appear granular, promoting better control over egress."
378
+ raw_score += @weights[:strength_granular_lan_outbound]
379
+ end
380
+
381
+ if rules.empty? && firewall_type != 'UNKNOWN'
382
+ weaknesses << "No firewall rules found in the configuration. This implies an 'allow all' or unknown state."
383
+ raw_score += @weights[:weakness_no_rules_found]
384
+ critical_weakness_types_found << :no_rules_found
385
+ end
386
+
387
+ # --- Simulated Attack Scenarios ---
388
+ require 'set'
389
+ simulated_scenarios = [
390
+ { name: "WAN to LAN SSH (Port 22)",
391
+ packet: { src_ip: '203.0.113.1', dst_ip: '10.0.0.100', dst_port: 22, protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: false },
392
+ { name: "WAN to LAN RDP (Port 3389)",
393
+ packet: { src_ip: '203.0.113.1', dst_ip: '10.0.0.100', dst_port: 3389, protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: false },
394
+ { name: "WAN to Firewall HTTP Management (Port 80)",
395
+ packet: { src_ip: '203.0.113.1', dst_ip: system_ip, dst_port: 80, protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: false },
396
+ { name: "WAN to Firewall HTTPS Management (Port 443)",
397
+ packet: { src_ip: '203.0.113.1', dst_ip: system_ip, dst_port: 443, protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: false },
398
+ { name: "WAN to Internal FTP Server (Port 21)",
399
+ packet: { src_ip: '203.0.113.1', dst_ip: '10.0.0.200', dst_port: 21, protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: false },
400
+ { name: "WAN to Internal SMB Share (Port 445)",
401
+ packet: { src_ip: '203.0.113.1', dst_ip: '10.0.0.200', dst_port: 445, protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: false },
402
+ { name: "LAN to External Web (Port 80 - Expected Allowed)",
403
+ packet: { src_ip: '10.0.0.50', dst_ip: '8.8.8.8', dst_port: 80, protocol: 'tcp', interface: 'lan', direction: 'out' }, type: :legitimate, randomize_port: false },
404
+ { name: "WAN to Firewall SSH Brute-force (Random Port)",
405
+ packet: { src_ip: '185.10.10.10', dst_ip: system_ip, protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: true, base_port: 22 },
406
+ { name: "LAN to External Exfiltration (Random High Port)",
407
+ packet: { src_ip: '10.0.0.100', dst_ip: '1.2.3.4', protocol: 'tcp', interface: 'lan', direction: 'out' }, type: :exfiltration, randomize_port: true, port_range: (49152..65535) },
408
+ { name: "DMZ to LAN Database Access (Random Port)",
409
+ packet: { src_ip: '172.16.0.50', dst_ip: '10.0.0.150', protocol: 'tcp', interface: 'dmz', direction: 'in' }, type: :attack, randomize_port: true, base_port: 1433 },
410
+ { name: "WAN to Internal Reverse Shell (Random High Port)",
411
+ packet: { src_ip: '203.0.113.1', dst_ip: '10.0.0.100', protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: true, port_range: (49152..65535) },
412
+ { name: "WAN to Internal Netcat Listener (Random High Port)",
413
+ packet: { src_ip: '203.0.113.1', dst_ip: '10.0.0.100', protocol: 'tcp', interface: 'wan', direction: 'in' }, type: :attack, randomize_port: true, port_range: (49152..65535) },
414
+ ]
415
+
416
+ wan_dmz_attacks_prevented_count = 0
417
+ wan_dmz_total_attacks_in_scenarios = 0
418
+
419
+ simulated_scenarios.each do |scenario|
420
+ num_loops = scenario[:randomize_port] ? 5 : 1
421
+ scenario_outcomes = []
422
+ scenario_allowed_any_time = false
423
+
424
+ num_loops.times do
425
+ current_packet = scenario[:packet].dup
426
+ if scenario[:randomize_port]
427
+ if scenario[:base_port]
428
+ current_packet[:dst_port] = [1, [scenario[:base_port] - 100 + rand(201), 65535].min].max
429
+ elsif scenario[:port_range]
430
+ current_packet[:dst_port] = rand(scenario[:port_range])
431
+ else
432
+ current_packet[:dst_port] = rand(1024..65535)
433
+ end
434
+ scenario_name_with_port = "#{scenario[:name]} (Port #{current_packet[:dst_port]})"
435
+ else
436
+ scenario_name_with_port = scenario[:name]
437
+ end
438
+
439
+ outcome = simulate_connection_attempt(rules + nat_rules, current_packet, system_meta)
440
+ scenario_outcomes << "#{scenario_name_with_port}: #{outcome}"
441
+ scenario_allowed_any_time = true if outcome == 'ALLOW'
442
+ end
443
+
444
+ simulated_attacks.concat(scenario_outcomes)
445
+
446
+ is_external_attack_scenario = (scenario[:name].include?("WAN to") || scenario[:name].include?("DMZ to")) && scenario[:type] == :attack
447
+ is_exfiltration_scenario = scenario[:type] == :exfiltration
448
+ is_legitimate_scenario = scenario[:type] == :legitimate
449
+
450
+ if is_external_attack_scenario
451
+ wan_dmz_total_attacks_in_scenarios += 1
452
+ end
453
+
454
+ if scenario_allowed_any_time
455
+ if is_legitimate_scenario
456
+ strengths << "Simulated legitimate traffic: '#{scenario[:name]}' was ALLOWED (as expected) in at least one test."
457
+ raw_score += @weights[:strength_simulated_legitimate_allowed]
458
+ elsif is_external_attack_scenario
459
+ weaknesses << "Simulated attack: '#{scenario[:name]}' was ALLOWED in at least one randomized test. **Major exposure!**"
460
+ raw_score += @weights[:weakness_simulated_attack_allowed]
461
+ critical_weakness_types_found << :simulated_attack_allowed
462
+ elsif is_exfiltration_scenario
463
+ weaknesses << "Simulated exfiltration: '#{scenario[:name]}' was ALLOWED in at least one randomized test. Review outbound rules for data leakage prevention."
464
+ raw_score += @weights[:weakness_simulated_exfiltration_allowed]
465
+ critical_weakness_types_found << :simulated_exfiltration_allowed
466
+ end
467
+ else
468
+ if is_legitimate_scenario
469
+ weaknesses << "Simulated legitimate traffic: '#{scenario[:name]}' was BLOCKED/IMPLICITLY_BLOCKED unexpectedly in all tests. This might indicate a functional issue."
470
+ raw_score += @weights[:weakness_simulated_legitimate_blocked]
471
+ elsif is_external_attack_scenario
472
+ if scenario_outcomes.any? { |o| o.include?('DENY') }
473
+ strengths << "Simulated attack: '#{scenario[:name]}' was EXPLICITLY_BLOCKED in tests (strong security)."
474
+ raw_score += @weights[:strength_simulated_attack_explicitly_blocked]
475
+ else
476
+ weaknesses << "Simulated attack: '#{scenario[:name]}' was IMPLICITLY_BLOCKED in all tests. Consider explicit block rules for clarity and robustness."
477
+ raw_score += @weights[:weakness_simulated_attack_implicitly_blocked]
478
+ end
479
+ wan_dmz_attacks_prevented_count += 1
480
+ elsif is_exfiltration_scenario
481
+ if scenario_outcomes.any? { |o| o.include?('DENY') }
482
+ strengths << "Simulated exfiltration: '#{scenario[:name]}' was BLOCKED in tests. Good for data leakage prevention."
483
+ raw_score += @weights[:strength_simulated_exfiltration_blocked]
484
+ else
485
+ weaknesses << "Simulated exfiltration: '#{scenario[:name]}' was IMPLICITLY_BLOCKED. Consider explicit block rules for data leakage prevention."
486
+ raw_score += @weights[:weakness_simulated_exfiltration_allowed]
487
+ end
488
+ end
489
+ end
490
+ end
491
+
492
+ if wan_dmz_total_attacks_in_scenarios > 0 && wan_dmz_attacks_prevented_count == wan_dmz_total_attacks_in_scenarios
493
+ strengths << "All simulated external attack attempts were successfully prevented (either explicitly or implicitly blocked)."
494
+ raw_score += @weights[:strength_all_external_attacks_prevented_overall]
495
+ end
496
+
497
+ raw_score += (critical_weakness_types_found.size * -5.0)
498
+
499
+ effective_min_raw_score = @min_raw_score_contribution - @score_range_buffer
500
+ effective_max_raw_score = @max_raw_score_contribution + @score_range_buffer
501
+ range = effective_max_raw_score - effective_min_raw_score
502
+ normalized_score = range.abs < 1e-9 ? 0.5 : (raw_score - effective_min_raw_score) / range
503
+ final_score = (normalized_score * 9) + 1
504
+ final_score = [1.0, [final_score, 10.0].min].max
505
+
506
+ framework_assessments = _assess_frameworks(strengths, weaknesses, final_score)
507
+
508
+ { strengths: strengths, weaknesses: weaknesses, score: final_score, simulated_attacks: simulated_attacks, framework_assessments: framework_assessments }
509
+ end
510
+
511
+ def _assess_frameworks(strengths, weaknesses, score)
512
+ assessments = {}
513
+
514
+ weakness_categories = {
515
+ critical_exposure: weaknesses.any? { |w| w.include?('**Critical risk!**') || w.include?('**Major vulnerability!**') ||
516
+ (w.include?('Simulated attack:') && w.include?('ALLOW')) ||
517
+ (w.include?('Simulated exfiltration:') && w.include?('ALLOW')) },
518
+ insecure_services: weaknesses.any? { |w| w.include?('insecure service') && w.include?('ALLOW') },
519
+ broad_wan_rules: weaknesses.any? { |w| w.include?('overly permissive') && w.include?('WAN') },
520
+ no_explicit_deny: weaknesses.any? { |w| w.include?('No explicit \'DENY all\' inbound rule on WAN') },
521
+ functional_issues: weaknesses.any? { |w| w.include?('functional issue') },
522
+ no_rules_at_all: weaknesses.any? { |w| w.include?('No firewall rules found in the configuration.') },
523
+ nat_vulnerability: weaknesses.any? { |w| w.include?('NAT Rule') && (w.include?('insecure service') || w.include?('sensitive service')) }
524
+ }
525
+
526
+ # --- NIST CSF ---
527
+ nist_score = 5.0
528
+ nist_reasons = []
529
+ nist_reasons << "Overall security score is low (#{sprintf("%.2f", score)}/10)." if score < 6.0
530
+ if weakness_categories[:critical_exposure] || weakness_categories[:nat_vulnerability]
531
+ nist_score -= 2.0
532
+ nist_reasons << "Critical exposures (e.g., exposed management, allowed simulated attacks) detected."
533
+ end
534
+ if weakness_categories[:no_explicit_deny]
535
+ nist_score -= 1.0
536
+ nist_reasons << "Lack of explicit 'DENY all' inbound rule on WAN."
537
+ end
538
+ if weakness_categories[:insecure_services]
539
+ nist_score -= 1.5
540
+ nist_reasons << "Insecure services are allowed."
541
+ end
542
+ if weakness_categories[:functional_issues]
543
+ nist_score -= 0.5
544
+ nist_reasons << "Functional issues detected, potentially impacting system availability."
545
+ end
546
+ nist_score = [1.0, nist_score].max
547
+ nist_status = nist_score >= 3.0 ? 'Pass' : 'Fail'
548
+ assessments['NIST CSF'] = {
549
+ status: nist_status,
550
+ score: nist_score.round(1),
551
+ reason: nist_reasons.empty? ? 'Generally aligns with NIST CSF principles.' : 'Significant weaknesses in core protective controls and risk management.',
552
+ reason_details: nist_reasons
553
+ }
554
+
555
+ # --- ISO/IEC 27001 ---
556
+ iso_score = 5.0
557
+ iso_reasons = []
558
+ if weakness_categories[:critical_exposure]
559
+ iso_score -= 2.0
560
+ iso_reasons << "Critical exposures impacting information security objectives."
561
+ end
562
+ if weakness_categories[:insecure_services]
563
+ iso_score -= 1.5
564
+ iso_reasons << "Insecure services are allowed, violating control objectives."
565
+ end
566
+ if weakness_categories[:broad_wan_rules]
567
+ iso_score -= 1.0
568
+ iso_reasons << "Overly broad WAN rules reduce control effectiveness."
569
+ end
570
+ if weakness_categories[:no_rules_at_all]
571
+ iso_score -= 3.0
572
+ iso_reasons << "No firewall rules found, indicating a lack of basic security controls."
573
+ end
574
+ iso_score = [1.0, iso_score].max
575
+ iso_status = iso_score >= 3.0 ? 'Pass' : 'Fail'
576
+ assessments['ISO/IEC 27001'] = {
577
+ status: iso_status,
578
+ score: iso_score.round(1),
579
+ reason: iso_reasons.empty? ? 'Basic technical controls appear to be in place.' : 'Fundamental information security controls are not adequately implemented.',
580
+ reason_details: iso_reasons
581
+ }
582
+
583
+ # --- CIS Controls ---
584
+ cis_score = 5.0
585
+ cis_reasons = []
586
+ if weakness_categories[:critical_exposure] || weakness_categories[:nat_vulnerability]
587
+ cis_score -= 2.5
588
+ cis_reasons << "Violations of critical security controls (e.g., exposed management, allowed attacks)."
589
+ end
590
+ if weakness_categories[:insecure_services]
591
+ cis_score -= 1.5
592
+ cis_reasons << "Failure to block insecure services (CIS Control 1)."
593
+ end
594
+ if weakness_categories[:no_explicit_deny]
595
+ cis_score -= 1.0
596
+ cis_reasons << "Lack of explicit deny-all rule (CIS Control 9)."
597
+ end
598
+ if weakness_categories[:broad_wan_rules]
599
+ cis_score -= 1.0
600
+ cis_reasons << "Overly permissive inbound rules (CIS Control 9)."
601
+ end
602
+ cis_score = [1.0, cis_score].max
603
+ cis_status = cis_score >= 3.5 ? 'Pass' : 'Fail'
604
+ assessments['CIS Controls'] = {
605
+ status: cis_status,
606
+ score: cis_score.round(1),
607
+ reason: cis_reasons.empty? ? 'Adheres to many foundational CIS Controls.' : 'Violations of critical security controls identified.',
608
+ reason_details: cis_reasons
609
+ }
610
+
611
+ # --- PCI DSS ---
612
+ pci_score = 5.0
613
+ pci_reasons = []
614
+ if weakness_categories[:insecure_services]
615
+ pci_score -= 3.0
616
+ pci_reasons << "Insecure services (e.g., FTP, Telnet, SMB) are allowed, which is a direct PCI DSS violation."
617
+ end
618
+ if weakness_categories[:broad_wan_rules]
619
+ pci_score -= 2.0
620
+ pci_reasons << "Overly permissive WAN rules violate PCI DSS requirement for strict access control."
621
+ end
622
+ if weakness_categories[:critical_exposure] || weakness_categories[:nat_vulnerability]
623
+ pci_score -= 2.0
624
+ pci_reasons << "Critical exposures (e.g., allowed simulated attacks) indicate insufficient segmentation/access controls."
625
+ end
626
+ pci_score = [1.0, pci_score].max
627
+ pci_status = pci_score >= 4.0 ? 'Pass' : 'Fail'
628
+ assessments['PCI DSS'] = {
629
+ status: pci_status,
630
+ score: pci_score.round(1),
631
+ reason: pci_reasons.empty? ? 'No obvious firewall-related PCI DSS violations detected.' : '**Highly likely to fail PCI DSS.** Critical vulnerabilities present.',
632
+ reason_details: pci_reasons
633
+ }
634
+
635
+ # --- SOC 2 ---
636
+ soc2_score = 5.0
637
+ soc2_reasons = []
638
+ if weakness_categories[:critical_exposure]
639
+ soc2_score -= 2.0
640
+ soc2_reasons << "Critical exposures impact security and confidentiality criteria."
641
+ end
642
+ if weakness_categories[:broad_wan_rules]
643
+ soc2_score -= 1.0
644
+ soc2_reasons << "Broad WAN rules impact security and processing integrity."
645
+ end
646
+ if weakness_categories[:functional_issues]
647
+ soc2_score -= 0.8
648
+ soc2_reasons << "Functional issues (e.g., blocked legitimate traffic) impact availability."
649
+ end
650
+ if weakness_categories[:insecure_services]
651
+ soc2_score -= 1.2
652
+ soc2_reasons << "Allowing insecure services violates confidentiality and processing integrity."
653
+ end
654
+ soc2_score = [1.0, soc2_score].max
655
+ soc2_status = soc2_score >= 3.5 ? 'Pass' : 'Fail'
656
+ assessments['SOC 2'] = {
657
+ status: soc2_status,
658
+ score: soc2_score.round(1),
659
+ reason: soc2_reasons.empty? ? 'Basic security controls appear adequate.' : 'Significant control deficiencies related to Trust Services Criteria.',
660
+ reason_details: soc2_reasons
661
+ }
662
+
663
+ # --- COBIT 2019 ---
664
+ cobit_score = 5.0
665
+ cobit_reasons = []
666
+ if score < 6.0
667
+ cobit_score -= 1.0
668
+ cobit_reasons << "Overall security score is low (#{sprintf("%.2f", score)}/10)."
669
+ end
670
+ if weakness_categories[:no_explicit_deny]
671
+ cobit_score -= 1.0
672
+ cobit_reasons << "Lack of explicit deny policy impacts governance over network access."
673
+ end
674
+ if weakness_categories[:broad_wan_rules]
675
+ cobit_score -= 1.5
676
+ cobit_reasons << "Overly permissive rules indicate poor risk management and control design."
677
+ end
678
+ if weakness_categories[:critical_exposure]
679
+ cobit_score -= 2.0
680
+ cobit_reasons << "Critical exposures reflect failure in MEA (Monitor, Evaluate, and Assess) processes."
681
+ end
682
+ if weakness_categories[:insecure_services]
683
+ cobit_score -= 1.0
684
+ cobit_reasons << "Allowing insecure services violates DSS05 (Managed Security Services)."
685
+ end
686
+ cobit_score = [1.0, cobit_score].max
687
+ cobit_status = cobit_score >= 3.0 ? 'Pass' : 'Fail'
688
+ assessments['COBIT 2019'] = {
689
+ status: cobit_status,
690
+ score: cobit_score.round(1),
691
+ reason: cobit_reasons.empty? ? 'Basic governance of network security is present.' : 'Significant gaps in IT governance and control framework implementation.',
692
+ reason_details: cobit_reasons
693
+ }
694
+
695
+ assessments
696
+ end
697
+
698
+ def conduct_audit(config_file_path)
699
+ puts "Starting BlueWall audit..."
700
+ unless File.exist?(config_file_path)
701
+ puts "Error: Configuration file not found at '#{config_file_path}'"
702
+ return AuditResult.new('N/A', [], [], ["Configuration file not found: #{config_file_path}"], 1.0, 'Audit failed.', [], {})
703
+ end
704
+
705
+ begin
706
+ xml_content = File.read(config_file_path)
707
+ xml_doc = Nokogiri::XML(xml_content) { |c| c.options = Nokogiri::XML::ParseOptions::NOBLANKS }
708
+
709
+ system_meta = {
710
+ interfaces: extract_interfaces_from_xml(xml_doc),
711
+ system_ip: extract_system_ip_from_xml(xml_doc)
712
+ }
713
+
714
+ firewall_type = detect_firewall_type_from_xml(xml_doc)
715
+ if firewall_type == 'UNKNOWN'
716
+ return AuditResult.new(firewall_type, [], [], ['Unrecognized XML structure.'], 1.0, 'Cannot perform audit.', [], {})
717
+ end
718
+
719
+ puts "Detected firewall type: #{firewall_type}"
720
+
721
+ aliases = extract_aliases_from_xml(xml_doc)
722
+ schedules = extract_schedules_from_xml(xml_doc)
723
+ nat_rules = extract_nat_rules_from_xml(xml_doc, aliases)
724
+ rules = parse_config_from_xml(xml_doc, aliases)
725
+
726
+ puts "Parsed #{rules.count} firewall rules, #{aliases.size} aliases, #{nat_rules.size} NAT rules."
727
+
728
+ if rules.empty?
729
+ return AuditResult.new(firewall_type, [], [], ['No firewall rules found.'], 1.0, 'Cannot audit empty ruleset.', [], {})
730
+ end
731
+
732
+ audit_findings = audit_rules(rules, firewall_type, system_meta, aliases, nat_rules)
733
+ details = "BlueWall audit completed based on common cybersecurity principles tailored for #{firewall_type} (inspired by CIS Controls and NIST CSF). Score reflects adherence to least privilege, rule specificity, handling of insecure services, and interface-specific security."
734
+
735
+ AuditResult.new(
736
+ firewall_type, rules, audit_findings[:strengths], audit_findings[:weaknesses],
737
+ audit_findings[:score], details, audit_findings[:simulated_attacks], audit_findings[:framework_assessments]
738
+ )
739
+ rescue Nokogiri::XML::SyntaxError => e
740
+ puts "Error parsing XML: #{e.message}"
741
+ AuditResult.new('N/A', [], [], ["XML error: #{e.message}"], 1.0, 'Parse failed.', [], {})
742
+ rescue StandardError => e
743
+ puts "Unexpected error: #{e.message}"
744
+ AuditResult.new('ERROR', [], [], ["Unexpected error: #{e.message}"], 1.0, 'Audit failed.', [], {})
745
+ end
746
+ end
747
+ end
748
+
749
+ # --- Main Execution ---
750
+ if ARGV.empty?
751
+ puts "Usage: ruby bluewall.rb <config.xml>"
752
+ exit(1)
753
+ end
754
+
755
+ config_file = ARGV[0]
756
+ auditor = BlueWall.new
757
+ audit_result = auditor.conduct_audit(config_file)
758
+
759
+ puts audit_result.to_s.split("--- BlueWall Audit Report ---").first
760
+
761
+ puts "\n---------------------------"
762
+ puts "Framework Compliance Assessment (Simplified):"
763
+ audit_result.framework_assessments.each do |framework, result|
764
+ status = result[:status] == 'Pass' ? "\e[32mPASS\e[0m" : "\e[31mFAIL\e[0m"
765
+ puts " - #{framework}: #{status} - #{result[:reason]}"
766
+
767
+ end
768
+
769
+ print "\nWould you like to see the detailed framework assessment in the console? (yes/no): "
770
+ $stdout.flush
771
+ user_detail_choice = STDIN.gets.chomp.downcase
772
+ if user_detail_choice == 'yes'
773
+ puts "\n" + "="*80
774
+ puts " DETAILED FRAMEWORK COMPLIANCE ASSESSMENT"
775
+ puts "="*80
776
+
777
+ audit_result.framework_assessments.each do |framework, result|
778
+ score = sprintf("%.1f", result[:score])
779
+ status = result[:status]
780
+ status_color = status == 'Pass' ? "\e[32m#{status}\e[0m" : "\e[31m#{status}\e[0m"
781
+ bar_width = 20
782
+ filled = (score.to_f / 5.0 * bar_width).round
783
+ progress_bar = "#" * filled + "-" * (bar_width - filled)
784
+ score_bar = "[#{progress_bar}] #{score}/5.0"
785
+
786
+ puts "\n[Framework] #{framework}"
787
+ puts " Score: #{score_bar}"
788
+ puts " Status: #{status_color}"
789
+ puts " Summary: #{result[:reason]}"
790
+
791
+ unless result[:reason_details].empty?
792
+ puts " Breakdown:"
793
+ result[:reason_details].each do |detail|
794
+ # Add plain-text indicators based on content
795
+ indicator = if detail.include?("Critical") || detail.include?("Major")
796
+ "[CRITICAL]"
797
+ elsif detail.include?("Lack") || detail.include?("No")
798
+ "[WARNING]"
799
+ elsif detail.include?("violates") || detail.include?("exposure")
800
+ "[FAIL]"
801
+ elsif detail.include?("Good") || detail.include?("adheres")
802
+ "[PASS]"
803
+ else
804
+ " -"
805
+ end
806
+ puts " #{indicator} #{detail}"
807
+ end
808
+ end
809
+
810
+ # Add mitigation advice for failed frameworks
811
+ if status == 'Fail'
812
+ puts " Recommendations:"
813
+ case framework
814
+ when 'NIST CSF'
815
+ puts " • Implement explicit deny-all inbound rules on WAN"
816
+ puts " • Harden management access (SSH/HTTP) from external sources"
817
+ puts " • Review and block insecure services (e.g., FTP, Telnet)"
818
+ when 'CIS Controls'
819
+ puts " • Enforce least privilege in firewall rules"
820
+ puts " • Enable logging and monitoring of rule hits"
821
+ puts " • Apply 'quick' rules for high-priority blocks"
822
+ when 'PCI DSS'
823
+ puts " • Disable or restrict access to insecure services (ports 21, 139, 445)"
824
+ puts " • Implement strict segmentation between cardholder and other zones"
825
+ puts " • Conduct regular firewall rule reviews"
826
+ when 'SOC 2'
827
+ puts " • Document firewall change management process"
828
+ puts " • Ensure availability of legitimate traffic"
829
+ puts " • Implement automated rule cleanup policies"
830
+ when 'COBIT 2019'
831
+ puts " • Align firewall policy with governance objectives"
832
+ puts " • Define ownership and review cycles for rules"
833
+ puts " • Integrate firewall audits into risk management"
834
+ else
835
+ puts " • Review rule specificity and default deny posture"
836
+ puts " • Limit broad ALLOW rules from untrusted interfaces"
837
+ end
838
+ end
839
+ end
840
+
841
+ # Summary stats
842
+ total = audit_result.framework_assessments.size
843
+ passed = audit_result.framework_assessments.count { |_, r| r[:status] == 'Pass' }
844
+ failed = total - passed
845
+ compliance_rate = ((passed.to_f / total) * 100).round(1)
846
+
847
+ puts "\n" + "-"*50
848
+ puts "Compliance Summary:"
849
+ puts " Passed: #{passed} framework#{passed == 1 ? '' : 's'}"
850
+ puts " Failed: #{failed} framework#{failed == 1 ? '' : 's'}"
851
+ puts " Overall Compliance: #{compliance_rate}%"
852
+ puts "-"*50
853
+
854
+ # Final advisory
855
+ if failed > 0
856
+ puts "\nACTION REQUIRED: #{failed} framework#{failed == 1 ? ' has' : 's have'} critical gaps. Review recommendations above."
857
+ else
858
+ puts "\nEXCELLENT: All frameworks meet minimum compliance thresholds."
859
+ end
860
+ end
861
+
862
+ print "
863
+ Would you like to save this report as an HTML file with a score graph? (yes/no): "
864
+ $stdout.flush
865
+ user_html_choice = STDIN.gets.chomp.downcase
866
+ if user_html_choice == 'yes'
867
+ html_filename = "bluewall_report_#{Time.now.strftime('%Y%m%d_%H%M%S')}.html"
868
+ pre_content = audit_result.to_s.split("--- BlueWall Audit Report ---").first.strip
869
+ .gsub('<', '<').gsub('>', '>').gsub('&', '&amp;').gsub('`', '\\`')
870
+ total_strengths = audit_result.strengths.count
871
+ total_weaknesses = audit_result.weaknesses.count
872
+ total_sim_blocked = audit_result.simulated_attacks.count { |a| !a.include?('ALLOW') }
873
+ total_sim_allowed = audit_result.simulated_attacks.count { |a| a.include?('ALLOW') }
874
+ framework_data = audit_result.framework_assessments.map do |name, result|
875
+ {
876
+ name: name,
877
+ score: result[:score].to_f,
878
+ status: result[:status],
879
+ reason: result[:reason].gsub(/'/, "\\'"),
880
+ details: result[:reason_details].map { |d| d.gsub(/'/, "\\'") }
881
+ }
882
+ end
883
+ defense_depth = auditor.calculate_defense_depth(audit_result.rules)
884
+ entropy_score = auditor.calculate_entropy(audit_result.rules.map(&:to_s))
885
+ html_content = <<~HTML
886
+ <!DOCTYPE html>
887
+ <html lang="en">
888
+ <head>
889
+ <meta charset="UTF-8" />
890
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
891
+ <title>BlueWall Firewall Audit Report</title>
892
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
893
+ <style id="light-theme">
894
+ :root {
895
+ --bg: #f9fafa;
896
+ --text: #333;
897
+ --card-bg: #fff;
898
+ --header-bg: #1a3b5d;
899
+ --accent: #3498db;
900
+ --pass: #27ae60;
901
+ --fail: #e74c3c;
902
+ --warn: #f39c12;
903
+ --border: #ddd;
904
+ --pre-bg: #1e1e1e;
905
+ --pre-color: #00ff00;
906
+ }
907
+ body {
908
+ font-family: 'Segoe UI', sans-serif;
909
+ background-color: var(--bg);
910
+ color: var(--text);
911
+ margin: 0;
912
+ padding: 0;
913
+ line-height: 1.6;
914
+ transition: background-color 0.3s, color 0.3s;
915
+ }
916
+ header {
917
+ background: var(--header-bg);
918
+ color: white;
919
+ text-align: center;
920
+ padding: 20px;
921
+ border-bottom: 5px solid var(--accent);
922
+ }
923
+ .main-content {
924
+ max-width: 1200px;
925
+ margin: 20px auto;
926
+ padding: 20px;
927
+ background: var(--card-bg);
928
+ border-radius: 10px;
929
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
930
+ }
931
+ .tabs {
932
+ display: flex;
933
+ margin-bottom: 20px;
934
+ border-bottom: 1px solid var(--border);
935
+ }
936
+ .tab-button {
937
+ padding: 10px 20px;
938
+ cursor: pointer;
939
+ background: #eee;
940
+ border: 1px solid var(--border);
941
+ border-bottom: none;
942
+ border-radius: 5px 5px 0 0;
943
+ margin-right: 5px;
944
+ font-weight: 600;
945
+ }
946
+ .tab-button.active {
947
+ background: var(--accent);
948
+ color: white;
949
+ }
950
+ .tab-content {
951
+ display: none;
952
+ padding: 20px;
953
+ border: 1px solid var(--border);
954
+ border-radius: 5px;
955
+ background: #fafafa;
956
+ }
957
+ .tab-content.active {
958
+ display: block;
959
+ }
960
+ .chart-wrapper {
961
+ width: 100%;
962
+ height: 300px;
963
+ position: relative;
964
+ margin: 10px 0;
965
+ }
966
+ .chart-wrapper canvas {
967
+ width: 100% !important;
968
+ height: 100% !important;
969
+ }
970
+ .summary-box {
971
+ display: grid;
972
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
973
+ gap: 15px;
974
+ margin: 20px 0;
975
+ }
976
+ .box {
977
+ padding: 15px;
978
+ border-radius: 8px;
979
+ color: white;
980
+ font-weight: bold;
981
+ text-align: center;
982
+ }
983
+ .box.score { background: var(--accent); }
984
+ .box.pass { background: var(--pass); }
985
+ .box.fail { background: var(--fail); }
986
+ .box.warn { background: var(--warn); }
987
+ ul {
988
+ padding-left: 15px;
989
+ }
990
+ li {
991
+ margin: 5px 0;
992
+ }
993
+ pre {
994
+ background: var(--pre-bg);
995
+ color: var(--pre-color);
996
+ padding: 15px;
997
+ border-radius: 8px;
998
+ overflow-x: auto;
999
+ font-family: 'Courier New', monospace;
1000
+ white-space: pre;
1001
+ }
1002
+ footer {
1003
+ text-align: center;
1004
+ margin-top: 30px;
1005
+ color: #7f8c8d;
1006
+ font-size: 0.9em;
1007
+ }
1008
+ .theme-toggle {
1009
+ position: fixed;
1010
+ top: 20px;
1011
+ right: 20px;
1012
+ background: var(--accent);
1013
+ color: white;
1014
+ border: none;
1015
+ padding: 10px 15px;
1016
+ border-radius: 5px;
1017
+ cursor: pointer;
1018
+ font-weight: bold;
1019
+ z-index: 100;
1020
+ }
1021
+ .critical-risk {
1022
+ background: #fef6f6;
1023
+ border: 1px solid #e74c3c;
1024
+ border-radius: 8px;
1025
+ padding: 15px;
1026
+ margin: 20px 0;
1027
+ }
1028
+ .critical-risk h3 {
1029
+ color: #e74c3c;
1030
+ margin-top: 0;
1031
+ }
1032
+ .metrics-table {
1033
+ width: 100%;
1034
+ border-collapse: collapse;
1035
+ font-size: 0.9em;
1036
+ margin: 20px 0;
1037
+ }
1038
+ .metrics-table td {
1039
+ padding: 8px;
1040
+ border-bottom: 1px solid #ddd;
1041
+ }
1042
+ </style>
1043
+ <style id="dark-theme" disabled>
1044
+ :root {
1045
+ --bg: #121212;
1046
+ --text: #e0e0e0;
1047
+ --card-bg: #1e1e1e;
1048
+ --header-bg: #0d47a1;
1049
+ --accent: #1976d2;
1050
+ --pass: #4caf50;
1051
+ --fail: #f44336;
1052
+ --warn: #ff9800;
1053
+ --border: #444;
1054
+ --pre-bg: #000;
1055
+ --pre-color: #00ff00;
1056
+ }
1057
+ body {
1058
+ background-color: var(--bg);
1059
+ color: var(--text);
1060
+ }
1061
+ .tab-button {
1062
+ background: #333;
1063
+ color: white;
1064
+ border-color: var(--border);
1065
+ }
1066
+ .tab-button.active {
1067
+ background: var(--accent);
1068
+ }
1069
+ .tab-content {
1070
+ background: #2a2a2a;
1071
+ border-color: var(--border);
1072
+ }
1073
+ pre {
1074
+ background: var(--pre-bg);
1075
+ color: var(--pre-color);
1076
+ }
1077
+ .critical-risk {
1078
+ background: #380909;
1079
+ border-color: #c62828;
1080
+ }
1081
+ .critical-risk h3 {
1082
+ color: #e57373;
1083
+ }
1084
+ .metrics-table td {
1085
+ border-bottom: 1px solid #555;
1086
+ }
1087
+ </style>
1088
+ </head>
1089
+ <body>
1090
+ <button class="theme-toggle" onclick="toggleDarkMode()">🌙 Dark Mode</button>
1091
+ <header>
1092
+ <h1>🔐 BlueWall Firewall Audit Report</h1>
1093
+ <p>Generated on #{Time.now.strftime('%Y-%m-%d at %H:%M:%S')}</p>
1094
+ </header>
1095
+ <div class="main-content">
1096
+ <pre id="ascii-art"></pre>
1097
+ <div class="summary-box">
1098
+ <div class="box score">
1099
+ Overall Score<br><strong>#{sprintf("%.2f", audit_result.score)}/10</strong>
1100
+ </div>
1101
+ <div class="box pass">
1102
+ Strengths<br><strong>#{total_strengths}</strong>
1103
+ </div>
1104
+ <div class="box fail">
1105
+ Weaknesses<br><strong>#{total_weaknesses}</strong>
1106
+ </div>
1107
+ <div class="box warn">
1108
+ Type<br><strong>#{audit_result.firewall_type}</strong>
1109
+ </div>
1110
+ </div>
1111
+ <div class="tabs">
1112
+ <div class="tab-button active" onclick="openTab(event, 'overview')">📊 Overview</div>
1113
+ <div class="tab-button" onclick="openTab(event, 'frameworks')">🎯 Frameworks</div>
1114
+ <div class="tab-button" onclick="openTab(event, 'findings')">🔍 Findings</div>
1115
+ <div class="tab-button" onclick="openTab(event, 'simulations')">🧪 Simulations</div>
1116
+ </div>
1117
+ <div id="overview" class="tab-content active">
1118
+ <h2>Security Overview</h2>
1119
+ <div class="critical-risk" id="critical-risk-container" style="display: none;">
1120
+ <h3>🚨 Critical Risks</h3>
1121
+ <ul id="critical-risk-list"></ul>
1122
+ </div>
1123
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 25px; margin: 20px 0;">
1124
+ <div class="chart-wrapper"><canvas id="overallScoreChart"></canvas></div>
1125
+ <div class="chart-wrapper"><canvas id="findingsChart"></canvas></div>
1126
+ <div class="chart-wrapper"><canvas id="interfaceDistributionChart"></canvas></div>
1127
+ <div class="chart-wrapper"><canvas id="actionDistributionChart"></canvas></div>
1128
+ <div class="chart-wrapper"><canvas id="protocolDistributionChart"></canvas></div>
1129
+ <div class="chart-wrapper"><canvas id="topPortsChart"></canvas></div>
1130
+ </div>
1131
+ <div style="margin: 30px 0; padding: 20px; background: #f8f9fa; border-radius: 8px;">
1132
+ <h3>🔍 Advanced Security Metrics</h3>
1133
+ <table class="metrics-table">
1134
+ <tr><td><strong>Defense Depth Score</strong></td><td>#{sprintf("%.2f", defense_depth)}/5.0</td></tr>
1135
+ <tr><td><strong>Rule Entropy (Complexity)</strong></td><td>#{sprintf("%.2f", entropy_score)}/3.0</td></tr>
1136
+ <tr><td><strong>Scheduled Rules</strong></td><td>#{audit_result.rules.count { |r| r.schedule }} rules use time-based access control</td></tr>
1137
+ <tr><td><strong>Stateless Rules</strong></td><td>#{audit_result.rules.count { |r| r.state_type != 'keep state' }}</td></tr>
1138
+ <tr><td><strong>Quick Rules</strong></td><td>#{audit_result.rules.count { |r| r.quick }} rules use 'quick' evaluation</td></tr>
1139
+ </table>
1140
+ </div>
1141
+ </div>
1142
+ <div id="frameworks" class="tab-content">
1143
+ <h2>Framework Compliance Assessment</h2>
1144
+ <p>Each chart shows compliance score (out of 5).</p>
1145
+ <div id="frameworkCharts" style="display: flex; flex-wrap: wrap; justify-content: center; gap: 20px;">
1146
+ #{framework_data.map do |data|
1147
+ chart_id = data[:name].gsub(/\W+/, '_')
1148
+ status_class = data[:status] == 'Pass' ? 'pass' : 'fail'
1149
+ <<~HTML
1150
+ <div style="text-align:center; width: 30%;">
1151
+ <h3>#{data[:name]}</h3>
1152
+ <div class="chart-wrapper">
1153
+ <canvas id="#{chart_id}_chart"></canvas>
1154
+ </div>
1155
+ <p><strong>Status:</strong> <span class="#{status_class}">#{data[:status]}</span> (#{sprintf("%.1f", data[:score])}/5)</p>
1156
+ <p style="font-size:0.9em; color:#555;"><em>#{data[:reason]}</em></p>
1157
+ <details style="margin-top:10px; font-size:0.85em;">
1158
+ <summary>Why this score?</summary>
1159
+ <ul>
1160
+ #{data[:details].map { |d| "<li>#{d}</li>" }.join('')}
1161
+ </ul>
1162
+ </details>
1163
+ </div>
1164
+ HTML
1165
+ end.join("\n")}
1166
+ </div>
1167
+ </div>
1168
+ <div id="findings" class="tab-content">
1169
+ <h2>Strengths & Weaknesses</h2>
1170
+ <h3>✅ Strengths</h3>
1171
+ <ul>#{audit_result.strengths.map { |s| "<li>#{s}</li>" }.join('')}</ul>
1172
+ <h3>❌ Weaknesses</h3>
1173
+ <ul>#{audit_result.weaknesses.map { |w| "<li>#{w}</li>" }.join('')}</ul>
1174
+ </div>
1175
+ <div id="simulations" class="tab-content">
1176
+ <h2>Attack Simulation Results</h2>
1177
+ <div class="chart-wrapper"><canvas id="simulationsChart"></canvas></div>
1178
+ <ul>#{audit_result.simulated_attacks.map { |s| "<li>#{s}</li>" }.join('')}</ul>
1179
+ </div>
1180
+ <footer>
1181
+ Report generated by <strong>BlueWall</strong> — created by :cillia
1182
+ </footer>
1183
+ </div>
1184
+ <script>
1185
+ function toggleDarkMode() {
1186
+ const darkTheme = document.getElementById('dark-theme');
1187
+ const button = document.querySelector('.theme-toggle');
1188
+ if (darkTheme.disabled) {
1189
+ darkTheme.disabled = false;
1190
+ button.textContent = '☀️ Light Mode';
1191
+ localStorage.setItem('darkMode', 'enabled');
1192
+ } else {
1193
+ darkTheme.disabled = true;
1194
+ button.textContent = '🌙 Dark Mode';
1195
+ localStorage.setItem('darkMode', 'disabled');
1196
+ }
1197
+ }
1198
+
1199
+ window.addEventListener('DOMContentLoaded', () => {
1200
+ if (localStorage.getItem('darkMode') === 'enabled') {
1201
+ document.getElementById('dark-theme').disabled = false;
1202
+ document.querySelector('.theme-toggle').textContent = '☀️ Light Mode';
1203
+ }
1204
+
1205
+ const criticalRisks = #{JSON.generate(audit_result.weaknesses.select { |w| w.include?('**Critical risk!**') })};
1206
+ const container = document.getElementById('critical-risk-container');
1207
+ const list = document.getElementById('critical-risk-list');
1208
+ if (criticalRisks.length > 0) {
1209
+ container.style.display = 'block';
1210
+ criticalRisks.forEach(risk => {
1211
+ const li = document.createElement('li');
1212
+ li.innerHTML = '<strong>' + risk.replace(/\*\*/g, '') + '</strong>';
1213
+ list.appendChild(li);
1214
+ });
1215
+ }
1216
+ });
1217
+
1218
+ function openTab(evt, tabName) {
1219
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
1220
+ document.querySelectorAll('.tab-button').forEach(t => t.classList.remove('active'));
1221
+ document.getElementById(tabName).classList.add('active');
1222
+ evt.currentTarget.classList.add('active');
1223
+ }
1224
+
1225
+ document.addEventListener('DOMContentLoaded', function () {
1226
+ document.getElementById('ascii-art').textContent = `#{pre_content}`;
1227
+
1228
+ // 1. Overall Score
1229
+ new Chart(document.getElementById('overallScoreChart'), {
1230
+ type: 'bar',
1231
+ data: {
1232
+ labels: ['Security Score'],
1233
+ datasets: [{
1234
+ label: 'Score (1-10)',
1235
+ data: [#{audit_result.score}],
1236
+ backgroundColor: #{audit_result.score} >= 8 ? '#27ae60' : #{audit_result.score} >= 5 ? '#f39c12' : '#e74c3c',
1237
+ borderColor: '#2c3e50',
1238
+ borderWidth: 2
1239
+ }]
1240
+ },
1241
+ options: {
1242
+ responsive: true,
1243
+ maintainAspectRatio: false,
1244
+ indexAxis: 'y',
1245
+ scales: { x: { min: 0, max: 10 } },
1246
+ plugins: { legend: { display: false } }
1247
+ }
1248
+ });
1249
+
1250
+ // 2. Findings
1251
+ new Chart(document.getElementById('findingsChart'), {
1252
+ type: 'bar',
1253
+ data: {
1254
+ labels: ['Findings'],
1255
+ datasets: [
1256
+ { label: 'Strengths', data: [#{total_strengths}], backgroundColor: '#27ae60', stack: 'stack0' },
1257
+ { label: 'Weaknesses', data: [#{total_weaknesses}], backgroundColor: '#e74c3c', stack: 'stack0' }
1258
+ ]
1259
+ },
1260
+ options: {
1261
+ responsive: true,
1262
+ maintainAspectRatio: false,
1263
+ scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true } },
1264
+ plugins: { legend: { position: 'top' } }
1265
+ }
1266
+ });
1267
+
1268
+ // 3. Interface Distribution
1269
+ const interfaceData = #{JSON.generate(audit_result.rules.group_by(&:interface).map { |k, v| [k.to_s, v.size] }.to_h)};
1270
+ new Chart(document.getElementById('interfaceDistributionChart'), {
1271
+ type: 'pie',
1272
+ data: {
1273
+ labels: Object.keys(interfaceData),
1274
+ datasets: [{
1275
+ data: Object.values(interfaceData),
1276
+ backgroundColor: ['#3498db', '#e74c3c', '#f39c12', '#9b59b6', '#1abc9c']
1277
+ }]
1278
+ },
1279
+ options: {
1280
+ responsive: true,
1281
+ maintainAspectRatio: false,
1282
+ plugins: { legend: { position: 'bottom' } }
1283
+ }
1284
+ });
1285
+
1286
+ // 4. Action Distribution
1287
+ const actionData = {
1288
+ ALLOW: #{audit_result.rules.count { |r| r.action == 'ALLOW' }},
1289
+ DENY: #{audit_result.rules.count { |r| r.action == 'DENY' }}
1290
+ };
1291
+ new Chart(document.getElementById('actionDistributionChart'), {
1292
+ type: 'bar',
1293
+ data: {
1294
+ labels: ['Actions'],
1295
+ datasets: [
1296
+ { label: 'ALLOW', data: [actionData.ALLOW], backgroundColor: '#27ae60' },
1297
+ { label: 'DENY', data: [actionData.DENY], backgroundColor: '#e74c3c' }
1298
+ ]
1299
+ },
1300
+ options: {
1301
+ responsive: true,
1302
+ maintainAspectRatio: false,
1303
+ scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true } },
1304
+ plugins: { legend: { position: 'top' } }
1305
+ }
1306
+ });
1307
+
1308
+ // 5. Protocol Distribution
1309
+ const protocolData = #{JSON.generate(audit_result.rules.group_by(&:protocol).map { |k, v| [k.to_s, v.size] }.to_h)};
1310
+ new Chart(document.getElementById('protocolDistributionChart'), {
1311
+ type: 'doughnut',
1312
+ data: {
1313
+ labels: Object.keys(protocolData),
1314
+ datasets: [{
1315
+ data: Object.values(protocolData),
1316
+ backgroundColor: ['#3498db', '#e74c3c', '#f39c12', '#9b59b6']
1317
+ }]
1318
+ },
1319
+ options: {
1320
+ responsive: true,
1321
+ maintainAspectRatio: false,
1322
+ cutout: '60%',
1323
+ plugins: { legend: { position: 'bottom' } }
1324
+ }
1325
+ });
1326
+
1327
+ // 6. Top Ports
1328
+ const portCounts = #{JSON.generate(Hash.new(0).tap { |h| audit_result.rules.each { |r| h[r.dport || 'any'] += 1 if r.dport } }.sort_by { |k, v| -v }.take(8).to_h.transform_keys(&:to_s))};
1329
+ new Chart(document.getElementById('topPortsChart'), {
1330
+ type: 'bar',
1331
+ data: {
1332
+ labels: Object.keys(portCounts),
1333
+ datasets: [{
1334
+ label: 'Rule Count',
1335
+ data: Object.values(portCounts),
1336
+ backgroundColor: '#3498db'
1337
+ }]
1338
+ },
1339
+ options: {
1340
+ indexAxis: 'y',
1341
+ responsive: true,
1342
+ maintainAspectRatio: false,
1343
+ scales: { x: { beginAtZero: true } },
1344
+ plugins: { title: { display: true, text: 'Top Targeted Ports' } }
1345
+ }
1346
+ });
1347
+
1348
+ // 7. Simulations
1349
+ const totalSimBlocked = #{total_sim_blocked};
1350
+ const totalSimAllowed = #{total_sim_allowed};
1351
+ new Chart(document.getElementById('simulationsChart'), {
1352
+ type: 'pie',
1353
+ data: {
1354
+ labels: ['Blocked', 'Allowed'],
1355
+ datasets: [{
1356
+ data: [totalSimBlocked, totalSimAllowed],
1357
+ backgroundColor: ['#27ae60', '#e74c3c']
1358
+ }]
1359
+ },
1360
+ options: {
1361
+ responsive: true,
1362
+ maintainAspectRatio: false
1363
+ }
1364
+ });
1365
+
1366
+ // 8. Framework Charts
1367
+ #{framework_data.map do |data|
1368
+ chart_id = data[:name].gsub(/\W+/, '_')
1369
+ score = data[:score]
1370
+ remaining = (5.0 - score).round(2)
1371
+ <<~JS
1372
+ new Chart(document.getElementById('#{chart_id}_chart'), {
1373
+ type: 'doughnut',
1374
+ data: {
1375
+ labels: ['Score', 'Remaining'],
1376
+ datasets: [{
1377
+ data: [#{score}, #{remaining}],
1378
+ backgroundColor: ['#{data[:status] == 'Pass' ? '#27ae60' : '#e74c3c'}', '#ecf0f1']
1379
+ }]
1380
+ },
1381
+ options: {
1382
+ responsive: true,
1383
+ maintainAspectRatio: false,
1384
+ cutout: '70%',
1385
+ plugins: { legend: { display: false } }
1386
+ }
1387
+ });
1388
+ JS
1389
+ end.join("\n")}
1390
+ });
1391
+ </script>
1392
+ </body>
1393
+ </html>
1394
+ HTML
1395
+
1396
+ File.write(html_filename, html_content)
1397
+ puts "Interactive HTML report saved to #{html_filename}"
1398
+ else
1399
+ puts "HTML report not saved."
1400
+ end