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.
- package/Cargo.lock +2962 -0
- package/Cargo.toml +66 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/bin/flareguard.js +78 -0
- package/package.json +50 -0
- package/scripts/postinstall.mjs +83 -0
- package/src/bindings/ast_scanner.rs +950 -0
- package/src/bindings/cli.rs +49 -0
- package/src/bindings/jsonc.rs +166 -0
- package/src/bindings/mod.rs +7 -0
- package/src/bindings/reporter.rs +328 -0
- package/src/bindings/types.rs +129 -0
- package/src/bindings/validator.rs +227 -0
- package/src/bindings/wrangler.rs +647 -0
- package/src/cli.rs +77 -0
- package/src/lib.rs +7 -0
- package/src/main.rs +451 -0
- package/src/origin/cli.rs +92 -0
- package/src/origin/cloudflare.rs +170 -0
- package/src/origin/confidence.rs +320 -0
- package/src/origin/crtsh.rs +144 -0
- package/src/origin/dns.rs +177 -0
- package/src/origin/enumerator.rs +271 -0
- package/src/origin/error.rs +19 -0
- package/src/origin/mock.rs +320 -0
- package/src/origin/mod.rs +19 -0
- package/src/origin/models.rs +167 -0
- package/src/origin/prober.rs +260 -0
- package/src/origin/remediation.rs +73 -0
- package/src/origin/report.rs +625 -0
- package/src/origin/scanner.rs +217 -0
- package/src/secrets/cli.rs +94 -0
- package/src/secrets/env_parser.rs +464 -0
- package/src/secrets/ignore.rs +139 -0
- package/src/secrets/mod.rs +14 -0
- package/src/secrets/report/json_format.rs +97 -0
- package/src/secrets/report/mod.rs +48 -0
- package/src/secrets/report/sarif.rs +225 -0
- package/src/secrets/report/text.rs +105 -0
- package/src/secrets/rules/builtin.rs +280 -0
- package/src/secrets/rules/entropy.rs +66 -0
- package/src/secrets/rules/mod.rs +7 -0
- package/src/secrets/rules/types.rs +183 -0
- package/src/secrets/scanner.rs +444 -0
- package/src/zone/cli.rs +111 -0
- package/src/zone/client/cf_client.rs +405 -0
- package/src/zone/client/mod.rs +5 -0
- package/src/zone/client/provider.rs +12 -0
- package/src/zone/mock_data.rs +481 -0
- package/src/zone/mod.rs +125 -0
- package/src/zone/models/audit.rs +194 -0
- package/src/zone/models/cloudflare.rs +252 -0
- package/src/zone/models/mod.rs +7 -0
- package/src/zone/models/sarif.rs +89 -0
- package/src/zone/reporters/html_rep.rs +345 -0
- package/src/zone/reporters/json_rep.rs +7 -0
- package/src/zone/reporters/mod.rs +9 -0
- package/src/zone/reporters/sarif_rep.rs +100 -0
- package/src/zone/reporters/terminal.rs +322 -0
- package/src/zone/rules/definitions.rs +201 -0
- package/src/zone/rules/evaluator.rs +484 -0
- package/src/zone/rules/mod.rs +5 -0
- package/src/zone/scoring.rs +104 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
use clap::{Parser, ValueEnum};
|
|
2
|
+
use std::path::PathBuf;
|
|
3
|
+
|
|
4
|
+
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
5
|
+
pub enum CliFormat {
|
|
6
|
+
Text,
|
|
7
|
+
Json,
|
|
8
|
+
Sarif,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
#[derive(Parser, Debug)]
|
|
12
|
+
#[command(
|
|
13
|
+
name = "cf-binding-validator",
|
|
14
|
+
about = "⚡ Validate Cloudflare Worker/Pages bindings against Wrangler configuration",
|
|
15
|
+
version
|
|
16
|
+
)]
|
|
17
|
+
pub struct CliArgs {
|
|
18
|
+
/// Target files or directories to scan (defaults to current directory)
|
|
19
|
+
#[arg(default_value = ".")]
|
|
20
|
+
pub paths: Vec<PathBuf>,
|
|
21
|
+
|
|
22
|
+
/// Path to wrangler configuration (wrangler.jsonc, wrangler.json, or wrangler.toml)
|
|
23
|
+
#[arg(short = 'c', long = "config")]
|
|
24
|
+
pub config: Option<PathBuf>,
|
|
25
|
+
|
|
26
|
+
/// Wrangler environment to validate against (e.g. 'production', 'staging')
|
|
27
|
+
#[arg(short = 'e', long = "env")]
|
|
28
|
+
pub environment: Option<String>,
|
|
29
|
+
|
|
30
|
+
/// Check mode for CI/CD: exits with non-zero code if undeclared bindings exist
|
|
31
|
+
#[arg(long = "check")]
|
|
32
|
+
pub check: bool,
|
|
33
|
+
|
|
34
|
+
/// Strict mode: also fail CI if unused/ghost bindings exist
|
|
35
|
+
#[arg(long = "strict")]
|
|
36
|
+
pub strict: bool,
|
|
37
|
+
|
|
38
|
+
/// Output report format
|
|
39
|
+
#[arg(short = 'f', long = "format", value_enum, default_value_t = CliFormat::Text)]
|
|
40
|
+
pub format: CliFormat,
|
|
41
|
+
|
|
42
|
+
/// Comma-separated binding names to ignore if unused
|
|
43
|
+
#[arg(long = "ignore-unused", value_delimiter = ',')]
|
|
44
|
+
pub ignore_unused: Vec<String>,
|
|
45
|
+
|
|
46
|
+
/// Comma-separated binding names to ignore if undeclared
|
|
47
|
+
#[arg(long = "ignore-undeclared", value_delimiter = ',')]
|
|
48
|
+
pub ignore_undeclared: Vec<String>,
|
|
49
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
use std::error::Error;
|
|
2
|
+
|
|
3
|
+
/// Strips JavaScript-style comments (`//` and `/* ... */`) and trailing commas from JSONC text,
|
|
4
|
+
/// producing valid standard JSON.
|
|
5
|
+
pub fn clean_jsonc(input: &str) -> String {
|
|
6
|
+
let chars: Vec<char> = input.chars().collect();
|
|
7
|
+
let len = chars.len();
|
|
8
|
+
let mut output = String::with_capacity(len);
|
|
9
|
+
let mut i = 0;
|
|
10
|
+
let mut in_string = false;
|
|
11
|
+
let mut is_escaped = false;
|
|
12
|
+
|
|
13
|
+
// Step 1: Strip comments while respecting strings
|
|
14
|
+
let mut no_comments = String::with_capacity(len);
|
|
15
|
+
while i < len {
|
|
16
|
+
let ch = chars[i];
|
|
17
|
+
|
|
18
|
+
if in_string {
|
|
19
|
+
no_comments.push(ch);
|
|
20
|
+
if is_escaped {
|
|
21
|
+
is_escaped = false;
|
|
22
|
+
} else if ch == '\\' {
|
|
23
|
+
is_escaped = true;
|
|
24
|
+
} else if ch == '"' {
|
|
25
|
+
in_string = false;
|
|
26
|
+
}
|
|
27
|
+
i += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if ch == '"' {
|
|
32
|
+
in_string = true;
|
|
33
|
+
is_escaped = false;
|
|
34
|
+
no_comments.push(ch);
|
|
35
|
+
i += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check for line comment //
|
|
40
|
+
if ch == '/' && i + 1 < len && chars[i + 1] == '/' {
|
|
41
|
+
i += 2;
|
|
42
|
+
while i < len && chars[i] != '\n' {
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
if i < len {
|
|
46
|
+
no_comments.push('\n');
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check for block comment /* ... */
|
|
53
|
+
if ch == '/' && i + 1 < len && chars[i + 1] == '*' {
|
|
54
|
+
i += 2;
|
|
55
|
+
while i + 1 < len && !(chars[i] == '*' && chars[i + 1] == '/') {
|
|
56
|
+
if chars[i] == '\n' {
|
|
57
|
+
no_comments.push('\n'); // Preserve line breaks for line numbering
|
|
58
|
+
}
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
i += 2; // skip */
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
no_comments.push(ch);
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Step 2: Strip trailing commas in objects and arrays
|
|
70
|
+
let nc_chars: Vec<char> = no_comments.chars().collect();
|
|
71
|
+
let nc_len = nc_chars.len();
|
|
72
|
+
let mut j = 0;
|
|
73
|
+
let mut in_str = false;
|
|
74
|
+
let mut esc = false;
|
|
75
|
+
|
|
76
|
+
while j < nc_len {
|
|
77
|
+
let ch = nc_chars[j];
|
|
78
|
+
|
|
79
|
+
if in_str {
|
|
80
|
+
output.push(ch);
|
|
81
|
+
if esc {
|
|
82
|
+
esc = false;
|
|
83
|
+
} else if ch == '\\' {
|
|
84
|
+
esc = true;
|
|
85
|
+
} else if ch == '"' {
|
|
86
|
+
in_str = false;
|
|
87
|
+
}
|
|
88
|
+
j += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if ch == '"' {
|
|
93
|
+
in_str = true;
|
|
94
|
+
esc = false;
|
|
95
|
+
output.push(ch);
|
|
96
|
+
j += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if ch == ',' {
|
|
101
|
+
// Peek ahead to see if the next non-whitespace character is `}` or `]`
|
|
102
|
+
let mut k = j + 1;
|
|
103
|
+
while k < nc_len && nc_chars[k].is_whitespace() {
|
|
104
|
+
k += 1;
|
|
105
|
+
}
|
|
106
|
+
if k < nc_len && (nc_chars[k] == '}' || nc_chars[k] == ']') {
|
|
107
|
+
// Skip the trailing comma
|
|
108
|
+
j += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
output.push(ch);
|
|
114
|
+
j += 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
output
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Parses JSONC or JSON text into a `serde_json::Value`.
|
|
121
|
+
pub fn parse_jsonc(input: &str) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
|
|
122
|
+
let cleaned = clean_jsonc(input);
|
|
123
|
+
let val: serde_json::Value = serde_json::from_str(&cleaned)?;
|
|
124
|
+
Ok(val)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#[cfg(test)]
|
|
128
|
+
mod tests {
|
|
129
|
+
use super::*;
|
|
130
|
+
|
|
131
|
+
#[test]
|
|
132
|
+
fn test_clean_jsonc_basic() {
|
|
133
|
+
let jsonc = r#"
|
|
134
|
+
{
|
|
135
|
+
// This is a line comment
|
|
136
|
+
"name": "my-worker", /* inline comment */
|
|
137
|
+
"vars": {
|
|
138
|
+
"API_KEY": "https://api.example.com//test", // URL with slashes
|
|
139
|
+
"DEBUG": true,
|
|
140
|
+
},
|
|
141
|
+
"kv_namespaces": [
|
|
142
|
+
{
|
|
143
|
+
"binding": "MY_KV",
|
|
144
|
+
"id": "12345",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
"#;
|
|
149
|
+
let cleaned = clean_jsonc(jsonc);
|
|
150
|
+
let parsed: serde_json::Value =
|
|
151
|
+
serde_json::from_str(&cleaned).expect("Failed to parse cleaned JSON");
|
|
152
|
+
assert_eq!(parsed["name"], "my-worker");
|
|
153
|
+
assert_eq!(parsed["vars"]["API_KEY"], "https://api.example.com//test");
|
|
154
|
+
assert_eq!(parsed["vars"]["DEBUG"], true);
|
|
155
|
+
assert_eq!(parsed["kv_namespaces"][0]["binding"], "MY_KV");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#[test]
|
|
159
|
+
fn test_clean_jsonc_nested_trailing_commas() {
|
|
160
|
+
let jsonc = r#"{"a": [1, 2, [3, 4,],], "b": {"c": 1,},}"#;
|
|
161
|
+
let cleaned = clean_jsonc(jsonc);
|
|
162
|
+
let parsed: serde_json::Value = serde_json::from_str(&cleaned).unwrap();
|
|
163
|
+
assert_eq!(parsed["a"][2][0], 3);
|
|
164
|
+
assert_eq!(parsed["b"]["c"], 1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
use crate::bindings::types::{OutputFormat, ValidationReport};
|
|
2
|
+
use colored::Colorize;
|
|
3
|
+
use serde_json::json;
|
|
4
|
+
use std::io::{self, Write};
|
|
5
|
+
|
|
6
|
+
/// Render validation report according to requested OutputFormat.
|
|
7
|
+
pub fn render_report(report: &ValidationReport, format: OutputFormat) -> io::Result<()> {
|
|
8
|
+
match format {
|
|
9
|
+
OutputFormat::Text => render_text_report(report),
|
|
10
|
+
OutputFormat::Json => render_json_report(report),
|
|
11
|
+
OutputFormat::Sarif => render_sarif_report(report),
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Render rich human-readable terminal text output.
|
|
16
|
+
pub fn render_text_report(report: &ValidationReport) -> io::Result<()> {
|
|
17
|
+
let mut stdout = io::stdout().lock();
|
|
18
|
+
|
|
19
|
+
writeln!(
|
|
20
|
+
stdout,
|
|
21
|
+
"{}",
|
|
22
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
|
|
23
|
+
)?;
|
|
24
|
+
writeln!(
|
|
25
|
+
stdout,
|
|
26
|
+
"{}",
|
|
27
|
+
" ⚡ Cloudflare Worker / Pages Binding Validator"
|
|
28
|
+
.bold()
|
|
29
|
+
.cyan()
|
|
30
|
+
)?;
|
|
31
|
+
writeln!(
|
|
32
|
+
stdout,
|
|
33
|
+
"{}",
|
|
34
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed()
|
|
35
|
+
)?;
|
|
36
|
+
|
|
37
|
+
if let Some(cfg) = &report.config_file {
|
|
38
|
+
writeln!(stdout, " {} {}", "Config:".bold(), cfg.white())?;
|
|
39
|
+
} else {
|
|
40
|
+
writeln!(
|
|
41
|
+
stdout,
|
|
42
|
+
" {} {}",
|
|
43
|
+
"Config:".bold(),
|
|
44
|
+
"None found (all bindings treated as undeclared)".yellow()
|
|
45
|
+
)?;
|
|
46
|
+
}
|
|
47
|
+
writeln!(
|
|
48
|
+
stdout,
|
|
49
|
+
" {} {}",
|
|
50
|
+
"Environment:".bold(),
|
|
51
|
+
report.environment.magenta()
|
|
52
|
+
)?;
|
|
53
|
+
writeln!(
|
|
54
|
+
stdout,
|
|
55
|
+
" {} {}",
|
|
56
|
+
"Files Scanned:".bold(),
|
|
57
|
+
report.total_files_scanned.to_string().white()
|
|
58
|
+
)?;
|
|
59
|
+
writeln!(stdout)?;
|
|
60
|
+
|
|
61
|
+
// 1. Undeclared Bindings (Errors)
|
|
62
|
+
if !report.undeclared_accesses.is_empty() {
|
|
63
|
+
writeln!(
|
|
64
|
+
stdout,
|
|
65
|
+
"{}",
|
|
66
|
+
format!(
|
|
67
|
+
" CRITICAL / ERROR: Undeclared Bindings ({} found)",
|
|
68
|
+
report.undeclared_accesses.len()
|
|
69
|
+
)
|
|
70
|
+
.bold()
|
|
71
|
+
.red()
|
|
72
|
+
)?;
|
|
73
|
+
writeln!(
|
|
74
|
+
stdout,
|
|
75
|
+
" {}",
|
|
76
|
+
"These bindings are accessed in code but NOT declared in Wrangler config."
|
|
77
|
+
.red()
|
|
78
|
+
.italic()
|
|
79
|
+
)?;
|
|
80
|
+
writeln!(
|
|
81
|
+
stdout,
|
|
82
|
+
" {}",
|
|
83
|
+
"Risk: Runtime TypeError / crash when accessing undefined property at runtime."
|
|
84
|
+
.red()
|
|
85
|
+
.italic()
|
|
86
|
+
)?;
|
|
87
|
+
writeln!(stdout)?;
|
|
88
|
+
|
|
89
|
+
for access in &report.undeclared_accesses {
|
|
90
|
+
let loc = format!("{}:{}:{}", access.file_path, access.line, access.column);
|
|
91
|
+
writeln!(
|
|
92
|
+
stdout,
|
|
93
|
+
" {} {} in {}",
|
|
94
|
+
"✖".bold().red(),
|
|
95
|
+
access.name.bold().bright_red(),
|
|
96
|
+
loc.underline().dimmed()
|
|
97
|
+
)?;
|
|
98
|
+
writeln!(
|
|
99
|
+
stdout,
|
|
100
|
+
" {} `{}`",
|
|
101
|
+
"Expression:".dimmed(),
|
|
102
|
+
access.raw_expression.bright_yellow()
|
|
103
|
+
)?;
|
|
104
|
+
writeln!(
|
|
105
|
+
stdout,
|
|
106
|
+
" {} Declare `{}` in your wrangler configuration (e.g. `vars`, `kv_namespaces`, `d1_databases`)",
|
|
107
|
+
"Fix Hint:".bold().cyan(),
|
|
108
|
+
access.name.bright_white()
|
|
109
|
+
)?;
|
|
110
|
+
writeln!(stdout)?;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Ghost / Unused Bindings (Warnings)
|
|
115
|
+
if !report.ghost_bindings.is_empty() {
|
|
116
|
+
writeln!(
|
|
117
|
+
stdout,
|
|
118
|
+
"{}",
|
|
119
|
+
format!(
|
|
120
|
+
" WARNING: Ghost / Unused Bindings ({} found)",
|
|
121
|
+
report.ghost_bindings.len()
|
|
122
|
+
)
|
|
123
|
+
.bold()
|
|
124
|
+
.yellow()
|
|
125
|
+
)?;
|
|
126
|
+
writeln!(
|
|
127
|
+
stdout,
|
|
128
|
+
" {}",
|
|
129
|
+
"These bindings are declared in Wrangler config but never referenced in code."
|
|
130
|
+
.yellow()
|
|
131
|
+
.italic()
|
|
132
|
+
)?;
|
|
133
|
+
writeln!(
|
|
134
|
+
stdout,
|
|
135
|
+
" {}",
|
|
136
|
+
"Recommendation: Remove unused bindings to keep infrastructure lean.".dimmed()
|
|
137
|
+
)?;
|
|
138
|
+
writeln!(stdout)?;
|
|
139
|
+
|
|
140
|
+
for ghost in &report.ghost_bindings {
|
|
141
|
+
let details_str = ghost
|
|
142
|
+
.details
|
|
143
|
+
.as_deref()
|
|
144
|
+
.map(|d| format!(" ({})", d))
|
|
145
|
+
.unwrap_or_default();
|
|
146
|
+
writeln!(
|
|
147
|
+
stdout,
|
|
148
|
+
" {} {} [{}{}] in {}",
|
|
149
|
+
"▲".bold().yellow(),
|
|
150
|
+
ghost.name.bold().bright_yellow(),
|
|
151
|
+
ghost.binding_type,
|
|
152
|
+
details_str.dimmed(),
|
|
153
|
+
ghost.file.dimmed()
|
|
154
|
+
)?;
|
|
155
|
+
}
|
|
156
|
+
writeln!(stdout)?;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. Valid / Active Bindings Summary
|
|
160
|
+
if !report.valid_bindings.is_empty() {
|
|
161
|
+
writeln!(
|
|
162
|
+
stdout,
|
|
163
|
+
"{}",
|
|
164
|
+
format!(
|
|
165
|
+
" SYNCHRONIZED BINDINGS ({} active)",
|
|
166
|
+
report.valid_bindings.len()
|
|
167
|
+
)
|
|
168
|
+
.bold()
|
|
169
|
+
.green()
|
|
170
|
+
)?;
|
|
171
|
+
for valid in &report.valid_bindings {
|
|
172
|
+
writeln!(
|
|
173
|
+
stdout,
|
|
174
|
+
" {} {} [{}] ({} reference{})",
|
|
175
|
+
"✔".bold().green(),
|
|
176
|
+
valid.binding.name.bold().bright_white(),
|
|
177
|
+
valid.binding.binding_type.to_string().dimmed(),
|
|
178
|
+
valid.access_count,
|
|
179
|
+
if valid.access_count == 1 { "" } else { "s" }
|
|
180
|
+
)?;
|
|
181
|
+
}
|
|
182
|
+
writeln!(stdout)?;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Summary Box
|
|
186
|
+
writeln!(
|
|
187
|
+
stdout,
|
|
188
|
+
"{}",
|
|
189
|
+
"─────────────────────────────────────────────────────────────────────".dimmed()
|
|
190
|
+
)?;
|
|
191
|
+
let status_str = if report.is_success {
|
|
192
|
+
"✓ VALIDATION PASSED".bold().bright_green()
|
|
193
|
+
} else {
|
|
194
|
+
"✗ VALIDATION FAILED".bold().bright_red()
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
let errors_str = if report.undeclared_accesses.is_empty() {
|
|
198
|
+
report.undeclared_accesses.len().to_string().green()
|
|
199
|
+
} else {
|
|
200
|
+
report.undeclared_accesses.len().to_string().red().bold()
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
let warnings_str = if report.ghost_bindings.is_empty() {
|
|
204
|
+
report.ghost_bindings.len().to_string().green()
|
|
205
|
+
} else {
|
|
206
|
+
report.ghost_bindings.len().to_string().yellow().bold()
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
writeln!(
|
|
210
|
+
stdout,
|
|
211
|
+
" Status: {} | Declared: {} | Scanned Accesses: {} | Errors: {} | Warnings: {}",
|
|
212
|
+
status_str,
|
|
213
|
+
report.total_declared.to_string().white(),
|
|
214
|
+
report.total_accesses.to_string().white(),
|
|
215
|
+
errors_str,
|
|
216
|
+
warnings_str,
|
|
217
|
+
)?;
|
|
218
|
+
writeln!(
|
|
219
|
+
stdout,
|
|
220
|
+
"{}",
|
|
221
|
+
"─────────────────────────────────────────────────────────────────────".dimmed()
|
|
222
|
+
)?;
|
|
223
|
+
|
|
224
|
+
Ok(())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// Render JSON format.
|
|
228
|
+
pub fn render_json_report(report: &ValidationReport) -> io::Result<()> {
|
|
229
|
+
let json_str = serde_json::to_string_pretty(report)
|
|
230
|
+
.map_err(|e| io::Error::other(e.to_string()))?;
|
|
231
|
+
println!("{}", json_str);
|
|
232
|
+
Ok(())
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Render SARIF format (v2.1.0 standard for GitHub Actions / Code Scanning).
|
|
236
|
+
pub fn render_sarif_report(report: &ValidationReport) -> io::Result<()> {
|
|
237
|
+
let mut results = Vec::new();
|
|
238
|
+
|
|
239
|
+
// Undeclared binding errors
|
|
240
|
+
for access in &report.undeclared_accesses {
|
|
241
|
+
results.push(json!({
|
|
242
|
+
"ruleId": "cf001-undeclared-binding",
|
|
243
|
+
"level": "error",
|
|
244
|
+
"message": {
|
|
245
|
+
"text": format!("Binding '{}' accessed in code via `{}` is not declared in Wrangler config.", access.name, access.raw_expression)
|
|
246
|
+
},
|
|
247
|
+
"locations": [{
|
|
248
|
+
"physicalLocation": {
|
|
249
|
+
"artifactLocation": {
|
|
250
|
+
"uri": access.file_path.replace("\\", "/")
|
|
251
|
+
},
|
|
252
|
+
"region": {
|
|
253
|
+
"startLine": access.line,
|
|
254
|
+
"startColumn": access.column
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}]
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Ghost binding warnings
|
|
262
|
+
for ghost in &report.ghost_bindings {
|
|
263
|
+
results.push(json!({
|
|
264
|
+
"ruleId": "cf002-ghost-binding",
|
|
265
|
+
"level": "warning",
|
|
266
|
+
"message": {
|
|
267
|
+
"text": format!("Binding '{}' [{}] declared in '{}' is never referenced in source code.", ghost.name, ghost.binding_type, ghost.file)
|
|
268
|
+
},
|
|
269
|
+
"locations": [{
|
|
270
|
+
"physicalLocation": {
|
|
271
|
+
"artifactLocation": {
|
|
272
|
+
"uri": ghost.file.replace("\\", "/")
|
|
273
|
+
},
|
|
274
|
+
"region": {
|
|
275
|
+
"startLine": 1,
|
|
276
|
+
"startColumn": 1
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}]
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let sarif = json!({
|
|
284
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
285
|
+
"version": "2.1.0",
|
|
286
|
+
"runs": [{
|
|
287
|
+
"tool": {
|
|
288
|
+
"driver": {
|
|
289
|
+
"name": "cf-binding-validator",
|
|
290
|
+
"informationUri": "https://github.com/cloudflare/wrangler",
|
|
291
|
+
"version": env!("CARGO_PKG_VERSION"),
|
|
292
|
+
"rules": [
|
|
293
|
+
{
|
|
294
|
+
"id": "cf001-undeclared-binding",
|
|
295
|
+
"name": "UndeclaredBindingAccess",
|
|
296
|
+
"shortDescription": {
|
|
297
|
+
"text": "Binding is accessed in code without being declared in Wrangler config"
|
|
298
|
+
},
|
|
299
|
+
"fullDescription": {
|
|
300
|
+
"text": "Accessing undefined environment bindings at runtime causes TypeErrors and Worker crashes."
|
|
301
|
+
},
|
|
302
|
+
"defaultConfiguration": {
|
|
303
|
+
"level": "error"
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"id": "cf002-ghost-binding",
|
|
308
|
+
"name": "GhostBindingDeclared",
|
|
309
|
+
"shortDescription": {
|
|
310
|
+
"text": "Binding is declared in Wrangler configuration but never referenced in code"
|
|
311
|
+
},
|
|
312
|
+
"fullDescription": {
|
|
313
|
+
"text": "Unused bindings add maintenance overhead and clutter configuration."
|
|
314
|
+
},
|
|
315
|
+
"defaultConfiguration": {
|
|
316
|
+
"level": "warning"
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
"results": results
|
|
323
|
+
}]
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
println!("{}", serde_json::to_string_pretty(&sarif).unwrap());
|
|
327
|
+
Ok(())
|
|
328
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::fmt;
|
|
3
|
+
|
|
4
|
+
/// The type of Cloudflare Worker/Pages binding declared in Wrangler configuration.
|
|
5
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
6
|
+
#[serde(rename_all = "snake_case")]
|
|
7
|
+
pub enum BindingType {
|
|
8
|
+
KvNamespace,
|
|
9
|
+
D1Database,
|
|
10
|
+
R2Bucket,
|
|
11
|
+
QueueProducer,
|
|
12
|
+
QueueConsumer,
|
|
13
|
+
Vectorize,
|
|
14
|
+
Hyperdrive,
|
|
15
|
+
Ai,
|
|
16
|
+
Service,
|
|
17
|
+
AnalyticsEngine,
|
|
18
|
+
DurableObject,
|
|
19
|
+
Workflow,
|
|
20
|
+
Browser,
|
|
21
|
+
SendEmail,
|
|
22
|
+
MtlsCertificate,
|
|
23
|
+
Pipeline,
|
|
24
|
+
Assets,
|
|
25
|
+
Var,
|
|
26
|
+
Secret,
|
|
27
|
+
Unknown(String),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
impl fmt::Display for BindingType {
|
|
31
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
32
|
+
match self {
|
|
33
|
+
BindingType::KvNamespace => write!(f, "KV Namespace"),
|
|
34
|
+
BindingType::D1Database => write!(f, "D1 Database"),
|
|
35
|
+
BindingType::R2Bucket => write!(f, "R2 Bucket"),
|
|
36
|
+
BindingType::QueueProducer => write!(f, "Queue Producer"),
|
|
37
|
+
BindingType::QueueConsumer => write!(f, "Queue Consumer"),
|
|
38
|
+
BindingType::Vectorize => write!(f, "Vectorize Index"),
|
|
39
|
+
BindingType::Hyperdrive => write!(f, "Hyperdrive"),
|
|
40
|
+
BindingType::Ai => write!(f, "Workers AI"),
|
|
41
|
+
BindingType::Service => write!(f, "Service Binding"),
|
|
42
|
+
BindingType::AnalyticsEngine => write!(f, "Analytics Engine Dataset"),
|
|
43
|
+
BindingType::DurableObject => write!(f, "Durable Object"),
|
|
44
|
+
BindingType::Workflow => write!(f, "Workflow"),
|
|
45
|
+
BindingType::Browser => write!(f, "Browser Rendering"),
|
|
46
|
+
BindingType::SendEmail => write!(f, "Send Email"),
|
|
47
|
+
BindingType::MtlsCertificate => write!(f, "mTLS Certificate"),
|
|
48
|
+
BindingType::Pipeline => write!(f, "Pipeline"),
|
|
49
|
+
BindingType::Assets => write!(f, "Static Assets"),
|
|
50
|
+
BindingType::Var => write!(f, "Environment Variable (var)"),
|
|
51
|
+
BindingType::Secret => write!(f, "Secret"),
|
|
52
|
+
BindingType::Unknown(name) => write!(f, "Custom ({})", name),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// A binding declared in `wrangler.jsonc`, `wrangler.json`, or `wrangler.toml`.
|
|
58
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
59
|
+
pub struct DeclaredBinding {
|
|
60
|
+
pub name: String,
|
|
61
|
+
pub binding_type: BindingType,
|
|
62
|
+
pub file: String,
|
|
63
|
+
pub environment: String,
|
|
64
|
+
pub details: Option<String>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// How a binding was accessed in source code.
|
|
68
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
69
|
+
#[serde(rename_all = "snake_case")]
|
|
70
|
+
pub enum AccessKind {
|
|
71
|
+
DirectMember,
|
|
72
|
+
Destructured,
|
|
73
|
+
ParamDestructured,
|
|
74
|
+
HelperCall,
|
|
75
|
+
ProcessEnv,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
impl fmt::Display for AccessKind {
|
|
79
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
80
|
+
match self {
|
|
81
|
+
AccessKind::DirectMember => write!(f, "property access"),
|
|
82
|
+
AccessKind::Destructured => write!(f, "destructuring"),
|
|
83
|
+
AccessKind::ParamDestructured => write!(f, "parameter destructuring"),
|
|
84
|
+
AccessKind::HelperCall => write!(f, "helper call"),
|
|
85
|
+
AccessKind::ProcessEnv => write!(f, "process.env access"),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// An instance in source code where a binding was accessed.
|
|
91
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
92
|
+
pub struct BindingAccess {
|
|
93
|
+
pub name: String,
|
|
94
|
+
pub file_path: String,
|
|
95
|
+
pub line: usize,
|
|
96
|
+
pub column: usize,
|
|
97
|
+
pub raw_expression: String,
|
|
98
|
+
pub access_kind: AccessKind,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Information about a binding that was successfully matched between config and code.
|
|
102
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
103
|
+
pub struct ValidBindingInfo {
|
|
104
|
+
pub binding: DeclaredBinding,
|
|
105
|
+
pub access_count: usize,
|
|
106
|
+
pub accesses: Vec<BindingAccess>,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Complete report of the validation process.
|
|
110
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
111
|
+
pub struct ValidationReport {
|
|
112
|
+
pub config_file: Option<String>,
|
|
113
|
+
pub environment: String,
|
|
114
|
+
pub total_files_scanned: usize,
|
|
115
|
+
pub total_declared: usize,
|
|
116
|
+
pub total_accesses: usize,
|
|
117
|
+
pub undeclared_accesses: Vec<BindingAccess>,
|
|
118
|
+
pub ghost_bindings: Vec<DeclaredBinding>,
|
|
119
|
+
pub valid_bindings: Vec<ValidBindingInfo>,
|
|
120
|
+
pub is_success: bool,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Output formats supported by the CLI.
|
|
124
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
125
|
+
pub enum OutputFormat {
|
|
126
|
+
Text,
|
|
127
|
+
Json,
|
|
128
|
+
Sarif,
|
|
129
|
+
}
|