Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
runtime/*.jsonl
runtime/*.log
runtime/attack_events.jsonl
configs/runtime/

125 changes: 97 additions & 28 deletions src/adaptive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ impl AdaptiveDetector {
poisoned.into_inner()
}
};
if !windows.entries.contains_key(signature) {
let window = if let Some(window) = windows.entries.get_mut(signature) {
window
} else {
if !ensure_signature_window_capacity(
&mut windows,
self.max_signature_windows_per_shard,
Expand All @@ -240,18 +242,19 @@ impl AdaptiveDetector {
emit: false,
};
}
windows.order.push_back(signature.to_string());
}
let window = windows
.entries
.entry(signature.to_string())
.or_insert_with(|| SignatureWindow {
bucket: adaptive_bucket(self.threshold_per_second, now),
observed_count: 0,
last_event: None,
last_strong_event: None,
last_seen: now,
});
let signature_owned = signature.to_string();
windows.order.push_back(signature_owned.clone());
windows
.entries
.entry(signature_owned)
.or_insert_with(|| SignatureWindow {
bucket: adaptive_bucket(self.threshold_per_second, now),
observed_count: 0,
last_event: None,
last_strong_event: None,
last_seen: now,
})
};
window.last_seen = now;
window.observed_count = window.observed_count.saturating_add(1);
let count = window.observed_count;
Expand Down Expand Up @@ -294,28 +297,31 @@ impl AdaptiveDetector {
poisoned.into_inner()
}
};
if !windows.entries.contains_key(path_shape) {
let window = if let Some(window) = windows.entries.get_mut(path_shape) {
window
} else {
if !ensure_shape_window_capacity(
&mut windows,
self.max_path_shape_windows_per_shard,
now,
) {
return None;
}
windows.order.push_back(path_shape.to_string());
}
let window = windows
.entries
.entry(path_shape.to_string())
.or_insert_with(|| ShapeWindow {
bucket: adaptive_bucket(self.threshold_per_second, now),
strong_bucket: adaptive_bucket(self.threshold_per_second, now),
observed_count: 0,
strong_count: 0,
sample_window_start: now,
emitted_samples: 0,
last_seen: now,
});
let path_shape_owned = path_shape.to_string();
windows.order.push_back(path_shape_owned.clone());
windows
.entries
.entry(path_shape_owned)
.or_insert_with(|| ShapeWindow {
bucket: adaptive_bucket(self.threshold_per_second, now),
strong_bucket: adaptive_bucket(self.threshold_per_second, now),
observed_count: 0,
strong_count: 0,
sample_window_start: now,
emitted_samples: 0,
last_seen: now,
})
};
window.last_seen = now;
window.observed_count = window.observed_count.saturating_add(1);
if now.saturating_duration_since(window.sample_window_start) >= Duration::from_secs(1) {
Expand Down Expand Up @@ -1266,6 +1272,69 @@ mod tests {
}
}

fn signature_observed_count(detector: &AdaptiveDetector, signature: &str) -> u64 {
let shard_idx = shard_for(signature);
let windows = match detector.windows[shard_idx].lock() {
Ok(windows) => windows,
Err(poisoned) => poisoned.into_inner(),
};
windows
.entries
.get(signature)
.map(|window| window.observed_count)
.unwrap_or(0)
}

#[tokio::test]
async fn body_too_large_hot_path_observes_before_strong_evidence() {
let engine = FilterEngine::new(
vec![],
PathBuf::from("/tmp/altura-prot-nonexistent-body-too-large-filters.json"),
Duration::from_secs(30),
)
.await;
let logger = Arc::new(
EventLogger::new("/tmp/altura-prot-test-body-too-large-events.jsonl").unwrap(),
);
let detector = AdaptiveDetector::new(
test_detector_config(60, 8_192, 8_192),
Arc::clone(&engine),
logger,
);
let headers = HeaderMap::new();
let signature = crate::filter::request_signature("POST", "/upload", None, &headers);
let ctx = RequestContext {
client_ip: "127.0.0.1".parse().unwrap(),
method: "POST",
path: "/upload",
query: None,
headers: &headers,
signature: signature.clone(),
};
let path_shape = request_path_shape("/upload");

detector.observe_with_path_shape(&ctx, "body_too_large", &path_shape);
assert_eq!(signature_observed_count(&detector, &signature), 1);

let engine = FilterEngine::new(
vec![],
PathBuf::from("/tmp/altura-prot-nonexistent-body-too-large-hot-path-filters.json"),
Duration::from_secs(30),
)
.await;
let logger = Arc::new(
EventLogger::new("/tmp/altura-prot-test-body-too-large-hot-path-events.jsonl").unwrap(),
);
let detector = AdaptiveDetector::new(
test_detector_config(60, 8_192, 8_192),
Arc::clone(&engine),
logger,
);
detector.observe_with_path_shape(&ctx, "observed", &path_shape);
detector.observe_with_path_shape(&ctx, "body_too_large", &path_shape);
assert_eq!(signature_observed_count(&detector, &signature), 2);
}

fn same_signature_shard_keys(prefix: &str, count: usize) -> Vec<String> {
let mut selected = Vec::new();
let mut target_shard = None;
Expand Down
109 changes: 85 additions & 24 deletions src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,9 @@ impl FilterEvalScratch {
.as_str()
}

fn matches_path_shape(&mut self, path: &str, expected: &str) -> bool {
if self.path_shape(path) == expected {
fn matches_path_shape(&mut self, path: &str, expected: &str, hint: Option<&str>) -> bool {
let shape = hint.unwrap_or_else(|| self.path_shape(path));
if shape == expected {
return true;
}
let parent = self
Expand Down Expand Up @@ -613,6 +614,14 @@ impl FilterEngine {
}

pub fn evaluate(&self, ctx: &RequestContext<'_>) -> Option<FilterDecision> {
self.evaluate_with_path_shape(ctx, None)
}

pub fn evaluate_with_path_shape(
&self,
ctx: &RequestContext<'_>,
path_shape_hint: Option<&str>,
) -> Option<FilterDecision> {
let unix_ms = unix_time_ms();
let rules = self.rules_snapshot("evaluate");
let mut scratch = FilterEvalScratch::default();
Expand All @@ -624,7 +633,7 @@ impl FilterEngine {
if rule.adaptive && runtime.active_until_ms.load(Ordering::Relaxed) <= unix_ms {
continue;
}
if rule_matches(runtime, ctx, &mut scratch) {
if rule_matches(runtime, ctx, &mut scratch, path_shape_hint) {
return Some(FilterDecision {
rule_id: rule.id.clone(),
status: rule.action.status,
Expand Down Expand Up @@ -722,11 +731,9 @@ pub fn request_signature(
query: Option<&str>,
headers: &HeaderMap<HeaderValue>,
) -> String {
let basis = signature_basis(method, path, query, headers);
hex_prefix(
blake3::hash(basis.as_bytes()).as_bytes(),
REQUEST_SIGNATURE_HASH_BYTES,
)
let mut hasher = blake3::Hasher::new();
append_signature_basis(&mut hasher, method, path, query, headers);
hex_prefix(hasher.finalize().as_bytes(), REQUEST_SIGNATURE_HASH_BYTES)
}

pub fn legacy_request_signature(
Expand All @@ -735,7 +742,8 @@ pub fn legacy_request_signature(
query: Option<&str>,
headers: &HeaderMap<HeaderValue>,
) -> String {
let basis = signature_basis(method, path, query, headers);
let mut basis = String::with_capacity(128);
append_signature_basis_string(&mut basis, method, path, query, headers);
format!("{:016x}", fnv1a64(basis.as_bytes()))
}

Expand All @@ -745,26 +753,72 @@ pub fn signature_basis(
query: Option<&str>,
headers: &HeaderMap<HeaderValue>,
) -> String {
let ua = combined_header_value(headers, &http::header::USER_AGENT)
.map(|value| user_agent_family(value.as_ref()))
.unwrap_or_else(|| "empty".to_string());
let accept = combined_header_value(headers, &http::header::ACCEPT)
.map(|value| header_class(value.as_ref()))
.unwrap_or_else(|| "empty".to_string());
format!(
"{}|{}|{}|{}|{}",
method.to_ascii_uppercase(),
normalize_path(path),
query_shape(query.unwrap_or("")),
ua,
accept
)
let mut basis = String::with_capacity(128);
append_signature_basis_string(&mut basis, method, path, query, headers);
basis
}

fn append_signature_basis_string(
out: &mut String,
method: &str,
path: &str,
query: Option<&str>,
headers: &HeaderMap<HeaderValue>,
) {
for byte in method.bytes() {
out.push(byte.to_ascii_uppercase() as char);
}
out.push('|');
out.push_str(&normalize_path(path));
out.push('|');
out.push_str(&query_shape(query.unwrap_or("")));
out.push('|');
match combined_header_value(headers, &http::header::USER_AGENT) {
Some(value) => out.push_str(&user_agent_family(value.as_ref())),
None => out.push_str("empty"),
}
out.push('|');
match combined_header_value(headers, &http::header::ACCEPT) {
Some(value) => out.push_str(&header_class(value.as_ref())),
None => out.push_str("empty"),
}
}

fn append_signature_basis(
hasher: &mut blake3::Hasher,
method: &str,
path: &str,
query: Option<&str>,
headers: &HeaderMap<HeaderValue>,
) {
for byte in method.bytes() {
hasher.update(&[byte.to_ascii_uppercase()]);
}
hasher.update(b"|");
let normalized_path = normalize_path(path);
hasher.update(normalized_path.as_bytes());
hasher.update(b"|");
let query = query_shape(query.unwrap_or(""));
hasher.update(query.as_bytes());
hasher.update(b"|");
if let Some(value) = combined_header_value(headers, &http::header::USER_AGENT) {
hasher.update(user_agent_family(value.as_ref()).as_bytes());
} else {
hasher.update(b"empty");
}
hasher.update(b"|");
if let Some(value) = combined_header_value(headers, &http::header::ACCEPT) {
hasher.update(header_class(value.as_ref()).as_bytes());
} else {
hasher.update(b"empty");
}
}

fn rule_matches(
runtime: &RuntimeRule,
ctx: &RequestContext<'_>,
scratch: &mut FilterEvalScratch,
path_shape_hint: Option<&str>,
) -> bool {
let rule = &runtime.rule;
let compiled = &runtime.condition;
Expand Down Expand Up @@ -801,7 +855,7 @@ fn rule_matches(
}
}
if let Some(path_shape) = &rule.condition.path_shape {
if !scratch.matches_path_shape(ctx.path, path_shape) {
if !scratch.matches_path_shape(ctx.path, path_shape, path_shape_hint) {
return false;
}
}
Expand Down Expand Up @@ -2218,6 +2272,13 @@ mod tests {
};
assert!(engine.evaluate(&matching).is_some());
assert!(engine.evaluate(&benign).is_none());
let hinted_shape = request_path_shape(matching.path);
assert_eq!(
engine
.evaluate_with_path_shape(&matching, Some(&hinted_shape))
.map(|decision| decision.rule_id),
engine.evaluate(&matching).map(|decision| decision.rule_id)
);
}

#[tokio::test]
Expand Down
Loading
Loading