use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{
ArtifactKind, CoreRulePack, DIRECTORY_ID, Engine, EngineError, PreparedArtifact, RuleSource,
Severity, ValidationOptions, ValidationReport, ValidationRequest, ValidationSummary,
ValidatorPlugin, Violation, detect_macro_spans,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpHeader {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HttpHeaders {
Object(BTreeMap<String, String>),
List(Vec<HttpHeader>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpRequest {
pub url: String,
pub method: String,
pub headers: HttpHeaders,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BinaryHttpRequest {
pub url: String,
pub method: String,
pub headers: HttpHeaders,
pub body_base64: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture: Option<HttpCaptureContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HttpCaptureContext {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub headers_unavailable: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unavailable_headers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redacted_headers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<HttpBodyAvailability>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HttpBodyAvailability {
Available,
Absent,
Unavailable,
Redacted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CapturedHttpRequest {
#[serde(flatten)]
pub request: HttpRequest,
pub capture: HttpCaptureContext,
}
impl HttpCaptureContext {
pub(crate) fn body_unavailable(&self) -> bool {
matches!(
self.body,
Some(HttpBodyAvailability::Unavailable | HttpBodyAvailability::Redacted)
)
}
fn redacted_header(&self, name: &str) -> bool {
self.redacted_headers
.iter()
.any(|entry| entry.eq_ignore_ascii_case(name))
}
fn header_unknown(&self, name: &str, present: bool) -> bool {
self.redacted_header(name)
|| (!present
&& (self.headers_unavailable
|| self
.unavailable_headers
.iter()
.any(|entry| entry.eq_ignore_ascii_case(name))))
}
}
struct ParsedCapture {
request: HttpRequest,
context: HttpCaptureContext,
binary_body: Option<String>,
}
fn parse_capture(value: Value) -> Option<ParsedCapture> {
let Value::Object(mut object) = value else {
return None;
};
let context = match object.remove("capture") {
Some(value) => serde_json::from_value::<HttpCaptureContext>(value).ok()?,
None => HttpCaptureContext::default(),
};
let binary_body = match object.remove("body_base64") {
Some(Value::String(value)) if !object.contains_key("body") => Some(value),
Some(_) => return None,
None => None,
};
let request = serde_json::from_value::<HttpRequest>(Value::Object(object)).ok()?;
let has_body = request.body.is_some() || binary_body.is_some();
if matches!(context.body, Some(HttpBodyAvailability::Available)) && !has_body
|| matches!(context.body, Some(HttpBodyAvailability::Absent)) && has_body
{
return None;
}
let mut names = BTreeSet::new();
for name in context
.unavailable_headers
.iter()
.chain(&context.redacted_headers)
{
if !token(name) || !names.insert(name.to_ascii_lowercase()) {
return None;
}
}
Some(ParsedCapture {
request,
context,
binary_body,
})
}
struct DecodedRequest {
normalized: String,
json_body: Option<String>,
form: bool,
multipart_fields: Option<Vec<crate::manifest::RawParam<'static>>>,
unavailable_form: bool,
entity_text: Option<String>,
entity_bytes: usize,
unsupported: bool,
violations: Vec<Violation>,
}
impl Engine {
pub(crate) fn validate_http_json_at(
&self,
request: &ValidationRequest,
options: &ValidationOptions,
clock: i64,
) -> Result<ValidationSummary, EngineError> {
self.ensure_known_rulepacks(&options.only_rulepacks)?;
self.ensure_known_rulepacks(&options.except_rulepacks)?;
let capture = crate::json::JsonDocument::parse(&request.artifact)
.ok()
.filter(|document| {
let unique = |path: &str| {
let names = document.members(path);
names
.iter()
.map(|(name, _)| name)
.collect::<BTreeSet<_>>()
.len()
== names.len()
};
unique("")
&& unique("capture")
&& unique("headers")
&& document.expand("headers[]").iter().all(|path| unique(path))
})
.and_then(|_| serde_json::from_str::<Value>(&request.artifact).ok())
.and_then(parse_capture);
let Some(ParsedCapture {
request: capture,
context,
binary_body,
}) = capture
else {
return Ok(ValidationSummary::from_evaluated(
vec![core_report(vec![request_violation(
"invalid_envelope",
None,
"A request capture needs string url and method, headers as an object or name/value list, and at most one raw string body or string body_base64. Availability declarations must agree with body presence.",
Severity::Error,
)])],
Vec::new(),
));
};
let url_request = ValidationRequest {
artifact_kind: ArtifactKind::Url,
artifact: capture.url.clone(),
claimed_vendor: request.claimed_vendor.clone(),
expansion_state: request.expansion_state,
};
let endpoint = PreparedArtifact::from_request_at(&url_request, clock);
let headerless_form = capture_allows_headerless_form(&capture, &context)
&& self.candidate_ids(&endpoint).into_iter().any(|id| {
!options
.except_rulepacks
.iter()
.any(|excluded| excluded == id)
&& (options.only_rulepacks.is_empty()
|| options.only_rulepacks.iter().any(|selected| selected == id))
&& self
.plugins
.get(id)
.is_some_and(|entry| entry.plugin.uses_headerless_form_body(&endpoint))
});
let decoded = decode_request_with_form_hint(
&capture,
&context,
binary_body.as_deref(),
clock,
headerless_form,
);
let mut prepared = PreparedArtifact::from_request_at(&url_request, clock);
prepared.mark_complete_http_request();
if decoded.form {
prepared.set_form_body(decoded.entity_text.as_deref().unwrap_or_default());
}
if let Some(fields) = &decoded.multipart_fields {
prepared.set_form_fields(fields.clone());
}
if decoded.unavailable_form {
prepared.mark_unavailable_form_body();
}
let body_request = decoded.json_body.as_ref().map(|body| ValidationRequest {
artifact_kind: ArtifactKind::JsonPayload,
artifact: body.clone(),
claimed_vendor: request.claimed_vendor.clone(),
expansion_state: request.expansion_state,
});
let body = body_request
.as_ref()
.map(|body| PreparedArtifact::from_request_at(body, clock));
let excluded: BTreeSet<_> = options
.except_rulepacks
.iter()
.map(String::as_str)
.collect();
let explicit = !options.only_rulepacks.is_empty();
let plugins: Vec<&crate::PluginEntry> = if explicit {
options
.only_rulepacks
.iter()
.filter(|id| id.as_str() != DIRECTORY_ID && !excluded.contains(id.as_str()))
.filter_map(|id| self.plugins.get(id))
.collect()
} else {
self.candidate_ids(&prepared)
.into_iter()
.filter(|id| !excluded.contains(id))
.filter_map(|id| self.plugins.get(id))
.filter(|entry| entry.plugin.supports_http(&prepared))
.collect()
};
if plugins.is_empty()
&& !(explicit && options.only_rulepacks.iter().all(|id| id == DIRECTORY_ID))
{
return Err(if !explicit && excluded.is_empty() {
EngineError::NoMatchingPlugin
} else {
EngineError::NoRulepacksSelected
});
}
for entry in &plugins {
entry.plugin.prepare_vendor_context(&mut prepared);
}
let vendor_rulepacks = plugins
.iter()
.filter(|entry| {
entry.plugin.metadata().id != "core"
&& entry.plugin.metadata().id != DIRECTORY_ID
&& entry.plugin.supports_http(&prepared)
})
.map(|entry| entry.plugin.metadata().id.clone())
.collect::<Vec<_>>();
let mut sdk_violations = Vec::new();
let content_types: Vec<_> = header_lines(&capture.headers)
.into_iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| value)
.collect();
let sdk_text = content_types.first().filter(|mime| {
mime.split(';')
.next()
.is_some_and(|essence| essence.trim().eq_ignore_ascii_case("text/plain"))
});
let core_selected = plugins
.iter()
.any(|entry| entry.plugin.metadata().id == "core");
let mut reports: Vec<_> = plugins
.iter()
.map(|entry| {
let preparation = entry
.sdk_body_profile
.as_ref()
.filter(|profile| {
entry.plugin.supports_http(&endpoint)
&& profile.endpoint(&capture.method, &endpoint)
&& !decoded.unsupported
&& decoded.json_body.is_none()
&& sdk_text.is_some()
&& !context.body_unavailable()
&& !context.header_unknown("content-type", true)
})
.and_then(|profile| {
decoded
.entity_text
.as_deref()
.filter(|text| text.contains('='))
.map(|text| {
profile.prepare(
text,
sdk_text.expect("checked"),
endpoint
.params(crate::manifest::ParamStyle::Query)
.iter()
.any(|field| field.name == "data"),
)
})
});
if let Some(preparation) = preparation {
let mut owner = PreparedArtifact::from_request_at(&url_request, clock);
owner.mark_complete_http_request();
let mut sdk_secrets = Vec::new();
match preparation {
crate::sdk_body::Preparation::Data(data) => {
sdk_secrets = data.secrets;
owner.set_form_fields(vec![crate::sdk_body::field(data.data)]);
}
crate::sdk_body::Preparation::Deferred(problem) => {
owner.mark_unavailable_form_body();
sdk_violations.push(problem.finding());
}
}
entry.plugin.prepare_vendor_context(&mut owner);
let mut report = entry.plugin.validate_http(
&owner,
body.as_ref(),
decoded.entity_bytes,
&decoded.normalized,
core_selected,
);
let secrets = sdk_secrets;
let prefix = format!("{}.body.", entry.plugin.metadata().id.replace('/', "."));
for violation in report
.violations
.iter_mut()
.filter(|finding| finding.code.starts_with(&prefix))
{
violation.message = sdk_redact(&violation.message, &secrets);
if let Some(hint) = &mut violation.fix_hint {
*hint = sdk_redact(hint, &secrets);
}
}
report
} else {
entry.plugin.validate_http(
&prepared,
body.as_ref(),
decoded.entity_bytes,
&decoded.normalized,
core_selected,
)
}
})
.collect();
let mut input_violations = decoded.violations;
input_violations.extend(sdk_violations);
if let Some(body) = &body {
input_violations.extend(CoreRulePack::default().validate_prepared(body).violations);
}
if !input_violations.is_empty() {
if let Some(core) = reports.iter_mut().find(|report| report.plugin_id == "core") {
core.violations.extend(input_violations);
} else {
reports.insert(0, core_report(input_violations));
}
}
if let Some(report) = self.directory_report(&prepared, options, &reports, &vendor_rulepacks)
{
reports.push(report);
}
let mut secrets = secret_values(&capture);
if binary_body.is_some()
&& let Some(json) = decoded.json_body.as_deref()
&& let Ok(value) = serde_json::from_str::<Value>(json)
{
collect_entity_secrets(&value, false, &mut secrets);
}
for field in prepared.params(crate::manifest::ParamStyle::Query) {
let name = field.name.to_ascii_lowercase();
if name.contains("key")
|| name.contains("token")
|| name.contains("secret")
|| name.contains("password")
{
secrets.push(field.value.to_string());
}
}
secrets.retain(|value| !value.is_empty());
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
secrets.dedup();
for report in &mut reports {
for violation in &mut report.violations {
violation.targets.clear();
for secret in &secrets {
violation.message = violation.message.replace(secret, "[redacted]");
if let Some(hint) = &mut violation.fix_hint {
*hint = hint.replace(secret, "[redacted]");
}
}
}
}
Ok(ValidationSummary::from_evaluated(reports, vendor_rulepacks))
}
}
fn core_report(violations: Vec<Violation>) -> ValidationReport {
ValidationReport {
plugin_id: "core".into(),
detected_vendor: None,
violations,
}
}
fn request_violation(
code: &str,
field: Option<&str>,
message: &str,
severity: Severity,
) -> Violation {
Violation {
code: format!("core.request.{code}"),
message: message.into(),
severity,
field: field.map(str::to_owned),
fix_hint: None,
source: RuleSource::normative(
"HTTP Semantics (RFC 9110)",
"https://www.rfc-editor.org/rfc/rfc9110",
),
targets: Vec::new(),
}
}
fn token(value: &str) -> bool {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
}
fn insert_repeated(object: &mut Map<String, Value>, name: String, value: String) {
match object.get_mut(&name) {
None => {
object.insert(name, Value::String(value));
}
Some(Value::Array(values)) => values.push(Value::String(value)),
Some(previous) => {
*previous = Value::Array(vec![previous.take(), Value::String(value)]);
}
}
}
fn header_lines(headers: &HttpHeaders) -> Vec<(&str, &str)> {
match headers {
HttpHeaders::Object(headers) => headers
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect(),
HttpHeaders::List(headers) => headers
.iter()
.map(|header| (header.name.as_str(), header.value.as_str()))
.collect(),
}
}
fn capture_allows_headerless_form(request: &HttpRequest, context: &HttpCaptureContext) -> bool {
let headers = header_lines(&request.headers);
let present = |name: &str| {
headers
.iter()
.any(|(key, _)| key.eq_ignore_ascii_case(name))
};
!context.header_unknown("content-type", present("content-type"))
&& !context.header_unknown("content-encoding", present("content-encoding"))
&& !context.body_unavailable()
&& context.body != Some(HttpBodyAvailability::Absent)
}
fn decode_request_with_form_hint(
request: &HttpRequest,
context: &HttpCaptureContext,
binary_body: Option<&str>,
clock: i64,
headerless_form: bool,
) -> DecodedRequest {
let mut violations = Vec::new();
if !token(&request.method) && detect_macro_spans(&request.method).is_empty() {
violations.push(request_violation(
"invalid_method",
Some("method"),
"HTTP method must be a nonempty token.",
Severity::Error,
));
}
let mut headers = Map::new();
for (name, value) in header_lines(&request.headers) {
if !token(name) && detect_macro_spans(name).is_empty() {
violations.push(request_violation(
"invalid_header",
Some("headers"),
"HTTP header names must be nonempty tokens.",
Severity::Error,
));
}
if context.redacted_header(name) {
continue;
}
if value.bytes().any(|byte| {
byte == 0
|| byte == b'\r'
|| byte == b'\n'
|| (byte < 32 && byte != b'\t')
|| byte == 127
}) {
violations.push(request_violation(
"invalid_header",
Some("headers"),
"HTTP header values cannot contain control characters other than horizontal tab.",
Severity::Error,
));
}
insert_repeated(
&mut headers,
name.to_ascii_lowercase(),
value.trim_matches([' ', '\t']).into(),
);
}
let ambiguous_content_type = headers.get("content-type").is_some_and(Value::is_array);
let content_type = headers
.get("content-type")
.and_then(Value::as_str)
.map(|value| {
value
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
});
let has_body = request.body.is_some() || binary_body.is_some();
let encoding = match (has_body, content_type.as_deref()) {
(false, _) => "none",
(true, Some("application/x-www-form-urlencoded")) => "form",
(true, None) if headerless_form && !headers.contains_key("content-type") => "form",
(true, Some("application/x-ndjson")) => "ndjson",
(true, Some("multipart/form-data")) => "multipart",
(true, Some(mime)) if mime == "application/json" || mime.ends_with("+json") => "json",
_ => "text",
};
let mut entity_text = request.body.clone();
let mut wire_bytes = request.body.as_ref().map_or(0, String::len);
let capture_body_unavailable = context.body_unavailable();
let mut unavailable_paths = Vec::new();
if context.headers_unavailable {
unavailable_paths.push("headers".to_string());
}
for name in context
.unavailable_headers
.iter()
.chain(&context.redacted_headers)
{
let name = name.to_ascii_lowercase();
if context.header_unknown(&name, headers.contains_key(&name)) {
unavailable_paths.push(crate::json::member_path("headers", &name));
}
}
if context.header_unknown("authorization", headers.contains_key("authorization")) {
unavailable_paths.extend(["authorization_scheme".into(), "basic_auth".into()]);
}
let unavailable_content_type =
context.header_unknown("content-type", headers.contains_key("content-type"));
let unavailable_content_encoding =
context.header_unknown("content-encoding", headers.contains_key("content-encoding"));
if unavailable_content_type {
unavailable_paths.extend(["content_type".into(), "body_encoding".into()]);
}
if capture_body_unavailable {
unavailable_paths.extend(["body".into(), "body_encoding".into()]);
}
let decoder_context_unavailable =
has_body && (unavailable_content_type || unavailable_content_encoding);
if decoder_context_unavailable {
unavailable_paths.extend(["body".into(), "body_encoding".into()]);
}
if !unavailable_paths.is_empty() {
let mut finding = request_violation(
"capture_incomplete",
Some("capture"),
"Declared capture fields are unavailable or redacted. Checks that depend on them remain unvalidated; observed method, URL and other fields still run.",
Severity::Info,
);
finding.source = RuleSource::normative(
"HTTP Archive 1.2 request representation",
"https://webperfwg.org/specs/HAR/Overview.html#request",
);
violations.push(finding);
}
let compressed = headers.get("content-encoding").is_some_and(|value| {
value
.as_str()
.is_none_or(|value| !value.eq_ignore_ascii_case("identity"))
});
let gzip = headers
.get("content-encoding")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("gzip"));
let mut unsupported = capture_body_unavailable
|| decoder_context_unavailable
|| (has_body
&& ((compressed && !(binary_body.is_some() && gzip))
|| ambiguous_content_type
|| content_type.as_deref().is_some_and(|mime| {
mime.starts_with("multipart/") && mime != "multipart/form-data"
})));
if unsupported && !capture_body_unavailable && !decoder_context_unavailable {
violations.push(request_violation("unsupported_body_encoding", Some("body"), "This capture has unsupported or ambiguous entity encoding. Provide raw decoded text with matching headers, or explicit body_base64 wire bytes with a supported Content-Encoding, for local body checks.", Severity::Info));
}
if !unsupported && let Some(encoded) = binary_body {
match crate::binary_body::decode(encoded, gzip) {
Ok(decoded) => {
wire_bytes = decoded.wire_bytes;
entity_text = Some(decoded.text);
}
Err(problem) => {
let (code, severity, message, reference) = match problem {
crate::binary_body::BinaryBodyError::Base64 => (
"invalid_base64_body",
Severity::Error,
"body_base64 must contain canonical standard Base64 with required padding and no whitespace.",
"https://www.rfc-editor.org/rfc/rfc4648",
),
crate::binary_body::BinaryBodyError::Gzip => (
"invalid_gzip_body",
Severity::Error,
"The captured gzip entity has invalid framing, compressed data, checksum, size, truncation or trailing bytes.",
"https://www.rfc-editor.org/rfc/rfc1952",
),
crate::binary_body::BinaryBodyError::Utf8
if matches!(encoding, "json" | "ndjson") =>
{
(
"invalid_body_utf8",
Severity::Error,
"The captured JSON entity is not UTF-8 encoded.",
"https://www.rfc-editor.org/rfc/rfc8259#section-8.1",
)
}
crate::binary_body::BinaryBodyError::Utf8 => (
"unsupported_body_encoding",
Severity::Info,
"The captured binary entity is outside local UTF-8 text inspection. Payload checks remain unvalidated; no vendor rejection is inferred.",
"https://github.com/aleksUIX/pixellint/blob/main/docs/HTTP_REQUEST_SCHEMA.md",
),
crate::binary_body::BinaryBodyError::Limit => (
"body_decode_limit",
Severity::Info,
"Binary entity inspection exceeded a local resource bound: 16 MiB wire bytes, 16 MiB decoded bytes, 1024 gzip members or 64 KiB header per member. Payload checks remain unvalidated; no vendor rejection is inferred.",
"https://github.com/aleksUIX/pixellint/blob/main/docs/HTTP_REQUEST_SCHEMA.md",
),
};
let mut finding = request_violation(code, Some("body_base64"), message, severity);
finding.source = if severity == Severity::Info {
RuleSource {
level: crate::RuleSourceLevel::Heuristic,
name: "Pixellint local binary inspection scope".into(),
reference: Some(reference.into()),
}
} else {
RuleSource::normative("Captured binary entity decoding", reference)
};
violations.push(finding);
entity_text = None;
unsupported = true;
}
}
}
let wire = entity_text.as_deref();
let mut decoded_body = Value::Null;
let mut json_body = None;
let mut multipart_fields = None;
if !unsupported && let Some(body) = wire {
match encoding {
"multipart" => {
let raw_type = headers
.get("content-type")
.and_then(Value::as_str)
.unwrap_or_default();
match crate::multipart::decode(raw_type, body) {
Ok(fields) => {
let mut values = Map::new();
for field in &fields {
insert_repeated(
&mut values,
field.name.to_string(),
field.value.to_string(),
);
}
decoded_body = Value::Object(values);
multipart_fields = Some(fields);
}
Err(problem) => {
let (code, severity, message) = match problem {
crate::multipart::MultipartError::Invalid(message) => {
("invalid_multipart", Severity::Error, message)
}
crate::multipart::MultipartError::Unsupported(message) => {
("unsupported_body_encoding", Severity::Info, message)
}
};
let mut finding = request_violation(code, Some("body"), message, severity);
finding.source = RuleSource::normative(
"Multipart form data (RFC 7578 and RFC 2046)",
"https://www.rfc-editor.org/rfc/rfc7578",
);
violations.push(finding);
unsupported = true;
}
}
}
"form" if body.trim_start().starts_with(['{', '[']) => {
json_body = Some(body.into());
decoded_body = decode_json_entity(body);
}
"form" => {
let mut fields = Map::new();
for (name, value) in url::form_urlencoded::parse(body.as_bytes()) {
insert_repeated(&mut fields, name.into_owned(), value.into_owned());
}
decoded_body = Value::Object(fields);
}
"ndjson" => {
let mut values = Vec::new();
let mut valid = true;
for line in body.lines().filter(|line| !line.trim().is_empty()) {
match serde_json::from_str::<Value>(line) {
Ok(value) => values.push(value),
Err(_) => {
valid = false;
break;
}
}
}
if valid {
decoded_body = Value::Array(values);
json_body = Some(decoded_body.to_string());
} else {
violations.push(request_violation(
"invalid_ndjson",
Some("body"),
"Each nonempty NDJSON line must be valid JSON.",
Severity::Error,
));
}
}
_ if encoding == "json" || body.trim_start().starts_with(['{', '[']) => {
json_body = Some(body.into());
decoded_body = decode_json_entity(body);
}
_ => decoded_body = Value::String(body.into()),
}
}
if json_body
.as_ref()
.is_some_and(|body| crate::json::JsonDocument::parse(body).is_err())
{
decoded_body = Value::Null;
}
let mut normalized = Map::new();
normalized.insert("url".into(), Value::String(request.url.clone()));
normalized.insert("method".into(), Value::String(request.method.clone()));
let url_request = ValidationRequest {
artifact_kind: ArtifactKind::Url,
artifact: request.url.clone(),
claimed_vendor: None,
expansion_state: crate::ExpansionState::Unknown,
};
let parsed = PreparedArtifact::from_request_at(&url_request, clock);
if let Some(url) = parsed.url() {
normalized.insert("path".into(), Value::String(url.path.to_string()));
if let Some(host) = &url.host {
normalized.insert("host".into(), Value::String(host.to_string()));
}
}
let mut query = Map::new();
let without_fragment = request.url.split('#').next().unwrap_or_default();
if let Some((_, query_text)) = without_fragment.split_once('?') {
for (name, value) in
url::form_urlencoded::parse(query_text.split('#').next().unwrap_or_default().as_bytes())
{
insert_repeated(&mut query, name.into_owned(), value.into_owned());
}
}
normalized.insert("query".into(), Value::Object(query));
if let Some(content_type) = &content_type {
normalized.insert("content_type".into(), Value::String(content_type.clone()));
}
if let Some(auth) = headers.get("authorization").and_then(Value::as_str)
&& let Some((scheme, credentials)) = auth.split_once([' ', '\t'])
{
normalized.insert(
"authorization_scheme".into(),
Value::String(scheme.to_ascii_lowercase()),
);
if scheme.eq_ignore_ascii_case("basic")
&& let Ok(decoded) =
crate::manifest::decode_base64_json(credentials.trim_start_matches([' ', '\t']))
{
let (username, password, has_colon) = match decoded.split_once(':') {
Some((username, password)) => (username, password, true),
None => (decoded.as_str(), "", false),
};
normalized.insert("basic_auth".into(), serde_json::json!({ "username":username, "password":password, "has_colon":has_colon }));
}
}
if ambiguous_content_type || unavailable_content_type {
normalized.insert("content_type_unavailable".into(), Value::Bool(true));
}
if binary_body.is_some() && unsupported {
unavailable_paths.extend(["wire_body_bytes".into(), "entity_body_bytes".into()]);
}
if !unavailable_paths.is_empty() {
normalized.insert(
"capture_unavailable_paths".into(),
serde_json::json!(unavailable_paths),
);
}
normalized.insert("headers".into(), Value::Object(headers));
if binary_body.is_some() && !unsupported {
normalized.insert("wire_body_bytes".into(), serde_json::json!(wire_bytes));
normalized.insert(
"entity_body_bytes".into(),
serde_json::json!(wire.map_or(0, str::len)),
);
}
normalized.insert("body".into(), decoded_body);
normalized.insert(
"body_encoding".into(),
Value::String(if unsupported { "unsupported" } else { encoding }.into()),
);
let unavailable_form = unsupported
&& (ambiguous_content_type
|| encoding == "form"
|| decoder_context_unavailable
|| (capture_body_unavailable && content_type.is_none())
|| content_type_from_headers(request)
.is_some_and(|mime| mime.starts_with("multipart/")));
let form = encoding == "form" && !unsupported && json_body.is_none();
DecodedRequest {
normalized: Value::Object(normalized).to_string(),
json_body,
form,
multipart_fields,
unavailable_form,
entity_bytes: wire.map_or(0, str::len),
unsupported,
entity_text,
violations,
}
}
fn content_type_from_headers(request: &HttpRequest) -> Option<String> {
header_lines(&request.headers)
.into_iter()
.find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
.map(|(_, value)| {
value
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
})
}
fn decode_json_entity(body: &str) -> Value {
if crate::json::JsonDocument::parse(body).is_err() {
return Value::Null;
}
serde_json::from_str(body).unwrap_or(Value::Null)
}
fn secret_values(request: &HttpRequest) -> Vec<String> {
let mut values = Vec::new();
for (name, value) in header_lines(&request.headers) {
let name = name.to_ascii_lowercase();
if name.contains("authorization")
|| name.contains("cookie")
|| name.contains("key")
|| name.contains("token")
|| name == "authentication"
{
values.push(value.to_owned());
let value = value.trim_matches([' ', '\t']);
values.push(value.to_owned());
if let Some((scheme, credential)) = value.split_once([' ', '\t']) {
let credential = credential.trim_start_matches([' ', '\t']);
values.push(credential.to_owned());
if scheme.eq_ignore_ascii_case("basic")
&& let Ok(decoded) = crate::manifest::decode_base64_json(credential)
{
values.push(decoded.clone());
if let Some((username, password)) = decoded.split_once(':') {
values.extend([username.to_owned(), password.to_owned()]);
}
}
}
}
}
values.retain(|value| !value.is_empty());
values.sort_by_key(|value| std::cmp::Reverse(value.len()));
values.dedup();
values
}
fn collect_entity_secrets(value: &Value, secret: bool, values: &mut Vec<String>) {
match value {
Value::Object(object) => {
for (name, value) in object {
let name = name.to_ascii_lowercase();
let sensitive = secret
|| [
"authorization",
"cookie",
"key",
"token",
"secret",
"password",
]
.iter()
.any(|part| name.contains(part));
collect_entity_secrets(value, sensitive, values);
}
}
Value::Array(array) => {
for value in array {
collect_entity_secrets(value, secret, values);
}
}
Value::String(value) if secret => values.push(value.clone()),
_ if secret && !value.is_null() => values.push(value.to_string()),
_ => {}
}
}
fn sdk_redact(text: &str, secrets: &[String]) -> String {
if secrets.is_empty() {
return text.into();
}
let mut output = String::new();
let mut index = 0;
while index < text.len() {
let tail = &text[index..];
if let Some(secret) = secrets
.iter()
.find(|secret| tail.starts_with(secret.as_str()))
{
output.push_str("[redacted]");
index += secret.len();
} else {
let character = tail.chars().next().expect("remaining text");
output.push(character);
index += character.len_utf8();
}
}
output
}
#[cfg(test)]
mod sdk_sanitizer_tests {
use super::sdk_redact;
#[test]
fn replacement_text_is_not_rescanned_for_short_or_overlapping_secrets() {
let secrets = vec!["redacted".into(), "a".into()];
assert_eq!(sdk_redact("a redacted", &secrets), "[redacted] [redacted]");
assert_eq!(sdk_redact("雪a😀", &secrets), "雪[redacted]😀");
}
}