use serde::{Deserialize, Serialize};
use crate::grammar::{Grammar, GrammarMeta};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GrammarIssue {
pub grammar: String,
pub level: IssueLevel,
pub message: String,
}
impl std::fmt::Display for GrammarIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.level, self.message)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum IssueLevel {
Error,
Warning,
}
impl std::fmt::Display for IssueLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Error => f.write_str("error"),
Self::Warning => f.write_str("warning"),
}
}
}
pub fn validate(grammar: &Grammar) -> Vec<GrammarIssue> {
let mut issues = Vec::new();
let name = &grammar.meta.name;
validate_meta(&grammar.meta, name, &mut issues);
validate_techniques(grammar, name, &mut issues);
validate_encodings(grammar, name, &mut issues);
validate_variables(grammar, name, &mut issues);
issues
}
fn validate_meta(meta: &GrammarMeta, name: &str, issues: &mut Vec<GrammarIssue>) {
if meta.name.is_empty() {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Error,
message: "grammar name is empty".into(),
});
}
if meta.sink_category.is_empty() {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Error,
message: "sink_category is empty - payloads won't be retrievable".into(),
});
}
}
fn validate_techniques(grammar: &Grammar, name: &str, issues: &mut Vec<GrammarIssue>) {
if grammar.techniques.is_empty() {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Warning,
message: "no techniques defined - grammar produces no payloads".into(),
});
return;
}
for tech in &grammar.techniques {
if tech.template.trim().is_empty() {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Error,
message: format!("technique '{}' has empty template", tech.name),
});
}
check_template_variables(grammar, tech, name, issues);
if tech.confidence < 0.0 || tech.confidence > 1.0 {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Warning,
message: format!(
"technique '{}' confidence {} is outside [0.0, 1.0]",
tech.name, tech.confidence
),
});
}
}
}
fn check_template_variables(
grammar: &Grammar,
tech: &crate::grammar::Technique,
name: &str,
issues: &mut Vec<GrammarIssue>,
) {
let mut pos = 0;
while let Some(start) = tech.template[pos..].find('{') {
let abs_start = pos + start;
if tech.template[abs_start..].starts_with("{{") {
pos = abs_start + 2;
continue;
}
if let Some(end) = tech.template[abs_start..].find('}') {
let var_name = &tech.template[abs_start + 1..abs_start + end];
let looks_like_var = var_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if looks_like_var
&& var_name != "prefix"
&& var_name != "suffix"
&& !var_name.is_empty()
{
let has_var = grammar.variables.contains_key(var_name)
|| grammar.variables.contains_key(&format!("{var_name}s"))
|| grammar
.variables
.keys()
.any(|k| crate::grammar::depluralize(k) == var_name);
if !has_var {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Warning,
message: format!(
"technique '{}' references undefined variable '{{{}}}'",
tech.name, var_name
),
});
}
}
pos = abs_start + end + 1;
} else {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Error,
message: format!("technique '{}' has unclosed '{{' in template", tech.name),
});
break;
}
}
}
fn validate_encodings(grammar: &Grammar, name: &str, issues: &mut Vec<GrammarIssue>) {
for enc in &grammar.encodings {
if !crate::encoding::BuiltinEncoding::is_builtin(&enc.transform) {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Warning,
message: format!(
"encoding '{}' uses unknown transform '{}' (not a built-in). If it is not a registered custom encoding, loading fails closed: the payload is rejected, not passed through.",
enc.name, enc.transform
),
});
}
}
}
fn validate_variables(grammar: &Grammar, name: &str, issues: &mut Vec<GrammarIssue>) {
for (var_name, values) in &grammar.variables {
if ["grammar", "contexts", "techniques", "encodings"].contains(&var_name.as_str()) {
continue;
}
if values.is_empty() {
issues.push(GrammarIssue {
grammar: name.into(),
level: IssueLevel::Warning,
message: format!("variable '{var_name}' has no values"),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::grammar::Technique;
use std::collections::HashMap;
fn grammar_with_template(template: &str) -> Grammar {
Grammar {
meta: GrammarMeta {
name: "t".into(),
sink_category: "xss".into(),
description: None,
tags: Vec::new(),
severity: None,
cwe: None,
target_runtime: None,
},
contexts: Vec::new(),
techniques: vec![Technique {
name: "basic".into(),
template: template.into(),
tags: Vec::new(),
confidence: 1.0,
expected_pattern: None,
}],
encodings: Vec::new(),
variables: HashMap::new(),
}
}
fn has_unclosed_error(g: &Grammar) -> bool {
validate(g)
.iter()
.any(|i| i.level == IssueLevel::Error && i.message.contains("unclosed '{'"))
}
#[test]
fn escaped_brace_is_not_unclosed() {
assert!(!has_unclosed_error(&grammar_with_template("payload {{ literal text")));
assert!(!has_unclosed_error(&grammar_with_template("a{{b")));
assert!(!has_unclosed_error(&grammar_with_template("{{ then {prefix}")));
}
#[test]
fn genuinely_unclosed_brace_still_errors() {
assert!(has_unclosed_error(&grammar_with_template("alert{prefix")));
assert!(has_unclosed_error(&grammar_with_template("x { y no close")));
}
}