pub mod entity_classifier;
pub mod error_sanitizer;
pub mod path_validator;
pub use self::entity_classifier::{
create_entity, create_external_entity, create_parameter_entity, AttackType, ClassifierConfig,
Entity, EntityClass, EntityClassifier, EntityMetrics, ValidationResult,
};
pub use self::path_validator::{PathValidationConfig, PathValidator, ValidatedPath};
pub use self::error_sanitizer::{
sanitize_build_error, sanitize_error, sanitize_io_error, sanitize_parse_error,
sanitize_security_error, ErrorContext, ErrorLevel, ErrorMode, ErrorSanitizer, RedactionRule,
SanitizedError, SanitizerConfig, SanitizerStatistics, SecureError,
};
use crate::error::BuildError;
use once_cell::sync::Lazy;
use quick_xml::events::Event;
use quick_xml::Reader;
use regex::Regex;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tracing::{debug, warn};
use url::Url;
const MAX_XML_SIZE: usize = 100 * 1024 * 1024;
const MAX_JSON_SIZE: usize = 50 * 1024 * 1024;
const MAX_STRING_SIZE: usize = 1024 * 1024;
const MAX_XML_DEPTH: usize = 100;
const MAX_ATTRIBUTES_PER_ELEMENT: usize = 100;
const MAX_CHILD_ELEMENTS: usize = 10000;
const MAX_REQUESTS_PER_MINUTE: u32 = 100;
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60);
static DANGEROUS_ENTITY_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"<!ENTITY\s+[^>]*>").unwrap()
});
fn contains_only_safe_entities(input: &str) -> bool {
let re = Regex::new(r"&([a-zA-Z_][a-zA-Z0-9._-]*|#[0-9]+|#x[0-9a-fA-F]+);").unwrap();
for cap in re.captures_iter(input) {
let entity = &cap[1];
match entity {
"lt" | "gt" | "amp" | "quot" | "apos" => continue,
_ if entity.starts_with('#') => continue, _ => return false, }
}
true
}
static EXTERNAL_REF_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"(SYSTEM|PUBLIC)\s+['"][^'"]*['"]"#).unwrap());
#[allow(dead_code)]
static DANGEROUS_PATH_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\.\./|\\\.\\\|/etc/|/proc/|/sys/|/dev/|/tmp/|C:\\|\\\\").unwrap());
static SQL_INJECTION_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(union|select|insert|update|delete|drop|exec|script|javascript|vbscript|onload|onerror)").unwrap()
});
static XML_BOMB_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"<!ENTITY\s+\w+\s+['"](&\w+;)+['"]"#).unwrap());
#[derive(Debug, Clone)]
pub struct SecurityConfig {
pub max_xml_size: usize,
pub max_json_size: usize,
pub max_string_size: usize,
pub max_xml_depth: usize,
pub max_attributes_per_element: usize,
pub max_child_elements: usize,
pub allow_external_entities: bool,
pub allow_dtd: bool,
pub rate_limiting_enabled: bool,
pub max_requests_per_minute: u32,
pub enable_entity_classification: bool,
pub max_entity_expansion_ratio: f64,
pub max_entity_depth: usize,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
max_xml_size: MAX_XML_SIZE,
max_json_size: MAX_JSON_SIZE,
max_string_size: MAX_STRING_SIZE,
max_xml_depth: MAX_XML_DEPTH,
max_attributes_per_element: MAX_ATTRIBUTES_PER_ELEMENT,
max_child_elements: MAX_CHILD_ELEMENTS,
allow_external_entities: false, allow_dtd: false, rate_limiting_enabled: true,
max_requests_per_minute: MAX_REQUESTS_PER_MINUTE,
enable_entity_classification: true, max_entity_expansion_ratio: 10.0, max_entity_depth: 3, }
}
}
pub struct SecureXmlReader<R: BufRead> {
reader: Reader<R>,
config: SecurityConfig,
current_depth: usize,
element_count: usize,
start_time: Instant,
}
impl<R: BufRead> SecureXmlReader<R> {
pub fn new(reader: R, config: SecurityConfig) -> Self {
let mut xml_reader = Reader::from_reader(reader);
xml_reader.config_mut().check_comments = false;
xml_reader.config_mut().check_end_names = true;
xml_reader.config_mut().trim_text_start = true;
xml_reader.config_mut().trim_text_end = true;
xml_reader.config_mut().expand_empty_elements = false;
Self {
reader: xml_reader,
config,
current_depth: 0,
element_count: 0,
start_time: Instant::now(),
}
}
pub fn read_event<'a>(&mut self, buf: &'a mut Vec<u8>) -> Result<Event<'a>, BuildError> {
if self.start_time.elapsed() > Duration::from_secs(30) {
return Err(BuildError::Security("XML processing timeout".to_string()));
}
let event = self
.reader
.read_event_into(buf)
.map_err(|e| BuildError::Security(format!("XML parsing error: {}", e)))?;
match &event {
Event::Start(_) => {
self.current_depth += 1;
self.element_count += 1;
if self.current_depth > self.config.max_xml_depth {
return Err(BuildError::Security(format!(
"XML nesting too deep: {} > {}",
self.current_depth, self.config.max_xml_depth
)));
}
if self.element_count > self.config.max_child_elements {
return Err(BuildError::Security(format!(
"Too many XML elements: {} > {}",
self.element_count, self.config.max_child_elements
)));
}
}
Event::End(_) => {
self.current_depth = self.current_depth.saturating_sub(1);
}
Event::DocType(dt) => {
if !self.config.allow_dtd {
return Err(BuildError::Security(
"DTD processing not allowed".to_string(),
));
}
let dtd_str = String::from_utf8_lossy(dt.as_ref());
if DANGEROUS_ENTITY_REGEX.is_match(&dtd_str) {
return Err(BuildError::Security(
"Dangerous entity detected in DTD".to_string(),
));
}
if EXTERNAL_REF_REGEX.is_match(&dtd_str) {
return Err(BuildError::Security(
"External reference detected in DTD".to_string(),
));
}
if XML_BOMB_REGEX.is_match(&dtd_str) {
return Err(BuildError::Security(
"Potential XML bomb detected".to_string(),
));
}
}
_ => {}
}
Ok(event)
}
pub fn into_inner(self) -> Reader<R> {
self.reader
}
}
pub struct InputValidator {
config: SecurityConfig,
entity_classifier: Option<EntityClassifier>,
}
impl InputValidator {
pub fn new(config: SecurityConfig) -> Self {
let entity_classifier = if config.enable_entity_classification {
let mut classifier_config = entity_classifier::ClassifierConfig::default();
classifier_config.max_expansion_ratio = config.max_entity_expansion_ratio;
classifier_config.max_depth = config.max_entity_depth;
classifier_config.allow_external_entities = config.allow_external_entities;
Some(EntityClassifier::with_config(classifier_config))
} else {
None
};
Self {
config,
entity_classifier,
}
}
pub fn validate_string(&self, input: &str, field_name: &str) -> Result<String, BuildError> {
if input.len() > self.config.max_string_size {
return Err(BuildError::InputSanitization(format!(
"String too long for field '{}': {} > {}",
field_name,
input.len(),
self.config.max_string_size
)));
}
if input.contains('\0') {
return Err(BuildError::InputSanitization(format!(
"Null byte detected in field '{}'",
field_name
)));
}
if SQL_INJECTION_REGEX.is_match(input) {
return Err(BuildError::InputSanitization(format!(
"Potential injection attack detected in field '{}'",
field_name
)));
}
if !contains_only_safe_entities(input) {
return Err(BuildError::InputSanitization(format!(
"Dangerous entity reference detected in field '{}'",
field_name
)));
}
if input.contains("../")
|| input.contains("..\\")
|| input.contains("/etc/")
|| input.contains("C:\\")
{
return Err(BuildError::InputSanitization(format!(
"Path traversal pattern detected in field '{}'",
field_name
)));
}
let sanitized = input
.chars()
.filter(|&c| !c.is_control() || c == '\n' || c == '\r' || c == '\t')
.collect::<String>()
.trim()
.to_string();
Ok(sanitized)
}
pub fn validate_path(&self, path: &str) -> Result<PathBuf, BuildError> {
let mut config = PathValidationConfig::default();
config.allow_relative_outside_base = true; config.check_existence = false;
let path_validator = PathValidator::with_config(config);
let validated_path = path_validator.validate(path)?;
if !validated_path.warnings.is_empty() {
tracing::debug!(
"Path validation warnings for '{}': {:?}",
path,
validated_path.warnings
);
}
Ok(validated_path.normalized)
}
pub fn validate_path_with_config(
&self,
path: &str,
config: PathValidationConfig,
) -> Result<PathBuf, BuildError> {
let path_validator = PathValidator::with_config(config);
let validated_path = path_validator.validate(path)?;
if !validated_path.warnings.is_empty() {
tracing::debug!(
"Path validation warnings for '{}': {:?}",
path,
validated_path.warnings
);
}
Ok(validated_path.normalized)
}
pub fn validate_url(&self, url_str: &str) -> Result<Url, BuildError> {
let url = Url::parse(url_str)
.map_err(|e| BuildError::InputSanitization(format!("Invalid URL: {}", e)))?;
match url.scheme() {
"http" | "https" => {}
_ => {
return Err(BuildError::InputSanitization(format!(
"Unsafe URL scheme: {}",
url.scheme()
)));
}
}
if let Some(host_str) = url.host_str() {
if host_str == "localhost"
|| host_str == "127.0.0.1"
|| host_str == "::1"
|| host_str.starts_with("192.168.")
|| host_str.starts_with("10.")
|| host_str.starts_with("172.")
{
return Err(BuildError::InputSanitization(
"Private or local URLs not allowed".to_string(),
));
}
}
Ok(url)
}
pub fn validate_xml_content(&self, xml: &str) -> Result<(), BuildError> {
if xml.len() > self.config.max_xml_size {
return Err(BuildError::InputSanitization(format!(
"XML too large: {} > {}",
xml.len(),
self.config.max_xml_size
)));
}
if DANGEROUS_ENTITY_REGEX.is_match(xml) {
return Err(BuildError::Security(
"XML entity declaration detected".to_string(),
));
}
if !contains_only_safe_entities(xml) {
return Err(BuildError::Security(
"Custom entity reference detected".to_string(),
));
}
if EXTERNAL_REF_REGEX.is_match(xml) {
return Err(BuildError::Security(
"External reference detected".to_string(),
));
}
if XML_BOMB_REGEX.is_match(xml) {
return Err(BuildError::Security(
"Potential XML bomb detected".to_string(),
));
}
let entity_count = xml.matches("&").count();
if entity_count > 1000 {
return Err(BuildError::Security(
"Excessive entity usage detected".to_string(),
));
}
Ok(())
}
pub fn validate_entities(&mut self, entities: &[Entity]) -> Result<(), BuildError> {
if let Some(ref mut classifier) = self.entity_classifier {
let result = classifier.validate_entity_chain(entities);
if !result.is_safe {
let error_msg = if !result.errors.is_empty() {
result.errors.join("; ")
} else {
format!("Entity validation failed: {:?}", result.classification)
};
return Err(BuildError::Security(error_msg));
}
if !result.warnings.is_empty() {
warn!("Entity validation warnings: {}", result.warnings.join("; "));
}
debug!(
"Entity validation metrics: {} entities, {:.2}x expansion, {}ms processing",
result.metrics.entity_count,
result.metrics.expansion_ratio,
result.metrics.processing_time_ms
);
}
Ok(())
}
pub fn classify_entity(&mut self, name: &str, value: &str) -> EntityClass {
if let Some(ref mut classifier) = self.entity_classifier {
classifier.classify_entity(name, value)
} else {
if contains_only_safe_entities(&format!("&{};", name)) {
EntityClass::SafeBuiltin
} else {
EntityClass::CustomLocal
}
}
}
pub fn get_entity_metrics(&self) -> Option<Vec<EntityMetrics>> {
self.entity_classifier
.as_ref()
.map(|classifier| classifier.get_metrics_history().iter().cloned().collect())
}
pub fn validate_json_content(&self, json: &str) -> Result<(), BuildError> {
if json.len() > self.config.max_json_size {
return Err(BuildError::InputSanitization(format!(
"JSON too large: {} > {}",
json.len(),
self.config.max_json_size
)));
}
if SQL_INJECTION_REGEX.is_match(json) {
return Err(BuildError::InputSanitization(
"Potential injection in JSON".to_string(),
));
}
let depth = json
.chars()
.fold((0i32, 0i32), |(max_depth, current_depth), c| match c {
'{' | '[' => (max_depth.max(current_depth + 1), current_depth + 1),
'}' | ']' => (max_depth, current_depth.saturating_sub(1)),
_ => (max_depth, current_depth),
})
.0;
if depth > self.config.max_xml_depth as i32 {
return Err(BuildError::InputSanitization(format!(
"JSON nesting too deep: {}",
depth
)));
}
Ok(())
}
}
#[derive(Debug)]
pub struct RateLimiter {
requests: indexmap::IndexMap<String, Vec<Instant>>,
config: SecurityConfig,
}
impl RateLimiter {
pub fn new(config: SecurityConfig) -> Self {
Self {
requests: indexmap::IndexMap::new(),
config,
}
}
pub fn check_rate_limit(&mut self, identifier: &str) -> Result<(), BuildError> {
if !self.config.rate_limiting_enabled {
return Ok(());
}
let now = Instant::now();
let requests = self.requests.entry(identifier.to_string()).or_default();
requests.retain(|&req_time| now.duration_since(req_time) <= RATE_LIMIT_WINDOW);
if requests.len() >= self.config.max_requests_per_minute as usize {
return Err(BuildError::Security(format!(
"Rate limit exceeded for {}",
identifier
)));
}
requests.push(now);
Ok(())
}
pub fn cleanup(&mut self) {
let now = Instant::now();
self.requests.retain(|_, requests| {
requests.retain(|&req_time| now.duration_since(req_time) <= RATE_LIMIT_WINDOW);
!requests.is_empty()
});
}
}
#[derive(Debug)]
pub struct OutputSanitizer {
#[allow(dead_code)]
config: SecurityConfig,
}
impl OutputSanitizer {
pub fn new(config: SecurityConfig) -> Self {
Self { config }
}
pub fn sanitize_xml_output(&self, xml: &str) -> Result<String, BuildError> {
self.check_for_sensitive_data(xml)?;
self.validate_xml_structure(xml)?;
let sanitized = self.escape_xml_entities(xml);
Ok(sanitized)
}
fn check_for_sensitive_data(&self, content: &str) -> Result<(), BuildError> {
let sensitive_patterns = [
r"<password[^>]*>[^<]+</password>",
r"<secret[^>]*>[^<]+</secret>",
r"<key[^>]*>[^<]+</key>",
r"<token[^>]*>[^<]+</token>",
r"password\s*[:=]\s*[^\s<]+",
r"secret\s*[:=]\s*[^\s<]+",
r"key\s*[:=]\s*[^\s<]+",
r"token\s*[:=]\s*[^\s<]+",
r"[A-Za-z0-9+/]{40,}={0,2}", ];
for pattern in &sensitive_patterns {
if let Ok(regex) = regex::Regex::new(pattern) {
if regex.is_match(content) {
return Err(BuildError::Security(
"Potential sensitive data detected in output".to_string(),
));
}
}
}
Ok(())
}
fn escape_xml_entities(&self, xml: &str) -> String {
html_escape::encode_text(xml).to_string()
}
fn validate_xml_structure(&self, xml: &str) -> Result<(), BuildError> {
let mut reader = quick_xml::Reader::from_str(xml);
reader.config_mut().expand_empty_elements = false;
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut depth = 0;
loop {
match reader.read_event_into(&mut buf) {
Ok(quick_xml::events::Event::Start(_)) => {
depth += 1;
if depth > MAX_XML_DEPTH {
return Err(BuildError::Security(
"XML depth limit exceeded in output".to_string(),
));
}
}
Ok(quick_xml::events::Event::End(_)) => {
depth = depth.saturating_sub(1);
}
Ok(quick_xml::events::Event::Eof) => break,
Ok(_) => {}
Err(e) => {
return Err(BuildError::Security(format!(
"Invalid XML structure in output: {}",
e
)));
}
}
buf.clear();
}
Ok(())
}
pub fn create_secure_log_message(
&self,
operation: &str,
success: bool,
details: Option<&str>,
) -> String {
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
let status = if success { "SUCCESS" } else { "FAILED" };
match details {
Some(detail) if detail.len() < 100 => {
let sanitized_detail = self.sanitize_log_detail(detail);
format!(
"[{}] {} - {}: {}",
timestamp, operation, status, sanitized_detail
)
}
_ => {
format!("[{}] {} - {}", timestamp, operation, status)
}
}
}
fn sanitize_log_detail(&self, detail: &str) -> String {
let sensitive_patterns = [
(r"password\s*[:=]\s*[^\s]+", "password=[REDACTED]"),
(r"secret\s*[:=]\s*[^\s]+", "secret=[REDACTED]"),
(r"key\s*[:=]\s*[^\s]+", "key=[REDACTED]"),
(r"token\s*[:=]\s*[^\s]+", "token=[REDACTED]"),
];
let mut sanitized = detail.to_string();
for (pattern, replacement) in &sensitive_patterns {
if let Ok(regex) = regex::Regex::new(pattern) {
sanitized = regex.replace_all(&sanitized, *replacement).to_string();
}
}
if sanitized.len() > 200 {
sanitized.truncate(197);
sanitized.push_str("...");
}
sanitized
}
}
pub struct SecureTempFile {
path: PathBuf,
file: std::fs::File,
}
impl SecureTempFile {
pub fn new() -> Result<Self, BuildError> {
use std::fs::OpenOptions;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
let temp_dir = std::env::temp_dir();
let file_name = format!("ddex_builder_{}", uuid::Uuid::new_v4());
let path = temp_dir.join(file_name);
#[cfg(unix)]
let file = OpenOptions::new()
.create_new(true)
.write(true)
.read(true)
.mode(0o600) .open(&path)
.map_err(|e| BuildError::Io(format!("Failed to create secure temp file: {}", e)))?;
#[cfg(not(unix))]
let file = OpenOptions::new()
.create_new(true)
.write(true)
.read(true)
.open(&path)
.map_err(|e| BuildError::Io(format!("Failed to create secure temp file: {}", e)))?;
Ok(Self { path, file })
}
pub fn file(&mut self) -> &mut std::fs::File {
&mut self.file
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for SecureTempFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub mod utils {
pub fn sanitize_filename(filename: &str) -> String {
filename
.chars()
.filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
.take(255) .collect::<String>()
.replace("..", "") }
pub fn generate_secure_id() -> String {
uuid::Uuid::new_v4().to_string()
}
pub fn constant_time_compare(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut result = 0u8;
for (byte_a, byte_b) in a.bytes().zip(b.bytes()) {
result |= byte_a ^ byte_b;
}
result == 0
}
pub fn hash_for_logging(data: &str) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(data.as_bytes());
format!("{:.8}", hex::encode(hash))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_input_validation() {
let config = SecurityConfig::default();
let validator = InputValidator::new(config);
assert!(validator.validate_string("Valid input", "test").is_ok());
assert!(validator.validate_string("Invalid\0input", "test").is_err());
assert!(validator
.validate_string("'; DROP TABLE users; --", "test")
.is_err());
assert!(validator.validate_string("&dangerous;", "test").is_err());
}
#[test]
fn test_path_validation() {
let config = SecurityConfig::default();
let validator = InputValidator::new(config);
assert!(validator.validate_path("safe/path/file.xml").is_ok());
assert!(validator.validate_path("../../../etc/passwd").is_err());
assert!(validator.validate_path("/etc/passwd").is_err());
}
#[test]
fn test_xml_security() {
let config = SecurityConfig::default();
let validator = InputValidator::new(config);
assert!(validator
.validate_xml_content("<root><child>content</child></root>")
.is_ok());
assert!(validator
.validate_xml_content(
"<!DOCTYPE test [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><root>&xxe;</root>"
)
.is_err());
assert!(validator.validate_xml_content(
"<!DOCTYPE bomb [<!ENTITY a '&b;&b;'><!ENTITY b '&c;&c;'><!ENTITY c 'boom'>]><root>&a;</root>"
).is_err());
}
#[test]
fn test_secure_xml_reader() {
let config = SecurityConfig::default();
let xml = b"<root><child>content</child></root>";
let cursor = Cursor::new(xml);
let mut reader = SecureXmlReader::new(cursor, config);
let mut buf = Vec::new();
loop {
match reader.read_event(&mut buf) {
Ok(Event::Eof) => break,
Ok(_) => {
buf.clear();
continue;
}
Err(e) => panic!("Unexpected error: {}", e),
}
}
}
#[test]
fn test_rate_limiter() {
let config = SecurityConfig {
rate_limiting_enabled: true,
max_requests_per_minute: 2,
..SecurityConfig::default()
};
let mut limiter = RateLimiter::new(config);
assert!(limiter.check_rate_limit("user1").is_ok());
assert!(limiter.check_rate_limit("user1").is_ok());
assert!(limiter.check_rate_limit("user1").is_err());
assert!(limiter.check_rate_limit("user2").is_ok());
}
#[test]
fn test_url_validation() {
let config = SecurityConfig::default();
let validator = InputValidator::new(config);
assert!(validator.validate_url("https://example.com/path").is_ok());
assert!(validator.validate_url("http://192.168.1.1/").is_err());
assert!(validator.validate_url("http://localhost:8080/").is_err());
assert!(validator.validate_url("file:///etc/passwd").is_err());
}
#[test]
fn test_output_sanitizer() {
let config = SecurityConfig::default();
let sanitizer = OutputSanitizer::new(config);
let safe_xml = "<root><child>content</child></root>";
assert!(sanitizer.sanitize_xml_output(safe_xml).is_ok());
let sensitive_xml = "<root><password>secret123</password></root>";
let result = sanitizer.sanitize_xml_output(sensitive_xml);
assert!(
result.is_err(),
"Expected sensitive data to be detected, but got: {:?}",
result
);
let malformed_xml = "<root><child>content</child><"; let result = sanitizer.sanitize_xml_output(malformed_xml);
assert!(
result.is_err(),
"Expected malformed XML to be rejected, but got: {:?}",
result
);
}
#[test]
fn test_secure_logging() {
let config = SecurityConfig::default();
let sanitizer = OutputSanitizer::new(config);
let log_msg = sanitizer.create_secure_log_message("BUILD", true, Some("file.xml"));
assert!(log_msg.contains("BUILD"));
assert!(log_msg.contains("SUCCESS"));
assert!(log_msg.contains("file.xml"));
let sensitive_detail = "password=secret123 key=abc";
let log_msg = sanitizer.create_secure_log_message("LOGIN", false, Some(sensitive_detail));
assert!(log_msg.contains("[REDACTED]"));
assert!(!log_msg.contains("secret123"));
assert!(!log_msg.contains("abc"));
}
#[test]
fn test_security_utils() {
let clean_name = utils::sanitize_filename("../../../etc/passwd");
assert!(!clean_name.contains(".."));
assert!(!clean_name.contains("/"));
let id1 = utils::generate_secure_id();
let id2 = utils::generate_secure_id();
assert_ne!(id1, id2);
assert_eq!(id1.len(), 36);
assert!(utils::constant_time_compare("test", "test"));
assert!(!utils::constant_time_compare("test", "other"));
assert!(!utils::constant_time_compare("test", "testing"));
let hash = utils::hash_for_logging("sensitive_data");
assert_eq!(hash.len(), 8);
assert!(!hash.contains("sensitive"));
}
}