use crate::handlers::pxcrypto::sha256_hex;
use crate::modules::pxconstants::{
ACTIVITY_API, ADDITIONAL_S2S_ACTIVITY_HEADER_NAME, ADDITIONAL_S2S_URL_HEADER_NAME,
APPLICATION_JSON, CI_BODY_MAX_LENGTH, COMPROMISED_CREDENTIALS_HEADER_VALUE,
DEFAULT_COMPROMISED_CREDENTIALS_HEADER,
};
use crate::modules::pxutils::{get_value_at_path, set_json_int, set_json_str};
use crate::px_debug;
use crate::pxconfig::{
PXConfig, PXCredentialEndpointConfig, PXPreparedCredentialEndpoint, PXRawCredentials,
};
use crate::pxcontext::PXContext;
use fastly::{Body, Request, Response};
use regex::Regex;
use serde_json::json;
pub(crate) const CI_VERSION_V2: &str = "v2";
pub(crate) const CI_VERSION_MULTISTEP: &str = "multistep_sso";
pub(crate) const CI_VERSION_BOTH: &str = "both";
pub(crate) const CI_VERSION_V1: &str = "v1";
pub(crate) const SSO_STEP_USER: &str = "user";
const SSO_STEP_PASS: &str = "pass";
#[derive(Debug, Clone, Default)]
pub struct PXCredentialIntelligenceData {
pub endpoint_index: usize,
pub ci_version: String,
pub user: Option<String>,
pub pass: Option<String>,
pub sso_step: Option<String>,
pub raw_username: Option<String>,
pub is_login_successful: Option<bool>,
pub response_status_code: Option<u16>,
}
pub(crate) fn enrich_context_from_request(req: &mut Request, conf: &PXConfig, ctx: &mut PXContext) {
if !conf.login_credentials_extraction_enabled {
return;
}
let endpoint_index = match find_matching_endpoint(req, conf) {
Some(i) => i,
None => return,
};
let raw = match extract_credentials(req, conf, endpoint_index) {
Some(c) if c.user.is_some() || c.pass.is_some() => c,
_ => return,
};
let protocol = resolve_protocol(conf, endpoint_index);
if protocol == CI_VERSION_V1 {
px_debug!("credentials intelligence v1 is not supported");
return;
}
let mut hashed = match hash_credentials(protocol, &raw) {
Some(h) => h,
None => return,
};
hashed.endpoint_index = endpoint_index;
ctx.credential_intelligence = Some(hashed);
ctx.is_sensitive_route = true;
}
pub(crate) fn is_credentials_compromised(ctx: &PXContext) -> bool {
ctx.get_data_enrichment()
.map(|de| de.get_breached_account() == 1)
.unwrap_or(false)
}
pub(crate) fn modify_incoming_request(req: &mut Request, conf: &PXConfig, ctx: &PXContext) {
if ctx.credential_intelligence.is_none() {
return;
}
if is_credentials_compromised(ctx) {
let header_name = if conf.compromised_credentials_header.is_empty() {
DEFAULT_COMPROMISED_CREDENTIALS_HEADER
} else {
conf.compromised_credentials_header.as_str()
};
req.set_header(header_name, COMPROMISED_CREDENTIALS_HEADER_VALUE);
}
if conf.additional_s2s_activity_enabled || !conf.additional_s2s_activity_header_enabled {
return;
}
let activity = build_additional_s2s_activity_base(conf, ctx);
let activity_json = match serde_json::to_string(&activity) {
Ok(s) => s,
Err(e) => {
px_debug!("failed to serialize additional_s2s activity: {}", e);
return;
}
};
let url = format!("https://{}{}", conf.human_collector_host, ACTIVITY_API);
req.set_header(ADDITIONAL_S2S_ACTIVITY_HEADER_NAME, activity_json);
req.set_header(ADDITIONAL_S2S_URL_HEADER_NAME, url);
}
pub(crate) fn enrich_context_from_response(
resp: &mut Response,
conf: &PXConfig,
ctx: &mut PXContext,
) {
let Some(ci) = ctx.credential_intelligence.as_mut() else {
return;
};
let status = resp.get_status().as_u16();
ci.response_status_code = Some(status);
let endpoint_index = ci.endpoint_index;
ci.is_login_successful = evaluate_login_successful(resp, conf, endpoint_index, status);
}
pub(crate) fn send_additional_s2s(ctx: &PXContext, conf: &PXConfig) {
if !conf.additional_s2s_activity_enabled {
return;
}
let Some(ci) = ctx.credential_intelligence.as_ref() else {
return;
};
if ctx
.block_reason
.as_ref()
.is_some_and(|r| *r != crate::pxcontext::BlockReason::None)
{
return;
}
let mut activity = build_additional_s2s_activity_base(conf, ctx);
if let Some(details) = activity.get_mut("details").and_then(|v| v.as_object_mut()) {
if let Some(login_ok) = ci.is_login_successful {
details.insert("login_successful".to_owned(), json!(login_ok));
}
if let Some(status) = ci.response_status_code {
details.insert("http_status_code".to_owned(), json!(status));
}
if should_send_raw_username(conf, ctx, ci) {
if let Some(raw) = &ci.raw_username {
details.insert("raw_username".to_owned(), json!(raw));
}
}
}
post_activity_payload(activity, ctx, conf);
}
pub(crate) fn apply_ci_fields_to_details(
details: &mut serde_json::Value,
ctx: &PXContext,
include_credentials: bool,
include_compromised: bool,
) {
let Some(ci) = ctx.credential_intelligence.as_ref() else {
return;
};
if !ci.ci_version.is_empty() {
set_json_str!(details, "ci_version"; ci.ci_version);
}
if include_credentials {
if let Some(u) = &ci.user {
set_json_str!(details, "user"; u);
}
if let Some(p) = &ci.pass {
set_json_str!(details, "pass"; p);
}
}
if let Some(step) = &ci.sso_step {
set_json_str!(details, "sso_step"; step);
}
if include_compromised && ctx.credential_intelligence.is_some() {
details["credentials_compromised"] = json!(is_credentials_compromised(ctx));
}
}
fn should_send_raw_username(
conf: &PXConfig,
ctx: &PXContext,
ci: &PXCredentialIntelligenceData,
) -> bool {
if !conf.send_raw_username_on_additional_s2s_activity {
return false;
}
if !is_credentials_compromised(ctx) {
return false;
}
match ci.is_login_successful {
Some(true) => true,
None => true,
Some(false) => false,
}
}
fn find_matching_endpoint(req: &Request, conf: &PXConfig) -> Option<usize> {
let path = req.get_path();
let method = req.get_method_str();
for (index, endpoint) in conf.prepared_ci_endpoints.iter().enumerate() {
if endpoint_matches(endpoint, path, method) {
return Some(index);
}
}
None
}
pub(crate) fn endpoint_matches(
endpoint: &PXPreparedCredentialEndpoint,
path: &str,
method: &str,
) -> bool {
if !endpoint.config.method.eq_ignore_ascii_case(method) {
return false;
}
if endpoint.config.path_type.eq_ignore_ascii_case("regex") {
endpoint
.path_regex
.as_ref()
.is_some_and(|re| re.is_match(path))
} else {
endpoint.config.path == path
}
}
fn extract_credentials(
req: &mut Request,
conf: &PXConfig,
endpoint_index: usize,
) -> Option<PXRawCredentials> {
let endpoint = conf.prepared_ci_endpoints.get(endpoint_index)?;
let sent_through = endpoint.config.sent_through.as_str();
let raw = if sent_through.eq_ignore_ascii_case("custom") {
conf.ci_extract_credentials_fn
.and_then(|f| f(req, endpoint_index))
} else if sent_through.eq_ignore_ascii_case("header") {
extract_from_headers(req, &endpoint.config)
} else if sent_through.eq_ignore_ascii_case("query-param") {
extract_from_query(req, &endpoint.config)
} else if sent_through.eq_ignore_ascii_case("body") {
extract_from_body(req, &endpoint.config)
} else {
None
}?;
let raw = raw.without_empty_fields();
(raw.user.is_some() || raw.pass.is_some()).then_some(raw)
}
fn extract_from_headers(
req: &Request,
config: &PXCredentialEndpointConfig,
) -> Option<PXRawCredentials> {
let user = read_header_field(req, &config.user_field);
let pass = read_header_field(req, &config.pass_field);
if user.is_some() || pass.is_some() {
Some(PXRawCredentials { user, pass })
} else {
None
}
}
fn read_header_field(req: &Request, name: &str) -> Option<String> {
if name.is_empty() {
return None;
}
req.get_header_str_lossy(name)
.map(|v| v.into_owned())
.filter(|v| !v.is_empty())
}
fn extract_from_query(
req: &Request,
config: &PXCredentialEndpointConfig,
) -> Option<PXRawCredentials> {
let query = req.get_url().query().unwrap_or_default();
let params = parse_urlencoded(query);
let user = params.get(&config.user_field).cloned();
let pass = params.get(&config.pass_field).cloned();
if user.is_some() || pass.is_some() {
Some(PXRawCredentials { user, pass })
} else {
None
}
}
fn extract_from_body(
req: &mut Request,
config: &PXCredentialEndpointConfig,
) -> Option<PXRawCredentials> {
let content_type = req
.get_header_str_lossy("content-type")
.map(|v| v.into_owned())
.unwrap_or_default();
if content_type.is_empty() {
return None;
}
if req.get_content_length().unwrap_or(0) > CI_BODY_MAX_LENGTH {
return None;
}
let body = req.get_body_prefix_mut(CI_BODY_MAX_LENGTH);
if body.is_empty() {
return None;
}
let body_str = std::str::from_utf8(body.as_slice()).ok()?;
if content_type.contains("json") {
let json: serde_json::Value = serde_json::from_str(body_str).ok()?;
let user = read_json_field(&json, &config.user_field);
let pass = read_json_field(&json, &config.pass_field);
if user.is_some() || pass.is_some() {
return Some(PXRawCredentials { user, pass });
}
return None;
}
if content_type.contains("application/x-www-form-urlencoded") {
let params = parse_urlencoded(body_str);
let user = params.get(&config.user_field).cloned();
let pass = params.get(&config.pass_field).cloned();
if user.is_some() || pass.is_some() {
return Some(PXRawCredentials { user, pass });
}
return None;
}
if content_type.contains("multipart/form-data") {
let boundary = parse_multipart_boundary(&content_type)?;
return extract_from_multipart(body_str, boundary, config);
}
None
}
fn read_json_field(json: &serde_json::Value, field: &str) -> Option<String> {
if field.is_empty() {
return None;
}
get_value_at_path(json, field).and_then(|v| v.as_str().map(str::to_owned))
}
pub(crate) fn parse_multipart_boundary(content_type: &str) -> Option<String> {
content_type.split(';').map(str::trim).find_map(|part| {
part.strip_prefix("boundary=")
.map(str::trim)
.map(|boundary| boundary.trim_matches(['"', '\'']))
.map(str::to_owned)
.filter(|boundary| !boundary.is_empty())
})
}
pub(crate) fn extract_from_multipart(
body: &str,
boundary: String,
config: &PXCredentialEndpointConfig,
) -> Option<PXRawCredentials> {
let delimiter = format!("--{boundary}");
let mut user = None;
let mut pass = None;
for part in body.split(&delimiter) {
let part = part.trim();
if part.is_empty() || part == "--" {
continue;
}
let Some(name) = parse_multipart_field_name(part) else {
continue;
};
let value = parse_multipart_field_value(part);
if name == config.user_field {
user = value;
} else if name == config.pass_field {
pass = value;
}
}
if user.is_some() || pass.is_some() {
Some(PXRawCredentials { user, pass })
} else {
None
}
}
fn parse_multipart_field_name(part: &str) -> Option<String> {
for line in part.lines() {
let lower = line.to_ascii_lowercase();
if lower.starts_with("content-disposition:") && lower.contains("name=") {
let start = line.find("name=")? + 5;
let rest = line.get(start..)?;
let name = rest.trim_matches(['"', '\'', ' ']);
let name = name.split(';').next()?.trim_matches(['"', '\'']);
if !name.is_empty() {
return Some(name.to_owned());
}
}
}
None
}
fn parse_multipart_field_value(part: &str) -> Option<String> {
let mut lines = part.lines();
while let Some(line) = lines.next() {
if line.is_empty() {
let value: String = lines.collect::<Vec<_>>().join("\n");
let value = value.trim_end_matches('\r').trim().to_owned();
if value.is_empty() {
return None;
}
return Some(value);
}
}
None
}
pub(crate) fn parse_urlencoded(input: &str) -> std::collections::HashMap<String, String> {
let mut map = std::collections::HashMap::new();
for pair in input.split('&') {
if pair.is_empty() {
continue;
}
let (key, value) = match pair.split_once('=') {
Some((k, v)) => (k, v),
None => (pair, ""),
};
let key = percent_decode(key.replace('+', " ").as_str());
let value = percent_decode(value.replace('+', " ").as_str());
if !key.is_empty() {
map.insert(key, value);
}
}
map
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let Some(&byte) = bytes.get(i) else {
break;
};
if byte == b'%' {
if let (Some(&b1), Some(&b2)) = (bytes.get(i + 1), bytes.get(i + 2)) {
let hex = [b1, b2];
if let Ok(hex_str) = std::str::from_utf8(&hex) {
if let Ok(decoded) = u8::from_str_radix(hex_str, 16) {
out.push(decoded);
i += 3;
continue;
}
}
}
}
out.push(byte);
i += 1;
}
String::from_utf8(out).unwrap_or_default()
}
fn resolve_protocol(conf: &PXConfig, endpoint_index: usize) -> &str {
conf.prepared_ci_endpoints
.get(endpoint_index)
.and_then(|e| {
if e.config.protocol.is_empty() {
None
} else {
Some(e.config.protocol.as_str())
}
})
.unwrap_or(conf.credentials_intelligence_version.as_str())
}
pub(crate) fn hash_credentials(
protocol: &str,
raw: &PXRawCredentials,
) -> Option<PXCredentialIntelligenceData> {
match protocol {
CI_VERSION_V2 => hash_v2(raw),
CI_VERSION_MULTISTEP => hash_multistep(raw),
CI_VERSION_BOTH => {
if raw.user.is_some() && raw.pass.is_some() {
hash_v2(raw)
} else {
hash_multistep(raw)
}
}
CI_VERSION_V1 => None,
_ => hash_v2(raw),
}
}
pub(crate) fn hash_v2(raw: &PXRawCredentials) -> Option<PXCredentialIntelligenceData> {
let user = raw.user.as_deref()?;
let pass = raw.pass.as_deref()?;
let normalized = normalize_username(user);
let hashed_user = sha256_hex(&normalized)?;
let hashed_pass = sha256_hex(&format!("{}{}", hashed_user, sha256_hex(pass)?))?;
Some(PXCredentialIntelligenceData {
ci_version: CI_VERSION_V2.to_owned(),
user: Some(hashed_user),
pass: Some(hashed_pass),
raw_username: Some(user.to_owned()),
..Default::default()
})
}
fn hash_multistep(raw: &PXRawCredentials) -> Option<PXCredentialIntelligenceData> {
if raw.user.is_some() {
let user = raw.user.clone()?;
return Some(PXCredentialIntelligenceData {
ci_version: CI_VERSION_MULTISTEP.to_owned(),
user: Some(user.clone()), sso_step: Some(SSO_STEP_USER.to_owned()),
..Default::default()
});
}
if raw.pass.is_some() {
let pass = raw.pass.as_deref()?;
let hashed_pass = sha256_hex(pass)?;
return Some(PXCredentialIntelligenceData {
ci_version: CI_VERSION_MULTISTEP.to_owned(),
pass: Some(hashed_pass),
sso_step: Some(SSO_STEP_PASS.to_owned()),
..Default::default()
});
}
None
}
pub(crate) fn normalize_username(username: &str) -> String {
if !is_email_address(username) {
return username.to_owned();
}
let lowercase = username.trim().to_ascii_lowercase();
let (local, domain) = match lowercase.split_once('@') {
Some(parts) => parts,
None => return lowercase,
};
let mut local = local
.split_once('+')
.map_or(local, |(before_plus, _)| before_plus)
.to_owned();
if domain == "gmail.com" {
local = local.replace('.', "");
}
format!("{local}@{domain}")
}
fn is_email_address(value: &str) -> bool {
value.trim().split_once('@').is_some_and(|(local, domain)| {
!local.is_empty() && !domain.is_empty() && !domain.contains('@')
})
}
fn evaluate_login_successful(
resp: &mut Response,
conf: &PXConfig,
endpoint_index: usize,
status: u16,
) -> Option<bool> {
let endpoint = conf.prepared_ci_endpoints.get(endpoint_index)?;
let method = resolve_login_success_method(conf, &endpoint.config);
match method.as_str() {
"status" => {
let statuses = resolve_login_success_statuses(conf, &endpoint.config);
Some(statuses.contains(&status))
}
"header" => evaluate_login_header(resp, conf, &endpoint.config),
"body" => evaluate_login_body(resp, conf, &endpoint.config),
"custom" => conf
.ci_login_successful_fn
.and_then(|f| f(resp, endpoint_index)),
_ => None,
}
}
fn resolve_login_success_method(conf: &PXConfig, endpoint: &PXCredentialEndpointConfig) -> String {
if !endpoint.login_successful_reporting_method.is_empty() {
endpoint.login_successful_reporting_method.clone()
} else if !conf.login_successful_reporting_method.is_empty() {
conf.login_successful_reporting_method.clone()
} else {
"status".to_owned()
}
}
fn resolve_login_success_statuses(
conf: &PXConfig,
endpoint: &PXCredentialEndpointConfig,
) -> Vec<u16> {
if !endpoint.login_successful_statuses.is_empty() {
endpoint.login_successful_statuses.clone()
} else if !conf.login_successful_status.is_empty() {
conf.login_successful_status.clone()
} else {
vec![200]
}
}
fn resolve_login_body_regex(
conf: &PXConfig,
endpoint: &PXCredentialEndpointConfig,
) -> Option<Regex> {
let pattern = if !endpoint.login_successful_body_regex.is_empty() {
endpoint.login_successful_body_regex.as_str()
} else if !conf.login_successful_body_regex.is_empty() {
conf.login_successful_body_regex.as_str()
} else {
return None;
};
let raw = pattern
.strip_prefix(crate::pxconfig::REGEX_PREFIX)
.unwrap_or(pattern);
Regex::new(raw).ok()
}
fn evaluate_login_header(
resp: &Response,
conf: &PXConfig,
endpoint: &PXCredentialEndpointConfig,
) -> Option<bool> {
let name = if !endpoint.login_successful_header_name.is_empty() {
endpoint.login_successful_header_name.as_str()
} else if !conf.login_successful_header_name.is_empty() {
conf.login_successful_header_name.as_str()
} else {
return None;
};
let value = resp.get_header_str_lossy(name).map(|v| v.into_owned());
let expected = if !endpoint.login_successful_header_value.is_empty() {
Some(endpoint.login_successful_header_value.as_str())
} else if !conf.login_successful_header_value.is_empty() {
Some(conf.login_successful_header_value.as_str())
} else {
None
};
match (value, expected) {
(Some(actual), Some(expected)) => Some(actual == expected),
(Some(_), None) => Some(true),
_ => Some(false),
}
}
fn evaluate_login_body(
resp: &mut Response,
conf: &PXConfig,
endpoint: &PXCredentialEndpointConfig,
) -> Option<bool> {
let re = resolve_login_body_regex(conf, endpoint)?;
let bytes = resp.take_body_bytes();
let matched = std::str::from_utf8(&bytes)
.ok()
.is_some_and(|body| re.is_match(body));
resp.set_body(Body::from(bytes));
Some(matched)
}
pub(crate) fn build_additional_s2s_activity_base(
conf: &PXConfig,
ctx: &PXContext,
) -> serde_json::Value {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let mut details = json!({
"client_uuid": ctx.uuid.as_deref().unwrap_or_default(),
"module_version": crate::modules::pxconstants::PX_MODULE_VERSION,
"request_id": ctx.request_id.to_string(),
});
apply_ci_fields_to_details(&mut details, ctx, false, true);
let mut activity = json!({
"timestamp": timestamp,
"type": "additional_s2s",
"socket_ip": ctx.ip,
"px_app_id": conf.app_id,
"url": ctx.full_url,
"details": details,
});
set_json_str!(&mut activity, "vid"; ctx.vid.as_deref().unwrap_or_default());
set_json_str!(&mut activity, "pxhd"; ctx.get_pxhd().unwrap_or_default());
activity
}
fn post_activity_payload(mut activity: serde_json::Value, ctx: &PXContext, conf: &PXConfig) {
if let Some(details) = activity.get_mut("details") {
set_json_int!(details, "risk_rtt"; ctx.risk_rtt.unwrap_or(0));
}
let body = activity.to_string();
px_debug!("additional_s2s activity body: {}", body);
let url = format!("https://{}{}", conf.human_collector_host, ACTIVITY_API);
let req = Request::post(url)
.with_header("Authorization", format!("Bearer {}", conf.auth_token))
.with_header("Content-Type", APPLICATION_JSON)
.with_body(body.as_bytes())
.send_async(&conf.human_collector_backend);
match req {
Ok(r) => {
r.poll();
}
Err(e) => px_debug!("Error sending additional_s2s activity: {}", e),
}
}