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,625 @@
1
+ use crate::origin::error::{HunterError, Result};
2
+ use crate::origin::models::{ConfidenceLevel, ScanReport};
3
+ use colored::Colorize;
4
+ use comfy_table::modifiers::UTF8_ROUND_CORNERS;
5
+ use comfy_table::presets::UTF8_FULL;
6
+ use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
7
+ use serde_json::json;
8
+
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10
+ pub enum OutputFormat {
11
+ Text,
12
+ Json,
13
+ Sarif,
14
+ Html,
15
+ }
16
+
17
+ impl std::str::FromStr for OutputFormat {
18
+ type Err = String;
19
+
20
+ fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
21
+ match s.to_lowercase().as_str() {
22
+ "text" | "term" | "terminal" | "table" => Ok(OutputFormat::Text),
23
+ "json" => Ok(OutputFormat::Json),
24
+ "sarif" => Ok(OutputFormat::Sarif),
25
+ "html" => Ok(OutputFormat::Html),
26
+ _ => Err(format!(
27
+ "Unsupported output format '{}'. Valid: text, json, sarif, html",
28
+ s
29
+ )),
30
+ }
31
+ }
32
+ }
33
+
34
+ /// Renders scan report in the requested output format
35
+ pub fn render_report(report: &ScanReport, format: OutputFormat) -> Result<String> {
36
+ match format {
37
+ OutputFormat::Text => Ok(render_text(report)),
38
+ OutputFormat::Json => Ok(render_json(report)?),
39
+ OutputFormat::Sarif => Ok(render_sarif(report)?),
40
+ OutputFormat::Html => Ok(render_html(report)),
41
+ }
42
+ }
43
+
44
+ /// Renders rich colored terminal report with comfy-table
45
+ pub fn render_text(report: &ScanReport) -> String {
46
+ let mut out = String::new();
47
+
48
+ out.push('\n');
49
+ out.push_str(
50
+ &"╔══════════════════════════════════════════════════════════════════════════════╗\n"
51
+ .bright_cyan()
52
+ .bold()
53
+ .to_string(),
54
+ );
55
+ out.push_str(
56
+ &"║ CLOUDFLARE ORIGIN HUNTER (v0.1.0) ║\n"
57
+ .bright_cyan()
58
+ .bold()
59
+ .to_string(),
60
+ );
61
+ out.push_str(
62
+ &"╚══════════════════════════════════════════════════════════════════════════════╝\n"
63
+ .bright_cyan()
64
+ .bold()
65
+ .to_string(),
66
+ );
67
+ out.push('\n');
68
+
69
+ out.push_str(&format!(
70
+ " 🎯 Target Domain: {}\n",
71
+ report.summary.target_domain.bright_yellow().bold()
72
+ ));
73
+ out.push_str(&format!(
74
+ " 🕒 Scanned At: {}\n",
75
+ report
76
+ .summary
77
+ .scanned_at
78
+ .format("%Y-%m-%d %H:%M:%S UTC")
79
+ .to_string()
80
+ .cyan()
81
+ ));
82
+ out.push_str(&format!(
83
+ " ⏱️ Scan Duration: {:.2}s\n",
84
+ report.summary.duration_seconds
85
+ ));
86
+
87
+ let cf_status = if report.summary.is_behind_cloudflare {
88
+ "PROXIED BEHIND CLOUDFLARE".bright_green().bold()
89
+ } else {
90
+ "DIRECT / NOT CLOUDFLARE PROXIED".bright_red().bold()
91
+ };
92
+ out.push_str(&format!(" 🛡️ Cloudflare Edge: {}\n", cf_status));
93
+
94
+ let edge_ips_str = report
95
+ .summary
96
+ .cloudflare_edge_ips
97
+ .iter()
98
+ .map(|ip| ip.to_string())
99
+ .collect::<Vec<_>>()
100
+ .join(", ");
101
+ out.push_str(&format!(
102
+ " 🌐 Cloudflare Edge IPs: {}\n",
103
+ if edge_ips_str.is_empty() {
104
+ "None".dimmed().to_string()
105
+ } else {
106
+ edge_ips_str.cyan().to_string()
107
+ }
108
+ ));
109
+
110
+ if let Some(ref title) = report.baseline.html_title {
111
+ out.push_str(&format!(" 📄 Baseline Title: {}\n", title.dimmed()));
112
+ }
113
+ if let Some(ref hash) = report.baseline.body_sha256 {
114
+ out.push_str(&format!(
115
+ " 🔑 Baseline SHA-256: {}\n",
116
+ hash[..16].dimmed()
117
+ ));
118
+ }
119
+
120
+ out.push('\n');
121
+ out.push_str(
122
+ &"─── CANDIDATE ORIGIN IP FINDINGS ───────────────────────────────────────────────\n"
123
+ .bright_white()
124
+ .bold()
125
+ .to_string(),
126
+ );
127
+
128
+ if report.findings.is_empty() {
129
+ out.push_str(&format!(
130
+ " {}\n\n",
131
+ "✅ No unmasked origin IP addresses detected. Target is well-protected."
132
+ .bright_green()
133
+ .bold()
134
+ ));
135
+ } else {
136
+ let mut table = Table::new();
137
+ table.load_preset(UTF8_FULL);
138
+ table.apply_modifier(UTF8_ROUND_CORNERS);
139
+ table.set_content_arrangement(ContentArrangement::Dynamic);
140
+
141
+ table.set_header(vec![
142
+ Cell::new("IP Address").add_attribute(Attribute::Bold),
143
+ Cell::new("Confidence").add_attribute(Attribute::Bold),
144
+ Cell::new("Score").add_attribute(Attribute::Bold),
145
+ Cell::new("Source").add_attribute(Attribute::Bold),
146
+ Cell::new("Direct Server").add_attribute(Attribute::Bold),
147
+ Cell::new("Analysis / Reason").add_attribute(Attribute::Bold),
148
+ ]);
149
+
150
+ for finding in &report.findings {
151
+ let conf_cell = match finding.confidence {
152
+ ConfidenceLevel::Confirmed => Cell::new("CONFIRMED")
153
+ .fg(Color::Red)
154
+ .add_attribute(Attribute::Bold),
155
+ ConfidenceLevel::High => Cell::new("HIGH")
156
+ .fg(Color::Yellow)
157
+ .add_attribute(Attribute::Bold),
158
+ ConfidenceLevel::Medium => Cell::new("MEDIUM").fg(Color::Cyan),
159
+ ConfidenceLevel::Low => Cell::new("LOW").fg(Color::DarkGrey),
160
+ };
161
+
162
+ let score_str = format!("{}%", finding.confidence_score);
163
+ let score_cell = match finding.confidence {
164
+ ConfidenceLevel::Confirmed => Cell::new(&score_str)
165
+ .fg(Color::Red)
166
+ .add_attribute(Attribute::Bold),
167
+ ConfidenceLevel::High => Cell::new(&score_str).fg(Color::Yellow),
168
+ _ => Cell::new(&score_str),
169
+ };
170
+
171
+ let server_name = finding
172
+ .successful_probes
173
+ .first()
174
+ .and_then(|p| p.server_header.as_deref())
175
+ .unwrap_or("N/A");
176
+
177
+ let source_str = finding.discovery_source.to_string();
178
+
179
+ table.add_row(vec![
180
+ Cell::new(finding.candidate_ip.to_string()).add_attribute(Attribute::Bold),
181
+ conf_cell,
182
+ score_cell,
183
+ Cell::new(source_str),
184
+ Cell::new(server_name),
185
+ Cell::new(&finding.confidence_reason),
186
+ ]);
187
+ }
188
+
189
+ out.push_str(&table.to_string());
190
+ out.push_str("\n\n");
191
+
192
+ // Risk Summary Stats
193
+ out.push_str(&format!(
194
+ " 📊 Origin Leak Summary: {} Confirmed, {} High, {} Medium, {} Low\n\n",
195
+ report
196
+ .summary
197
+ .origins_confirmed
198
+ .to_string()
199
+ .bright_red()
200
+ .bold(),
201
+ report
202
+ .summary
203
+ .high_confidence_origins
204
+ .to_string()
205
+ .bright_yellow()
206
+ .bold(),
207
+ report
208
+ .summary
209
+ .medium_confidence_origins
210
+ .to_string()
211
+ .bright_cyan(),
212
+ report.summary.low_confidence_origins.to_string().dimmed()
213
+ ));
214
+ }
215
+
216
+ // Remediation Plan Section
217
+ if report.summary.is_origin_leaked {
218
+ out.push_str(
219
+ &"─── ACTIONABLE REMEDIATION STEPS ──────────────────────────────────────────────\n"
220
+ .bright_red()
221
+ .bold()
222
+ .to_string(),
223
+ );
224
+ for step in &report.remediation {
225
+ out.push_str(&format!(
226
+ "\n [{}] {} ({})\n",
227
+ step.id.bold().yellow(),
228
+ step.title.bold().white(),
229
+ step.priority.bright_red()
230
+ ));
231
+ out.push_str(&format!(" 📝 {}\n", step.description));
232
+ if !step.commands.is_empty() {
233
+ out.push_str(" 💻 Commands:\n");
234
+ for cmd in &step.commands {
235
+ out.push_str(&format!(" {}\n", cmd.bright_cyan()));
236
+ }
237
+ }
238
+ out.push_str(&format!(" 🔗 Docs: {}\n", step.doc_url.dimmed()));
239
+ }
240
+ out.push('\n');
241
+ }
242
+
243
+ out
244
+ }
245
+
246
+ /// Renders JSON format
247
+ pub fn render_json(report: &ScanReport) -> Result<String> {
248
+ serde_json::to_string_pretty(report).map_err(HunterError::from)
249
+ }
250
+
251
+ /// Renders OASIS SARIF v2.1.0 format
252
+ pub fn render_sarif(report: &ScanReport) -> Result<String> {
253
+ let mut results = Vec::new();
254
+
255
+ for finding in &report.findings {
256
+ let (rule_id, level, title) = match finding.confidence {
257
+ ConfidenceLevel::Confirmed => (
258
+ "CF-ORIGIN-LEAK-CONFIRMED",
259
+ "error",
260
+ "Cloudflare Origin IP Directly Exposed (Confirmed)",
261
+ ),
262
+ ConfidenceLevel::High => (
263
+ "CF-ORIGIN-LEAK-HIGH",
264
+ "error",
265
+ "Cloudflare Origin IP Likely Exposed (High Confidence)",
266
+ ),
267
+ ConfidenceLevel::Medium => (
268
+ "CF-ORIGIN-LEAK-MEDIUM",
269
+ "warning",
270
+ "Potential Cloudflare Origin IP Exposed (Medium Confidence)",
271
+ ),
272
+ ConfidenceLevel::Low => (
273
+ "CF-ORIGIN-LEAK-LOW",
274
+ "note",
275
+ "Unverified Candidate Origin IP (Low Confidence)",
276
+ ),
277
+ };
278
+
279
+ let result_obj = json!({
280
+ "ruleId": rule_id,
281
+ "level": level,
282
+ "message": {
283
+ "text": format!(
284
+ "{}: IP {} discovered via {}. {}",
285
+ title, finding.candidate_ip, finding.discovery_source, finding.confidence_reason
286
+ )
287
+ },
288
+ "properties": {
289
+ "candidateIp": finding.candidate_ip.to_string(),
290
+ "confidenceLevel": finding.confidence.to_string(),
291
+ "confidenceScore": finding.confidence_score,
292
+ "hostname": finding.hostname,
293
+ "discoverySource": finding.discovery_source.to_string()
294
+ }
295
+ });
296
+
297
+ results.push(result_obj);
298
+ }
299
+
300
+ let sarif_obj = json!({
301
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
302
+ "version": "2.1.0",
303
+ "runs": [
304
+ {
305
+ "tool": {
306
+ "driver": {
307
+ "name": "cf-origin-hunter",
308
+ "version": "0.1.0",
309
+ "informationUri": "https://github.com/cloudflare-security/cf-origin-hunter",
310
+ "rules": [
311
+ {
312
+ "id": "CF-ORIGIN-LEAK-CONFIRMED",
313
+ "shortDescription": { "text": "Cloudflare Origin IP Directly Exposed (Confirmed)" },
314
+ "defaultConfiguration": { "level": "error" }
315
+ },
316
+ {
317
+ "id": "CF-ORIGIN-LEAK-HIGH",
318
+ "shortDescription": { "text": "Cloudflare Origin IP Likely Exposed (High Confidence)" },
319
+ "defaultConfiguration": { "level": "error" }
320
+ },
321
+ {
322
+ "id": "CF-ORIGIN-LEAK-MEDIUM",
323
+ "shortDescription": { "text": "Potential Cloudflare Origin IP Exposed (Medium Confidence)" },
324
+ "defaultConfiguration": { "level": "warning" }
325
+ },
326
+ {
327
+ "id": "CF-ORIGIN-LEAK-LOW",
328
+ "shortDescription": { "text": "Unverified Candidate Origin IP (Low Confidence)" },
329
+ "defaultConfiguration": { "level": "note" }
330
+ }
331
+ ]
332
+ }
333
+ },
334
+ "results": results
335
+ }
336
+ ]
337
+ });
338
+
339
+ serde_json::to_string_pretty(&sarif_obj).map_err(HunterError::from)
340
+ }
341
+
342
+ /// Renders a modern HTML report
343
+ pub fn render_html(report: &ScanReport) -> String {
344
+ let mut rows = String::new();
345
+
346
+ for finding in &report.findings {
347
+ let (badge_class, badge_label) = match finding.confidence {
348
+ ConfidenceLevel::Confirmed => ("badge-confirmed", "CONFIRMED 100%"),
349
+ ConfidenceLevel::High => ("badge-high", "HIGH"),
350
+ ConfidenceLevel::Medium => ("badge-medium", "MEDIUM"),
351
+ ConfidenceLevel::Low => ("badge-low", "LOW"),
352
+ };
353
+
354
+ let server_name = finding
355
+ .successful_probes
356
+ .first()
357
+ .and_then(|p| p.server_header.as_deref())
358
+ .unwrap_or("N/A");
359
+
360
+ rows.push_str(&format!(
361
+ r#"<tr>
362
+ <td><code>{}</code></td>
363
+ <td><span class="badge {}">{}</span></td>
364
+ <td><strong>{}%</strong></td>
365
+ <td>{}</td>
366
+ <td><code>{}</code></td>
367
+ <td>{}</td>
368
+ </tr>"#,
369
+ finding.candidate_ip,
370
+ badge_class,
371
+ badge_label,
372
+ finding.confidence_score,
373
+ finding.discovery_source,
374
+ server_name,
375
+ finding.confidence_reason
376
+ ));
377
+ }
378
+
379
+ let mut rem_cards = String::new();
380
+ for step in &report.remediation {
381
+ let mut cmds = String::new();
382
+ for cmd in &step.commands {
383
+ cmds.push_str(&format!("<code>{}</code>\n", cmd));
384
+ }
385
+
386
+ rem_cards.push_str(&format!(
387
+ r#"<div class="rem-card">
388
+ <div class="rem-header">
389
+ <span class="rem-id">{}</span>
390
+ <h3>{}</h3>
391
+ <span class="badge-priority">{}</span>
392
+ </div>
393
+ <p>{}</p>
394
+ <pre>{}</pre>
395
+ <a href="{}" target="_blank" rel="noreferrer">Cloudflare Documentation &rarr;</a>
396
+ </div>"#,
397
+ step.id, step.title, step.priority, step.description, cmds, step.doc_url
398
+ ));
399
+ }
400
+
401
+ format!(
402
+ r#"<!DOCTYPE html>
403
+ <html lang="en">
404
+ <head>
405
+ <meta charset="UTF-8">
406
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
407
+ <title>Cloudflare Origin Hunter Audit - {}</title>
408
+ <style>
409
+ :root {{
410
+ --bg: #0f172a;
411
+ --card-bg: #1e293b;
412
+ --text: #f8fafc;
413
+ --text-muted: #94a3b8;
414
+ --accent: #f97316;
415
+ --border: #334155;
416
+ --red: #ef4444;
417
+ --yellow: #f59e0b;
418
+ --cyan: #06b6d4;
419
+ --green: #10b981;
420
+ }}
421
+ body {{
422
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
423
+ background: var(--bg);
424
+ color: var(--text);
425
+ margin: 0;
426
+ padding: 2rem;
427
+ line-height: 1.5;
428
+ }}
429
+ .container {{
430
+ max-width: 1200px;
431
+ margin: 0 auto;
432
+ }}
433
+ header {{
434
+ background: var(--card-bg);
435
+ border: 1px solid var(--border);
436
+ border-radius: 12px;
437
+ padding: 1.5rem;
438
+ margin-bottom: 2rem;
439
+ }}
440
+ h1 {{
441
+ margin: 0 0 0.5rem 0;
442
+ color: var(--accent);
443
+ }}
444
+ .grid {{
445
+ display: grid;
446
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
447
+ gap: 1rem;
448
+ margin-top: 1rem;
449
+ }}
450
+ .stat-card {{
451
+ background: #0f172a;
452
+ padding: 1rem;
453
+ border-radius: 8px;
454
+ border: 1px solid var(--border);
455
+ }}
456
+ .stat-val {{
457
+ font-size: 1.8rem;
458
+ font-weight: bold;
459
+ }}
460
+ table {{
461
+ width: 100%;
462
+ border-collapse: collapse;
463
+ background: var(--card-bg);
464
+ border-radius: 12px;
465
+ overflow: hidden;
466
+ border: 1px solid var(--border);
467
+ margin-bottom: 2rem;
468
+ }}
469
+ th, td {{
470
+ padding: 0.8rem 1rem;
471
+ text-align: left;
472
+ border-bottom: 1px solid var(--border);
473
+ }}
474
+ th {{
475
+ background: #0f172a;
476
+ color: var(--text-muted);
477
+ font-weight: 600;
478
+ }}
479
+ .badge {{
480
+ padding: 0.25rem 0.5rem;
481
+ border-radius: 4px;
482
+ font-size: 0.75rem;
483
+ font-weight: bold;
484
+ display: inline-block;
485
+ }}
486
+ .badge-confirmed {{ background: #991b1b; color: #fee2e2; }}
487
+ .badge-high {{ background: #854d0e; color: #fef9c3; }}
488
+ .badge-medium {{ background: #155e75; color: #cffafe; }}
489
+ .badge-low {{ background: #374151; color: #e5e7eb; }}
490
+ .rem-card {{
491
+ background: var(--card-bg);
492
+ border: 1px solid var(--border);
493
+ border-radius: 12px;
494
+ padding: 1.25rem;
495
+ margin-bottom: 1rem;
496
+ }}
497
+ .rem-header {{
498
+ display: flex;
499
+ align-items: center;
500
+ gap: 0.75rem;
501
+ }}
502
+ .rem-id {{
503
+ background: var(--accent);
504
+ color: #000;
505
+ font-weight: bold;
506
+ padding: 0.2rem 0.5rem;
507
+ border-radius: 4px;
508
+ }}
509
+ pre {{
510
+ background: #0f172a;
511
+ padding: 1rem;
512
+ border-radius: 8px;
513
+ overflow-x: auto;
514
+ border: 1px solid var(--border);
515
+ }}
516
+ code {{
517
+ font-family: monospace;
518
+ color: #38bdf8;
519
+ }}
520
+ a {{
521
+ color: var(--accent);
522
+ text-decoration: none;
523
+ }}
524
+ </style>
525
+ </head>
526
+ <body>
527
+ <div class="container">
528
+ <header>
529
+ <h1>🛡️ Cloudflare Origin Hunter Audit</h1>
530
+ <p>Target: <strong>{}</strong> | Generated at: {}</p>
531
+ <div class="grid">
532
+ <div class="stat-card">
533
+ <div style="color: var(--text-muted);">Confirmed Origins</div>
534
+ <div class="stat-val" style="color: var(--red);">{}</div>
535
+ </div>
536
+ <div class="stat-card">
537
+ <div style="color: var(--text-muted);">High Confidence</div>
538
+ <div class="stat-val" style="color: var(--yellow);">{}</div>
539
+ </div>
540
+ <div class="stat-card">
541
+ <div style="color: var(--text-muted);">Medium Candidates</div>
542
+ <div class="stat-val" style="color: var(--cyan);">{}</div>
543
+ </div>
544
+ <div class="stat-card">
545
+ <div style="color: var(--text-muted);">Proxy Status</div>
546
+ <div class="stat-val" style="color: var(--green);">{}</div>
547
+ </div>
548
+ </div>
549
+ </header>
550
+
551
+ <h2>Discovered Candidate Findings</h2>
552
+ <table>
553
+ <thead>
554
+ <tr>
555
+ <th>IP Address</th>
556
+ <th>Confidence</th>
557
+ <th>Score</th>
558
+ <th>Source</th>
559
+ <th>Direct Server</th>
560
+ <th>Reason</th>
561
+ </tr>
562
+ </thead>
563
+ <tbody>
564
+ {}
565
+ </tbody>
566
+ </table>
567
+
568
+ <h2>Actionable Mitigation Plan</h2>
569
+ {}
570
+ </div>
571
+ </body>
572
+ </html>"#,
573
+ report.summary.target_domain,
574
+ report.summary.target_domain,
575
+ report.summary.scanned_at.format("%Y-%m-%d %H:%M:%S UTC"),
576
+ report.summary.origins_confirmed,
577
+ report.summary.high_confidence_origins,
578
+ report.summary.medium_confidence_origins,
579
+ if report.summary.is_behind_cloudflare {
580
+ "Cloudflare Proxied"
581
+ } else {
582
+ "Direct"
583
+ },
584
+ rows,
585
+ rem_cards
586
+ )
587
+ }
588
+
589
+ #[cfg(test)]
590
+ mod tests {
591
+ use super::*;
592
+ use crate::origin::mock::run_mock_scan;
593
+
594
+ #[test]
595
+ fn test_render_text() {
596
+ let report = run_mock_scan("example.com");
597
+ let txt = render_text(&report);
598
+ assert!(txt.contains("CLOUDFLARE ORIGIN HUNTER"));
599
+ assert!(txt.contains("198.51.100.42"));
600
+ assert!(txt.contains("CONFIRMED"));
601
+ }
602
+
603
+ #[test]
604
+ fn test_render_json() {
605
+ let report = run_mock_scan("example.com");
606
+ let json_str = render_json(&report).unwrap();
607
+ assert!(json_str.contains("\"target_domain\": \"example.com\""));
608
+ }
609
+
610
+ #[test]
611
+ fn test_render_sarif() {
612
+ let report = run_mock_scan("example.com");
613
+ let sarif_str = render_sarif(&report).unwrap();
614
+ assert!(sarif_str.contains("CF-ORIGIN-LEAK-CONFIRMED"));
615
+ assert!(sarif_str.contains("sarif-schema-2.1.0.json"));
616
+ }
617
+
618
+ #[test]
619
+ fn test_render_html() {
620
+ let report = run_mock_scan("example.com");
621
+ let html_str = render_html(&report);
622
+ assert!(html_str.contains("<html"));
623
+ assert!(html_str.contains("198.51.100.42"));
624
+ }
625
+ }