use crate::filters::{
BenignNameFilter, ConsequenceAnalyzer, RhsAnalyzer, ScopeFilter,
};
use crate::models::{
AnalysisContext, FactorCategory, Score, ScoreFactor, SuspectValue, Usage,
};
use crate::scoring::thresholds::ThresholdConfig;
use crate::simd;
#[derive(Debug, Clone)]
pub struct ScoringConfig {
pub base_comparison_score: i32,
pub base_definition_score: i32,
pub base_argument_score: i32,
pub base_struct_field_score: i32,
pub base_return_score: i32,
pub base_match_arm_score: i32,
pub base_closure_score: i32,
pub base_array_element_score: i32,
pub base_macro_score: i32,
pub base_field_assignment_score: i32,
pub base_builder_score: i32,
pub threshold_config: ThresholdConfig,
pub enable_name_filter: bool,
pub enable_scope_filter: bool,
pub enable_consequence_analysis: bool,
pub enable_rhs_analysis: bool,
pub ignore_values: Vec<String>,
pub ignore_value_prefixes: Vec<String>,
pub ignore_value_suffixes: Vec<String>,
pub ignore_value_contains: Vec<String>,
}
impl Default for ScoringConfig {
fn default() -> Self {
Self {
base_comparison_score: 50,
base_definition_score: 30,
base_argument_score: 55, base_struct_field_score: 50, base_return_score: 45, base_match_arm_score: 50, base_closure_score: 45, base_array_element_score: 40, base_macro_score: 40, base_field_assignment_score: 50, base_builder_score: 55, threshold_config: ThresholdConfig::default(),
enable_name_filter: true,
enable_scope_filter: true,
enable_consequence_analysis: true,
enable_rhs_analysis: true,
ignore_values: Vec::new(),
ignore_value_prefixes: Vec::new(),
ignore_value_suffixes: Vec::new(),
ignore_value_contains: Vec::new(),
}
}
}
impl ScoringConfig {
pub fn should_ignore_value(&self, value: &str) -> bool {
let lower = value.to_lowercase();
if self.ignore_values.iter().any(|v| v.to_lowercase() == lower) {
return true;
}
if self.ignore_value_prefixes.iter().any(|p| lower.starts_with(&p.to_lowercase())) {
return true;
}
if self.ignore_value_suffixes.iter().any(|s| lower.ends_with(&s.to_lowercase())) {
return true;
}
if self.ignore_value_contains.iter().any(|c| lower.contains(&c.to_lowercase())) {
return true;
}
false
}
}
pub struct ScoringEngine {
config: ScoringConfig,
name_filter: BenignNameFilter,
scope_filter: ScopeFilter,
consequence_analyzer: ConsequenceAnalyzer,
rhs_analyzer: RhsAnalyzer,
}
impl ScoringEngine {
pub fn new(config: ScoringConfig) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
config,
name_filter: BenignNameFilter::new(Default::default())?,
scope_filter: ScopeFilter::new(Default::default())?,
consequence_analyzer: ConsequenceAnalyzer::new(Default::default()),
rhs_analyzer: RhsAnalyzer::new(Default::default()),
})
}
pub fn score(
&self,
suspect: &SuspectValue,
usage: Option<&Usage>,
context: &AnalysisContext,
) -> Score {
let mut factors = Vec::new();
if suspect.value().is_empty() {
factors.push(ScoreFactor::kill(
"empty_value",
"Empty string cannot be a secret",
));
return Score::new(factors);
}
if self.config.enable_scope_filter {
if self.scope_filter.should_skip(context) {
factors.push(ScoreFactor::kill(
"scope_skip",
"Finding is in ignored scope (test/example/bench)",
));
return Score::new(factors);
}
factors.extend(self.scope_filter.analyze(context));
}
if let Some(usage) = usage {
if Self::is_safe_function_usage(usage) {
factors.push(ScoreFactor::kill(
"safe_function",
"Value passed to safe function (logging/error/formatting)",
));
return Score::new(factors);
}
}
let (base_score, usage_description) = match usage {
Some(Usage::Comparison(_)) => (
self.config.base_comparison_score,
"Constant used in equality comparison",
),
Some(Usage::FunctionArgument { function_name, .. }) => (
self.config.base_argument_score,
if function_name.contains("auth") || function_name.contains("token") {
"Passed to authentication function"
} else {
"Passed as function argument"
},
),
Some(Usage::MethodArgument { method_name, .. }) => (
self.config.base_argument_score,
if method_name.contains("auth") || method_name.contains("token") || method_name == "insert" || method_name.starts_with("set_") {
"Passed to suspicious method"
} else {
"Passed as method argument"
},
),
Some(Usage::StructFieldInit { field_name, .. }) => (
self.config.base_struct_field_score,
if field_name.contains("password") || field_name.contains("token") || field_name.contains("secret") || field_name.contains("key") {
"Assigned to sensitive struct field"
} else {
"Assigned to struct field"
},
),
Some(Usage::FieldAssignment { field_name, .. }) => (
self.config.base_field_assignment_score,
if field_name.contains("password") || field_name.contains("token") || field_name.contains("secret") || field_name.contains("key") {
"Assigned to sensitive field"
} else {
"Assigned to field"
},
),
Some(Usage::ReturnValue { function_name }) => (
self.config.base_return_score,
if function_name.contains("token") || function_name.contains("secret") || function_name.contains("password") {
"Returned from sensitive function"
} else {
"Returned from function"
},
),
Some(Usage::MatchArm { in_guard, .. }) => (
self.config.base_match_arm_score,
if *in_guard {
"Used in match arm guard (potential backdoor)"
} else {
"Used in match arm pattern (potential backdoor)"
},
),
Some(Usage::PatternMatch { .. }) => (
self.config.base_match_arm_score,
"Used in pattern match",
),
Some(Usage::ClosureCapture { passed_to }) => (
self.config.base_closure_score,
if passed_to.is_some() {
"Used in closure passed to function"
} else {
"Used inside closure"
},
),
Some(Usage::ArrayElement { .. }) => (
self.config.base_array_element_score,
"Used as array/vec element",
),
Some(Usage::MacroArgument { macro_name, .. }) => (
self.config.base_macro_score,
if macro_name == "env" || macro_name == "option_env" {
"Used in env! macro (may have fallback)"
} else {
"Used in macro invocation"
},
),
Some(Usage::BuilderMethod { method_name, .. }) => (
self.config.base_builder_score,
if method_name.contains("auth") || method_name.contains("token") || method_name.contains("password") {
"Used in auth builder method"
} else {
"Used in builder pattern"
},
),
Some(Usage::Definition) | None => (
self.config.base_definition_score,
"Suspicious constant definition",
),
};
factors.push(ScoreFactor::new(
"base",
FactorCategory::Base,
base_score,
usage_description,
));
let mut is_highly_suspicious = false;
if self.config.enable_name_filter {
if let Some(name) = suspect.name() {
let name_factors = self.name_filter.analyze(name);
if name_factors.iter().any(|f| f.contribution <= -100) {
factors.extend(name_factors);
return Score::new(factors);
}
is_highly_suspicious = name_factors.iter().any(|f| f.contribution >= 25);
factors.extend(name_factors);
}
}
if self.config.enable_consequence_analysis {
if let Some(Usage::Comparison(comparison)) = usage {
if let Some(consequence) = &comparison.consequence {
factors.extend(self.consequence_analyzer.score(consequence));
}
}
}
if self.config.enable_rhs_analysis {
if let Some(Usage::Comparison(comparison)) = usage {
if let Some(variable) = comparison.variable_side() {
factors.extend(self.rhs_analyzer.analyze(variable));
}
}
}
let is_auth_function = context.current_function.as_ref().map_or(false, |f| {
let lower = f.to_lowercase();
["auth", "login", "verify", "validate", "check_token", "check_password", "authenticate"]
.iter()
.any(|term| lower.contains(term))
});
if is_auth_function {
factors.push(ScoreFactor::new(
"auth_function_context",
FactorCategory::Context,
20,
"Found in authentication-related function",
).with_evidence(format!("Function: {}", context.current_function.as_deref().unwrap_or("unknown"))));
} else if context.has_auth_indicators {
factors.push(ScoreFactor::new(
"auth_indicators_context",
FactorCategory::Context,
20,
"Function contains authentication patterns",
).with_evidence("Contains auth-related method calls or error handling".to_string()));
}
let mut value_factors = self.analyze_value_with_context(suspect.value(), suspect.name(), context);
let has_auth_context = is_highly_suspicious || is_auth_function || context.has_auth_indicators;
if has_auth_context {
let has_value_penalty = value_factors
.iter()
.any(|f| f.name == "low_entropy" || f.name == "short_value");
if has_value_penalty {
value_factors.retain(|f| f.name != "low_entropy" && f.name != "short_value");
let reason = if is_highly_suspicious {
"Suspicious variable name overrides benign-looking value"
} else if is_auth_function {
"Auth function context overrides benign-looking value"
} else {
"Auth indicators in function override benign-looking value"
};
value_factors.push(ScoreFactor::new(
"auth_context_override",
FactorCategory::Context,
10,
reason,
));
}
}
factors.extend(value_factors);
Score::new(factors)
}
fn analyze_value_with_context(&self, value: &str, name: Option<&str>, context: &AnalysisContext) -> Vec<ScoreFactor> {
let mut factors = Vec::new();
if self.config.should_ignore_value(value) {
factors.push(ScoreFactor::kill(
"config_ignored",
"Value matches ignore pattern in config",
));
return factors;
}
if value.len() > 10 && value.chars().all(|c| c == '0' || c == '-') {
factors.push(ScoreFactor::kill(
"nil_uuid",
"Value is a nil UUID (all zeros)",
));
return factors;
}
let lower_value = value.to_lowercase();
if lower_value.starts_with("urn:")
|| lower_value.starts_with("http://")
|| lower_value.starts_with("https://")
|| lower_value.starts_with("application/")
|| lower_value.starts_with("text/")
|| lower_value.starts_with("image/")
|| lower_value.starts_with("mailto:")
|| lower_value.starts_with("tel:")
|| lower_value.starts_with("file://")
{
factors.push(ScoreFactor::kill(
"protocol_identifier",
"Value is a protocol identifier (URI/URN/MIME type)",
));
return factors;
}
if value.contains('/') && !value.contains("PRIVATE KEY") {
factors.push(ScoreFactor::kill(
"path_detected",
"Value looks like a file path or URL fragment",
));
return factors;
}
if value.contains(' ') {
let is_passphrase = name.map_or(false, |n| {
n.to_lowercase().contains("passphrase")
});
let has_auth_context = context.has_auth_indicators;
if !is_passphrase && !has_auth_context {
factors.push(ScoreFactor::kill(
"sentence_value",
"Value contains spaces (secrets don't contain spaces)",
));
return factors;
}
}
let lower = value.to_lowercase();
if lower.starts_with("x-") && value.contains('-') && !value.contains(' ') {
factors.push(ScoreFactor::kill(
"http_header",
"Value looks like an HTTP header name",
));
return factors;
}
let upper = value.to_uppercase();
if (upper.contains("SHA") || upper.contains("HMAC") || upper.contains("AES")
|| upper.contains("RSA") || upper.contains("ECDSA") || upper.contains("PAYLOAD"))
&& value.contains('-')
&& !value.contains(' ')
{
factors.push(ScoreFactor::kill(
"algorithm_id",
"Value looks like an algorithm identifier",
));
return factors;
}
if Self::is_env_var_name(value) {
factors.push(ScoreFactor::kill(
"env_var_name",
"Value looks like an environment variable name (not a secret)",
));
return factors;
}
if value.chars().any(|c| !c.is_ascii()) {
factors.push(ScoreFactor::kill(
"non_ascii_text",
"Value contains non-ASCII characters (likely i18n text, not a secret)",
));
return factors;
}
if Self::is_error_code(value) {
factors.push(ScoreFactor::kill(
"error_code",
"Value looks like an error code identifier",
));
return factors;
}
if value.len() < 8 {
factors.push(ScoreFactor::new(
"short_value",
FactorCategory::ValueAnalysis,
-20,
"Value is very short (likely not a secret)",
));
} else if value.len() >= 32 {
factors.push(ScoreFactor::new(
"long_value",
FactorCategory::ValueAnalysis,
15,
"Value is long (may be a key/token)",
));
}
let entropy = Self::calculate_entropy(value);
if entropy > 4.5 {
factors.push(
ScoreFactor::new(
"high_entropy",
FactorCategory::ValueAnalysis,
20,
"Value has high entropy (looks random)",
)
.with_evidence(format!("Entropy: {:.2}", entropy)),
);
} else if entropy < 2.0 && value.len() > 4 {
factors.push(ScoreFactor::new(
"low_entropy",
FactorCategory::ValueAnalysis,
-15,
"Value has low entropy (looks like text)",
));
}
if Self::looks_like_uuid(value) {
factors.push(ScoreFactor::new(
"uuid_pattern",
FactorCategory::ValueAnalysis,
-10,
"Value looks like a UUID (often not sensitive)",
));
}
if Self::looks_like_version(value) {
factors.push(ScoreFactor::new(
"version_pattern",
FactorCategory::ValueAnalysis,
-30,
"Value looks like a version string",
));
}
if Self::looks_like_base64(value) && value.len() >= 20 {
factors.push(ScoreFactor::new(
"base64_pattern",
FactorCategory::ValueAnalysis,
15,
"Value looks like base64 (may be encoded secret)",
));
}
if Self::looks_like_hex(value) && value.len() >= 32 {
factors.push(ScoreFactor::new(
"hex_pattern",
FactorCategory::ValueAnalysis,
15,
"Value looks like hex-encoded data (may be a key)",
));
}
if Self::is_placeholder(value) {
factors.push(ScoreFactor::new(
"placeholder",
FactorCategory::ValueAnalysis,
-50,
"Value appears to be a placeholder",
));
}
factors
}
fn calculate_entropy(s: &str) -> f64 {
simd::calculate_entropy_str(s)
}
fn looks_like_uuid(s: &str) -> bool {
simd::is_uuid_format(s)
}
fn looks_like_version(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() >= 2 && parts.len() <= 4 {
parts.iter().all(|p| {
p.chars()
.all(|c| c.is_ascii_digit() || c == '-' || c.is_ascii_alphabetic())
})
} else {
false
}
}
fn looks_like_base64(s: &str) -> bool {
if s.len() < 4 {
return false;
}
let has_base64_chars = s.contains('+') || s.contains('/') || s.ends_with('=');
let is_long_enough = s.len() >= 32;
let is_camel_case = simd::has_uppercase(s)
&& simd::has_lowercase(s)
&& simd::is_all_alphanumeric(s);
if is_camel_case && !has_base64_chars {
return false;
}
let valid_chars = simd::is_all_base64_chars(s);
valid_chars && (has_base64_chars || is_long_enough)
}
fn looks_like_hex(s: &str) -> bool {
s.len() >= 8 && simd::is_all_hex(s)
}
fn is_placeholder(s: &str) -> bool {
simd::patterns::prebuilt::placeholders().is_match(s)
}
fn is_error_code(value: &str) -> bool {
let prefixes = ["Err", "err", "Error", "error"];
if !prefixes.iter().any(|p| value.starts_with(p)) {
return false;
}
if !value.chars().all(|c| c.is_ascii_alphanumeric()) {
return false;
}
let after_prefix = if value.starts_with("Error") || value.starts_with("error") {
&value[5..]
} else {
&value[3..]
};
after_prefix.chars().next().map_or(false, |c| c.is_ascii_uppercase())
}
fn is_env_var_name(value: &str) -> bool {
if value.len() < 5 {
return false;
}
let is_screaming_snake = value
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
if !is_screaming_snake {
return false;
}
if !value.contains('_') {
return false;
}
let env_prefixes = ["ENV_", "RUSTFS_", "AWS_", "AZURE_", "GCP_", "DB_", "API_", "APP_"];
let env_suffixes = ["_TOKEN", "_KEY", "_SECRET", "_PASSWORD", "_URL", "_PATH", "_DIR", "_HOST", "_PORT"];
env_prefixes.iter().any(|p| value.starts_with(p))
|| env_suffixes.iter().any(|s| value.ends_with(s))
}
fn is_safe_function_usage(usage: &Usage) -> bool {
const SAFE_FUNCS: &[&str] = &[
"format", "print", "println", "eprint", "eprintln",
"debug", "info", "warn", "error", "trace", "log",
"expect", "panic", "unimplemented", "todo", "unreachable",
"msg", "other", "custom", "context", "with_context",
"wrap_err", "wrap_err_with",
"new", "from", "into", "as_str", "as_ref",
"from_string", "from_str", "to_string",
"some", "ok", "err", "map_err", "map_res", "ok_or", "ok_or_else",
"unwrap_or", "unwrap_or_else", "unwrap_or_default",
"contains", "starts_with", "ends_with", "matches",
"find", "rfind", "split", "trim", "replace",
"get", "remove", "contains_key",
"description", "with_description", "with_label", "label",
"serialize_struct", "serialize_field", "rename",
"write", "write_str", "write_fmt",
"grpcmethod",
"s3errorcode",
"parse", "type_name",
];
const SAFE_MACROS: &[&str] = &[
"format", "print", "println", "eprint", "eprintln",
"debug", "info", "warn", "error", "trace", "log",
"panic", "todo", "unimplemented", "unreachable",
"assert", "assert_eq", "assert_ne", "debug_assert",
"write", "writeln", "format_args",
"err", "bail", "anyhow", "ensure",
];
match usage {
Usage::FunctionArgument { function_name, .. } => {
let func_lower = function_name.to_lowercase();
SAFE_FUNCS.iter().any(|f| {
func_lower == *f || func_lower.ends_with(&format!("::{}", f))
})
}
Usage::MethodArgument { method_name, .. } => {
let method_lower = method_name.to_lowercase();
SAFE_FUNCS.iter().any(|f| method_lower == *f)
}
Usage::MacroArgument { macro_name, .. } => {
let macro_lower = macro_name.to_lowercase();
SAFE_MACROS.iter().any(|m| macro_lower == *m)
}
_ => false,
}
}
pub fn should_report(&self, score: &Score) -> bool {
self.config.threshold_config.should_report(score.total)
}
pub fn threshold(&self) -> i32 {
self.config.threshold_config.report_threshold()
}
pub fn severity(&self, score: &Score) -> crate::models::Severity {
self.config.threshold_config.severity_for_score(score.total)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_context() -> AnalysisContext {
AnalysisContext::new(PathBuf::from("src/lib.rs"))
}
#[test]
fn test_version_constant_killed() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "VERSION".into(),
value: "1.0.0".into(),
type_annotation: None,
};
let score = engine.score(&suspect, None, &make_context());
assert!(score.killed, "VERSION should be killed");
}
#[test]
fn test_auth_token_high_score() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "AUTH_TOKEN".into(),
value: "sk_live_abcdef123456".into(),
type_annotation: None,
};
let score = engine.score(&suspect, None, &make_context());
assert!(
score.total >= 70,
"AUTH_TOKEN should have high score: {}",
score.total
);
}
#[test]
fn test_test_context_kills() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "SECRET_KEY".into(),
value: "supersecret".into(),
type_annotation: None,
};
let mut context = AnalysisContext::new(PathBuf::from("tests/auth_test.rs"));
context.in_test_context = true;
let score = engine.score(&suspect, None, &context);
assert!(score.killed, "Test context should kill finding");
}
#[test]
fn test_entropy_calculation() {
let high_entropy = ScoringEngine::calculate_entropy("aB3xY9zQ");
assert!(high_entropy > 2.5, "Random string should have high entropy");
let low_entropy = ScoringEngine::calculate_entropy("aaaaaaa");
assert!(low_entropy < 1.0, "Repeated chars should have low entropy");
}
#[test]
fn test_uuid_detection() {
assert!(ScoringEngine::looks_like_uuid(
"550e8400-e29b-41d4-a716-446655440000"
));
assert!(!ScoringEngine::looks_like_uuid("not-a-uuid"));
}
#[test]
fn test_placeholder_detection() {
assert!(ScoringEngine::is_placeholder("your_api_key_here"));
assert!(ScoringEngine::is_placeholder("CHANGEME"));
assert!(ScoringEngine::is_placeholder("${API_KEY}"));
assert!(!ScoringEngine::is_placeholder("sk_live_real_key"));
}
#[test]
fn test_empty_string_killed() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "AUTH_TOKEN".into(),
value: "".into(),
type_annotation: None,
};
let score = engine.score(&suspect, None, &make_context());
assert!(score.killed, "Empty string should be killed");
}
#[test]
fn test_suspicious_name_override_short_value() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "token".into(),
value: "abc".into(), type_annotation: None,
};
let score = engine.score(&suspect, None, &make_context());
assert!(
!score.factors.iter().any(|f| f.name == "short_value"),
"Short value penalty should be removed for suspicious name"
);
assert!(
score
.factors
.iter()
.any(|f| f.name == "auth_context_override"),
"Should have auth_context_override factor"
);
assert!(
score.total >= 50,
"Score should be reportable: {}",
score.total
);
}
#[test]
fn test_benign_name_keeps_penalties() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "message".into(), value: "abc".into(), type_annotation: None,
};
let score = engine.score(&suspect, None, &make_context());
assert!(
score.factors.iter().any(|f| f.name == "short_value"),
"Short value penalty should remain for non-suspicious name"
);
assert!(
!score
.factors
.iter()
.any(|f| f.name == "suspicious_name_override"),
"Should not have override factor for non-suspicious name"
);
}
#[test]
fn test_hardcoded_token_is_detected() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "token".into(),
value: "s3cr3t_t0k3n".into(), type_annotation: None,
};
let usage = Usage::MethodArgument {
method_name: "insert".into(),
argument_position: 1,
receiver: "req.metadata_mut()".into(),
};
let score = engine.score(&suspect, Some(&usage), &make_context());
assert!(
!score.killed,
"hardcoded token should NOT be killed. Score: {}, Factors: {:?}",
score.total,
score.factors.iter().map(|f| (&f.name, f.contribution)).collect::<Vec<_>>()
);
assert!(
score.total >= 70,
"hardcoded token should be above threshold. Score: {}, Factors: {:?}",
score.total,
score.factors.iter().map(|f| (&f.name, f.contribution)).collect::<Vec<_>>()
);
}
#[test]
fn test_spaces_kill_without_auth_context() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "message".into(),
value: "some error message".into(), type_annotation: None,
};
let score = engine.score(&suspect, None, &make_context());
assert!(
score.killed,
"Value with spaces should be killed without auth context. Score: {}, Factors: {:?}",
score.total,
score.factors.iter().map(|f| (&f.name, f.contribution)).collect::<Vec<_>>()
);
}
#[test]
fn test_spaces_allowed_with_auth_context() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "token".into(),
value: "rustfs rpc".into(), type_annotation: None,
};
let mut context = make_context();
context.has_auth_indicators = true;
let score = engine.score(&suspect, None, &context);
assert!(
!score.killed,
"Value with spaces should NOT be killed with auth context (backdoor pattern). Score: {}, Factors: {:?}",
score.total,
score.factors.iter().map(|f| (&f.name, f.contribution)).collect::<Vec<_>>()
);
}
#[test]
fn test_passphrase_exception_for_spaces() {
let engine = ScoringEngine::new(ScoringConfig::default()).unwrap();
let suspect = SuspectValue::Constant {
name: "user_passphrase".into(),
value: "my secret phrase".into(),
type_annotation: None,
};
let score = engine.score(&suspect, None, &make_context());
assert!(
!score.killed,
"Passphrase with spaces should NOT be killed. Score: {}, Factors: {:?}",
score.total,
score.factors.iter().map(|f| (&f.name, f.contribution)).collect::<Vec<_>>()
);
}
}