flareguard 0.1.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 (64) hide show
  1. package/Cargo.lock +2962 -0
  2. package/Cargo.toml +66 -0
  3. package/LICENSE +21 -0
  4. package/README.md +115 -0
  5. package/bin/flareguard.js +78 -0
  6. package/package.json +50 -0
  7. package/scripts/postinstall.mjs +83 -0
  8. package/src/bindings/ast_scanner.rs +950 -0
  9. package/src/bindings/cli.rs +49 -0
  10. package/src/bindings/jsonc.rs +166 -0
  11. package/src/bindings/mod.rs +7 -0
  12. package/src/bindings/reporter.rs +328 -0
  13. package/src/bindings/types.rs +129 -0
  14. package/src/bindings/validator.rs +227 -0
  15. package/src/bindings/wrangler.rs +647 -0
  16. package/src/cli.rs +77 -0
  17. package/src/lib.rs +7 -0
  18. package/src/main.rs +451 -0
  19. package/src/origin/cli.rs +92 -0
  20. package/src/origin/cloudflare.rs +170 -0
  21. package/src/origin/confidence.rs +320 -0
  22. package/src/origin/crtsh.rs +144 -0
  23. package/src/origin/dns.rs +177 -0
  24. package/src/origin/enumerator.rs +271 -0
  25. package/src/origin/error.rs +19 -0
  26. package/src/origin/mock.rs +320 -0
  27. package/src/origin/mod.rs +19 -0
  28. package/src/origin/models.rs +167 -0
  29. package/src/origin/prober.rs +260 -0
  30. package/src/origin/remediation.rs +73 -0
  31. package/src/origin/report.rs +625 -0
  32. package/src/origin/scanner.rs +217 -0
  33. package/src/secrets/cli.rs +94 -0
  34. package/src/secrets/env_parser.rs +464 -0
  35. package/src/secrets/ignore.rs +139 -0
  36. package/src/secrets/mod.rs +14 -0
  37. package/src/secrets/report/json_format.rs +97 -0
  38. package/src/secrets/report/mod.rs +48 -0
  39. package/src/secrets/report/sarif.rs +225 -0
  40. package/src/secrets/report/text.rs +105 -0
  41. package/src/secrets/rules/builtin.rs +280 -0
  42. package/src/secrets/rules/entropy.rs +66 -0
  43. package/src/secrets/rules/mod.rs +7 -0
  44. package/src/secrets/rules/types.rs +183 -0
  45. package/src/secrets/scanner.rs +444 -0
  46. package/src/zone/cli.rs +111 -0
  47. package/src/zone/client/cf_client.rs +405 -0
  48. package/src/zone/client/mod.rs +5 -0
  49. package/src/zone/client/provider.rs +12 -0
  50. package/src/zone/mock_data.rs +481 -0
  51. package/src/zone/mod.rs +125 -0
  52. package/src/zone/models/audit.rs +194 -0
  53. package/src/zone/models/cloudflare.rs +252 -0
  54. package/src/zone/models/mod.rs +7 -0
  55. package/src/zone/models/sarif.rs +89 -0
  56. package/src/zone/reporters/html_rep.rs +345 -0
  57. package/src/zone/reporters/json_rep.rs +7 -0
  58. package/src/zone/reporters/mod.rs +9 -0
  59. package/src/zone/reporters/sarif_rep.rs +100 -0
  60. package/src/zone/reporters/terminal.rs +322 -0
  61. package/src/zone/rules/definitions.rs +201 -0
  62. package/src/zone/rules/evaluator.rs +484 -0
  63. package/src/zone/rules/mod.rs +5 -0
  64. package/src/zone/scoring.rs +104 -0
@@ -0,0 +1,484 @@
1
+ use crate::zone::models::{
2
+ AuditFinding, RiskLevel, ZoneAuditData, ZoneAuditReport, ZoneSettingsSummary,
3
+ };
4
+ use crate::zone::rules::definitions::get_rule_by_id;
5
+ use crate::zone::scoring::{calculate_grade, calculate_zone_score};
6
+
7
+ /// Audits a single zone against all security rules and returns a structured ZoneAuditReport
8
+ pub fn evaluate_zone(data: &ZoneAuditData) -> ZoneAuditReport {
9
+ let mut findings: Vec<AuditFinding> = Vec::new();
10
+ let mut passed_checks = 0usize;
11
+ let mut total_checks = 0usize;
12
+
13
+ // Helper macro to record finding
14
+ macro_rules! check {
15
+ ($rule_id:expr, $passed:expr, $actual:expr, $expected:expr, $severity_override:expr, $penalty:expr) => {{
16
+ total_checks += 1;
17
+ if $passed {
18
+ passed_checks += 1;
19
+ } else {
20
+ if let Some(def) = get_rule_by_id($rule_id) {
21
+ let severity: RiskLevel = $severity_override.unwrap_or(def.default_severity);
22
+ findings.push(AuditFinding {
23
+ rule_id: def.id.to_string(),
24
+ rule_name: def.name.to_string(),
25
+ category: def.category,
26
+ risk_level: severity,
27
+ title: def.title.to_string(),
28
+ description: def.description.to_string(),
29
+ actual_value: $actual.to_string(),
30
+ expected_value: $expected.to_string(),
31
+ remediation: def.remediation.to_string(),
32
+ score_penalty: $penalty,
33
+ doc_url: Some(def.doc_url.to_string()),
34
+ });
35
+ }
36
+ }
37
+ }};
38
+ }
39
+
40
+ // 1. SSL/TLS Mode Check (CF-SSL-001, CF-SSL-002)
41
+ let ssl_mode = data
42
+ .settings
43
+ .ssl
44
+ .as_deref()
45
+ .unwrap_or("unknown")
46
+ .to_lowercase();
47
+ match ssl_mode.as_str() {
48
+ "off" => {
49
+ check!(
50
+ "CF-SSL-001",
51
+ false,
52
+ "off (SSL disabled)",
53
+ "strict (Full strict)",
54
+ Some(RiskLevel::Critical),
55
+ 35
56
+ );
57
+ }
58
+ "flexible" => {
59
+ check!(
60
+ "CF-SSL-001",
61
+ false,
62
+ "flexible (Insecure edge-only encryption)",
63
+ "strict (Full strict)",
64
+ Some(RiskLevel::Critical),
65
+ 30
66
+ );
67
+ }
68
+ "full" => {
69
+ check!(
70
+ "CF-SSL-002",
71
+ false,
72
+ "full (Non-strict origin certificate)",
73
+ "strict (Full strict)",
74
+ Some(RiskLevel::Medium),
75
+ 10
76
+ );
77
+ }
78
+ "strict" => {
79
+ check!("CF-SSL-001", true, "strict", "strict", None, 0);
80
+ }
81
+ other => {
82
+ check!(
83
+ "CF-SSL-001",
84
+ false,
85
+ format!("unknown/unconfigured ({})", other),
86
+ "strict",
87
+ Some(RiskLevel::High),
88
+ 25
89
+ );
90
+ }
91
+ }
92
+
93
+ // 2. Minimum TLS Version (CF-TLS-001)
94
+ let min_tls = data
95
+ .settings
96
+ .min_tls_version
97
+ .as_deref()
98
+ .unwrap_or("1.0")
99
+ .to_string();
100
+ let min_tls_passed = min_tls == "1.2" || min_tls == "1.3";
101
+ check!(
102
+ "CF-TLS-001",
103
+ min_tls_passed,
104
+ format!("TLS {}", min_tls),
105
+ "TLS 1.2 or TLS 1.3",
106
+ if !min_tls_passed {
107
+ Some(RiskLevel::High)
108
+ } else {
109
+ None
110
+ },
111
+ if !min_tls_passed { 20 } else { 0 }
112
+ );
113
+
114
+ // 3. TLS 1.3 Protocol (CF-TLS-002)
115
+ let tls13_on = data
116
+ .settings
117
+ .tls_1_3
118
+ .as_deref()
119
+ .map(|v| v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("zrt"))
120
+ .unwrap_or(false);
121
+ check!(
122
+ "CF-TLS-002",
123
+ tls13_on,
124
+ if tls13_on { "on" } else { "off" },
125
+ "on",
126
+ Some(RiskLevel::Low),
127
+ if !tls13_on { 5 } else { 0 }
128
+ );
129
+
130
+ // 4. Always Use HTTPS (CF-HTTPS-001)
131
+ let always_https = data
132
+ .settings
133
+ .always_use_https
134
+ .as_deref()
135
+ .map(|v| v.eq_ignore_ascii_case("on"))
136
+ .unwrap_or(false);
137
+ check!(
138
+ "CF-HTTPS-001",
139
+ always_https,
140
+ if always_https { "on" } else { "off" },
141
+ "on",
142
+ Some(RiskLevel::High),
143
+ if !always_https { 20 } else { 0 }
144
+ );
145
+
146
+ // 5. Automatic HTTPS Rewrites (CF-HTTPS-002)
147
+ let auto_rewrites = data
148
+ .settings
149
+ .automatic_https_rewrites
150
+ .as_deref()
151
+ .map(|v| v.eq_ignore_ascii_case("on"))
152
+ .unwrap_or(false);
153
+ check!(
154
+ "CF-HTTPS-002",
155
+ auto_rewrites,
156
+ if auto_rewrites { "on" } else { "off" },
157
+ "on",
158
+ Some(RiskLevel::Medium),
159
+ if !auto_rewrites { 10 } else { 0 }
160
+ );
161
+
162
+ // 6. HSTS (CF-HSTS-001, CF-HSTS-002, CF-HSTS-003, CF-HSTS-004, CF-HSTS-005)
163
+ let hsts_opt = data
164
+ .settings
165
+ .security_header
166
+ .as_ref()
167
+ .and_then(|sh| sh.strict_transport_security.as_ref());
168
+
169
+ let hsts_enabled = hsts_opt.map(|h| h.enabled).unwrap_or(false);
170
+ check!(
171
+ "CF-HSTS-001",
172
+ hsts_enabled,
173
+ if hsts_enabled { "enabled" } else { "disabled" },
174
+ "enabled",
175
+ Some(RiskLevel::High),
176
+ if !hsts_enabled { 20 } else { 0 }
177
+ );
178
+
179
+ if hsts_enabled
180
+ && let Some(hsts) = hsts_opt {
181
+ let max_age = hsts.max_age.unwrap_or(0);
182
+ let six_months = 15_552_000u64;
183
+ let one_year = 31_536_000u64;
184
+
185
+ if max_age < six_months {
186
+ check!(
187
+ "CF-HSTS-002",
188
+ false,
189
+ format!(
190
+ "{} seconds (~{:.1} months)",
191
+ max_age,
192
+ (max_age as f64) / 2_592_000.0
193
+ ),
194
+ ">= 15,552,000 seconds (6 months)",
195
+ Some(RiskLevel::Medium),
196
+ 10
197
+ );
198
+ } else if max_age < one_year {
199
+ check!(
200
+ "CF-HSTS-002",
201
+ false,
202
+ format!(
203
+ "{} seconds (~{:.1} months)",
204
+ max_age,
205
+ (max_age as f64) / 2_592_000.0
206
+ ),
207
+ ">= 31,536,000 seconds (1 year / Preload ready)",
208
+ Some(RiskLevel::Low),
209
+ 5
210
+ );
211
+ } else {
212
+ check!(
213
+ "CF-HSTS-002",
214
+ true,
215
+ format!("{}s", max_age),
216
+ ">= 15,552,000s",
217
+ None,
218
+ 0
219
+ );
220
+ }
221
+
222
+ let subdomains = hsts.include_subdomains.unwrap_or(false);
223
+ check!(
224
+ "CF-HSTS-003",
225
+ subdomains,
226
+ if subdomains { "true" } else { "false" },
227
+ "true",
228
+ Some(RiskLevel::Medium),
229
+ if !subdomains { 10 } else { 0 }
230
+ );
231
+
232
+ let preload = hsts.preload.unwrap_or(false);
233
+ check!(
234
+ "CF-HSTS-004",
235
+ preload,
236
+ if preload { "true" } else { "false" },
237
+ "true",
238
+ Some(RiskLevel::Low),
239
+ if !preload { 5 } else { 0 }
240
+ );
241
+
242
+ let nosniff = hsts.nosniff.unwrap_or(false);
243
+ check!(
244
+ "CF-HSTS-005",
245
+ nosniff,
246
+ if nosniff { "true" } else { "false" },
247
+ "true",
248
+ Some(RiskLevel::Low),
249
+ if !nosniff { 5 } else { 0 }
250
+ );
251
+ }
252
+
253
+ // 7. WAF & Managed Rules (CF-WAF-001)
254
+ let waf_active = data.waf.waf_enabled
255
+ || data.waf.managed_rules_active
256
+ || !data.waf.packages.is_empty()
257
+ || !data.waf.rulesets.is_empty();
258
+ check!(
259
+ "CF-WAF-001",
260
+ waf_active,
261
+ if waf_active {
262
+ "Active (Managed Rules/Rulesets Configured)"
263
+ } else {
264
+ "Disabled / Not Configured"
265
+ },
266
+ "Active (Cloudflare Managed Ruleset / OWASP enabled)",
267
+ Some(RiskLevel::High),
268
+ if !waf_active { 20 } else { 0 }
269
+ );
270
+
271
+ // 8. Bot Fight Mode (CF-BOT-001)
272
+ let bot_active = data.bot_management.fight_mode
273
+ || data.bot_management.sbfm_definitely_automated.is_some()
274
+ || data.bot_management.using_latest_model.unwrap_or(false);
275
+ check!(
276
+ "CF-BOT-001",
277
+ bot_active,
278
+ if bot_active { "Active" } else { "Disabled" },
279
+ "Active (Bot Fight Mode / Super Bot Fight Mode)",
280
+ Some(RiskLevel::Medium),
281
+ if !bot_active { 10 } else { 0 }
282
+ );
283
+
284
+ // 9. Rate Limiting (CF-RATE-001)
285
+ let rate_limits_active = !data.rate_limits.rules.is_empty()
286
+ && data
287
+ .rate_limits
288
+ .rules
289
+ .iter()
290
+ .any(|r| !r.disabled.unwrap_or(false));
291
+ check!(
292
+ "CF-RATE-001",
293
+ rate_limits_active,
294
+ if rate_limits_active {
295
+ format!("{} active rules", data.rate_limits.rules.len())
296
+ } else {
297
+ "0 rules configured".to_string()
298
+ },
299
+ ">= 1 Rate Limiting rule",
300
+ Some(RiskLevel::Low),
301
+ if !rate_limits_active { 5 } else { 0 }
302
+ );
303
+
304
+ // 10. DNSSEC (CF-DNS-001)
305
+ let dnssec_active = data.dnssec.status.eq_ignore_ascii_case("active");
306
+ check!(
307
+ "CF-DNS-001",
308
+ dnssec_active,
309
+ &data.dnssec.status,
310
+ "active",
311
+ Some(RiskLevel::Medium),
312
+ if !dnssec_active { 10 } else { 0 }
313
+ );
314
+
315
+ // 11. Overly Permissive IP Access Rules (CF-SEC-001)
316
+ let mut permissive_rule_found = false;
317
+ let mut permissive_reason = String::new();
318
+ let mut permissive_severity = RiskLevel::High;
319
+ let mut permissive_penalty = 20;
320
+
321
+ for rule in &data.ip_access_rules.rules {
322
+ if rule.paused.unwrap_or(false) {
323
+ continue;
324
+ }
325
+ let mode = rule.mode.to_lowercase();
326
+ if mode == "whitelist" || mode == "allow" || mode == "bypass" {
327
+ let target_val = rule.configuration.value.trim().to_lowercase();
328
+ if target_val == "0.0.0.0/0"
329
+ || target_val == "::/0"
330
+ || target_val == "0.0.0.0"
331
+ || target_val == "any"
332
+ || target_val == "*"
333
+ {
334
+ permissive_rule_found = true;
335
+ permissive_severity = RiskLevel::Critical;
336
+ permissive_penalty = 35;
337
+ permissive_reason = format!(
338
+ "Rule ID '{}' sets global '{}' for target '{}'",
339
+ rule.id, rule.mode, rule.configuration.value
340
+ );
341
+ break;
342
+ } else if target_val.ends_with("/0")
343
+ || target_val.ends_with("/1")
344
+ || target_val.ends_with("/2")
345
+ || target_val.ends_with("/3")
346
+ || target_val.ends_with("/4")
347
+ || target_val.ends_with("/5")
348
+ || target_val.ends_with("/6")
349
+ || target_val.ends_with("/7")
350
+ || target_val.ends_with("/8")
351
+ {
352
+ permissive_rule_found = true;
353
+ permissive_severity = RiskLevel::High;
354
+ permissive_penalty = 20;
355
+ permissive_reason = format!(
356
+ "Rule ID '{}' allows excessive subnet range '{}'",
357
+ rule.id, rule.configuration.value
358
+ );
359
+ }
360
+ }
361
+ }
362
+
363
+ check!(
364
+ "CF-SEC-001",
365
+ !permissive_rule_found,
366
+ if permissive_rule_found {
367
+ permissive_reason
368
+ } else {
369
+ "No overly broad IP access allow rules".to_string()
370
+ },
371
+ "Restricted specific IP addresses / narrow CIDRs",
372
+ Some(permissive_severity),
373
+ if permissive_rule_found {
374
+ permissive_penalty
375
+ } else {
376
+ 0
377
+ }
378
+ );
379
+
380
+ // 12. Zone Security Level (CF-SEC-002)
381
+ let sec_level = data
382
+ .settings
383
+ .security_level
384
+ .as_deref()
385
+ .unwrap_or("medium")
386
+ .to_lowercase();
387
+ match sec_level.as_str() {
388
+ "essentially_off" => {
389
+ check!(
390
+ "CF-SEC-002",
391
+ false,
392
+ "essentially_off",
393
+ "medium or high",
394
+ Some(RiskLevel::High),
395
+ 15
396
+ );
397
+ }
398
+ "low" => {
399
+ check!(
400
+ "CF-SEC-002",
401
+ false,
402
+ "low",
403
+ "medium or high",
404
+ Some(RiskLevel::Medium),
405
+ 5
406
+ );
407
+ }
408
+ "medium" | "high" | "under_attack" => {
409
+ check!("CF-SEC-002", true, &sec_level, "medium or high", None, 0);
410
+ }
411
+ _ => {
412
+ check!("CF-SEC-002", true, &sec_level, "medium or high", None, 0);
413
+ }
414
+ }
415
+
416
+ // 13. Browser Integrity Check (CF-SEC-003)
417
+ let browser_check = data
418
+ .settings
419
+ .browser_check
420
+ .as_deref()
421
+ .map(|v| v.eq_ignore_ascii_case("on"))
422
+ .unwrap_or(true); // default in CF is usually on
423
+ check!(
424
+ "CF-SEC-003",
425
+ browser_check,
426
+ if browser_check { "on" } else { "off" },
427
+ "on",
428
+ Some(RiskLevel::Low),
429
+ if !browser_check { 5 } else { 0 }
430
+ );
431
+
432
+ // Calculate score & grade
433
+ let score = calculate_zone_score(&findings);
434
+ let grade = calculate_grade(score).to_string();
435
+
436
+ let settings_summary = ZoneSettingsSummary {
437
+ ssl_mode: data
438
+ .settings
439
+ .ssl
440
+ .clone()
441
+ .unwrap_or_else(|| "none".to_string()),
442
+ min_tls: format!("TLS {}", min_tls),
443
+ always_https: if always_https {
444
+ "Enabled".to_string()
445
+ } else {
446
+ "Disabled".to_string()
447
+ },
448
+ hsts_status: if hsts_enabled {
449
+ "Enabled".to_string()
450
+ } else {
451
+ "Disabled".to_string()
452
+ },
453
+ dnssec_status: data.dnssec.status.clone(),
454
+ waf_status: if waf_active {
455
+ "Active".to_string()
456
+ } else {
457
+ "Inactive".to_string()
458
+ },
459
+ bot_fight_mode: if bot_active {
460
+ "Active".to_string()
461
+ } else {
462
+ "Inactive".to_string()
463
+ },
464
+ security_level: sec_level,
465
+ };
466
+
467
+ ZoneAuditReport {
468
+ zone_id: data.zone.id.clone(),
469
+ zone_name: data.zone.name.clone(),
470
+ plan_name: data
471
+ .zone
472
+ .plan
473
+ .as_ref()
474
+ .and_then(|p| p.name.clone())
475
+ .unwrap_or_else(|| "Free Plan".to_string()),
476
+ status: data.zone.status.clone(),
477
+ score,
478
+ grade,
479
+ passed_checks_count: passed_checks,
480
+ total_checks_count: total_checks,
481
+ settings_summary,
482
+ findings,
483
+ }
484
+ }
@@ -0,0 +1,5 @@
1
+ pub mod definitions;
2
+ pub mod evaluator;
3
+
4
+ pub use definitions::{ALL_RULES, RuleDefinition, get_rule_by_id};
5
+ pub use evaluator::evaluate_zone;
@@ -0,0 +1,104 @@
1
+ use crate::zone::models::{AuditFinding, RiskLevel};
2
+
3
+ /// Baseline maximum score for a zone with 0 security findings
4
+ pub const BASELINE_SCORE: u32 = 100;
5
+
6
+ /// Calculates health score (0-100) for a zone given its security findings
7
+ pub fn calculate_zone_score(findings: &[AuditFinding]) -> u32 {
8
+ let mut total_penalty: u32 = 0;
9
+ let mut has_critical = false;
10
+
11
+ for finding in findings {
12
+ total_penalty = total_penalty.saturating_add(finding.score_penalty);
13
+ if finding.risk_level == RiskLevel::Critical {
14
+ has_critical = true;
15
+ }
16
+ }
17
+
18
+ let mut score = BASELINE_SCORE.saturating_sub(total_penalty);
19
+
20
+ // If any Critical risk exists (e.g. flexible SSL or 0.0.0.0/0 bypass),
21
+ // the zone cannot score higher than 49 (Failing grade)
22
+ if has_critical && score > 49 {
23
+ score = 49;
24
+ }
25
+
26
+ score.min(100)
27
+ }
28
+
29
+ /// Converts a numerical health score (0-100) to a letter grade
30
+ pub fn calculate_grade(score: u32) -> &'static str {
31
+ match score {
32
+ 95..=100 => "A+",
33
+ 90..=94 => "A",
34
+ 80..=89 => "B",
35
+ 70..=79 => "C",
36
+ 60..=69 => "D",
37
+ _ => "F",
38
+ }
39
+ }
40
+
41
+ /// Returns a human-friendly description of the security grade
42
+ pub fn grade_description(grade: &str) -> &'static str {
43
+ match grade {
44
+ "A+" => "Excellent Posture (Best-practice hardened)",
45
+ "A" => "Strong Posture (Compliant)",
46
+ "B" => "Good Posture (Minor enhancements recommended)",
47
+ "C" => "Moderate Risk (Several security gaps)",
48
+ "D" => "High Risk (Substantial vulnerabilities)",
49
+ "F" => "Critical Risk (Urgent remediation required)",
50
+ _ => "Unknown Posture",
51
+ }
52
+ }
53
+
54
+ #[cfg(test)]
55
+ mod tests {
56
+ use super::*;
57
+ use crate::zone::models::RuleCategory;
58
+
59
+ #[test]
60
+ fn test_perfect_score() {
61
+ let findings = vec![];
62
+ assert_eq!(calculate_zone_score(&findings), 100);
63
+ assert_eq!(calculate_grade(100), "A+");
64
+ }
65
+
66
+ #[test]
67
+ fn test_critical_finding_caps_score() {
68
+ let findings = vec![AuditFinding {
69
+ rule_id: "CF-SSL-001".to_string(),
70
+ rule_name: "SSLModeCheck".to_string(),
71
+ category: RuleCategory::SslTls,
72
+ risk_level: RiskLevel::Critical,
73
+ title: "Insecure SSL".to_string(),
74
+ description: "Flexible mode".to_string(),
75
+ actual_value: "flexible".to_string(),
76
+ expected_value: "strict".to_string(),
77
+ remediation: "Fix it".to_string(),
78
+ score_penalty: 30,
79
+ doc_url: None,
80
+ }];
81
+ let score = calculate_zone_score(&findings);
82
+ assert!(score <= 49);
83
+ assert_eq!(calculate_grade(score), "F");
84
+ }
85
+
86
+ #[test]
87
+ fn test_minor_deductions() {
88
+ let findings = vec![AuditFinding {
89
+ rule_id: "CF-TLS-002".to_string(),
90
+ rule_name: "Tls13Disabled".to_string(),
91
+ category: RuleCategory::SslTls,
92
+ risk_level: RiskLevel::Low,
93
+ title: "TLS 1.3 Disabled".to_string(),
94
+ description: "TLS 1.3 off".to_string(),
95
+ actual_value: "off".to_string(),
96
+ expected_value: "on".to_string(),
97
+ remediation: "Enable TLS 1.3".to_string(),
98
+ score_penalty: 5,
99
+ doc_url: None,
100
+ }];
101
+ assert_eq!(calculate_zone_score(&findings), 95);
102
+ assert_eq!(calculate_grade(95), "A+");
103
+ }
104
+ }