1use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8
9use crate::{
10 ArtifactKind, CoreRulePack, DIRECTORY_ID, Engine, EngineError, PreparedArtifact, RuleSource,
11 Severity, ValidationOptions, ValidationReport, ValidationRequest, ValidationSummary,
12 ValidatorPlugin, Violation, detect_macro_spans,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct HttpHeader {
18 pub name: String,
19 pub value: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum HttpHeaders {
26 Object(BTreeMap<String, String>),
27 List(Vec<HttpHeader>),
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct HttpRequest {
33 pub url: String,
34 pub method: String,
35 pub headers: HttpHeaders,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub body: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct BinaryHttpRequest {
45 pub url: String,
46 pub method: String,
47 pub headers: HttpHeaders,
48 pub body_base64: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub capture: Option<HttpCaptureContext>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
55#[serde(deny_unknown_fields)]
56pub struct HttpCaptureContext {
57 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
58 pub headers_unavailable: bool,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub unavailable_headers: Vec<String>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub redacted_headers: Vec<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub body: Option<HttpBodyAvailability>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum HttpBodyAvailability {
70 Available,
71 Absent,
72 Unavailable,
73 Redacted,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct CapturedHttpRequest {
80 #[serde(flatten)]
81 pub request: HttpRequest,
82 pub capture: HttpCaptureContext,
83}
84
85impl HttpCaptureContext {
86 pub(crate) fn body_unavailable(&self) -> bool {
87 matches!(
88 self.body,
89 Some(HttpBodyAvailability::Unavailable | HttpBodyAvailability::Redacted)
90 )
91 }
92
93 fn redacted_header(&self, name: &str) -> bool {
94 self.redacted_headers
95 .iter()
96 .any(|entry| entry.eq_ignore_ascii_case(name))
97 }
98
99 fn header_unknown(&self, name: &str, present: bool) -> bool {
100 self.redacted_header(name)
101 || (!present
102 && (self.headers_unavailable
103 || self
104 .unavailable_headers
105 .iter()
106 .any(|entry| entry.eq_ignore_ascii_case(name))))
107 }
108}
109
110struct ParsedCapture {
111 request: HttpRequest,
112 context: HttpCaptureContext,
113 binary_body: Option<String>,
114}
115
116fn parse_capture(value: Value) -> Option<ParsedCapture> {
117 let Value::Object(mut object) = value else {
118 return None;
119 };
120 let context = match object.remove("capture") {
121 Some(value) => serde_json::from_value::<HttpCaptureContext>(value).ok()?,
122 None => HttpCaptureContext::default(),
123 };
124 let binary_body = match object.remove("body_base64") {
125 Some(Value::String(value)) if !object.contains_key("body") => Some(value),
126 Some(_) => return None,
127 None => None,
128 };
129 let request = serde_json::from_value::<HttpRequest>(Value::Object(object)).ok()?;
130 let has_body = request.body.is_some() || binary_body.is_some();
131 if matches!(context.body, Some(HttpBodyAvailability::Available)) && !has_body
132 || matches!(context.body, Some(HttpBodyAvailability::Absent)) && has_body
133 {
134 return None;
135 }
136 let mut names = BTreeSet::new();
137 for name in context
138 .unavailable_headers
139 .iter()
140 .chain(&context.redacted_headers)
141 {
142 if !token(name) || !names.insert(name.to_ascii_lowercase()) {
143 return None;
144 }
145 }
146 Some(ParsedCapture {
147 request,
148 context,
149 binary_body,
150 })
151}
152
153struct DecodedRequest {
154 normalized: String,
155 json_body: Option<String>,
156 form: bool,
157 multipart_fields: Option<Vec<crate::manifest::RawParam<'static>>>,
158 unavailable_form: bool,
159 entity_text: Option<String>,
160 entity_bytes: usize,
161 unsupported: bool,
162 violations: Vec<Violation>,
163}
164
165impl Engine {
166 pub(crate) fn validate_http_json_at(
167 &self,
168 request: &ValidationRequest,
169 options: &ValidationOptions,
170 clock: i64,
171 ) -> Result<ValidationSummary, EngineError> {
172 self.ensure_known_rulepacks(&options.only_rulepacks)?;
173 self.ensure_known_rulepacks(&options.except_rulepacks)?;
174 let capture = crate::json::JsonDocument::parse(&request.artifact)
176 .ok()
177 .filter(|document| {
178 let unique = |path: &str| {
179 let names = document.members(path);
180 names
181 .iter()
182 .map(|(name, _)| name)
183 .collect::<BTreeSet<_>>()
184 .len()
185 == names.len()
186 };
187 unique("")
188 && unique("capture")
189 && unique("headers")
190 && document.expand("headers[]").iter().all(|path| unique(path))
191 })
192 .and_then(|_| serde_json::from_str::<Value>(&request.artifact).ok())
193 .and_then(parse_capture);
194 let Some(ParsedCapture {
195 request: capture,
196 context,
197 binary_body,
198 }) = capture
199 else {
200 return Ok(ValidationSummary::from_evaluated(
201 vec![core_report(vec![request_violation(
202 "invalid_envelope",
203 None,
204 "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.",
205 Severity::Error,
206 )])],
207 Vec::new(),
208 ));
209 };
210 let url_request = ValidationRequest {
211 artifact_kind: ArtifactKind::Url,
212 artifact: capture.url.clone(),
213 claimed_vendor: request.claimed_vendor.clone(),
214 expansion_state: request.expansion_state,
215 };
216 let endpoint = PreparedArtifact::from_request_at(&url_request, clock);
219 let headerless_form = capture_allows_headerless_form(&capture, &context)
220 && self.candidate_ids(&endpoint).into_iter().any(|id| {
221 !options
222 .except_rulepacks
223 .iter()
224 .any(|excluded| excluded == id)
225 && (options.only_rulepacks.is_empty()
226 || options.only_rulepacks.iter().any(|selected| selected == id))
227 && self
228 .plugins
229 .get(id)
230 .is_some_and(|entry| entry.plugin.uses_headerless_form_body(&endpoint))
231 });
232 let decoded = decode_request_with_form_hint(
233 &capture,
234 &context,
235 binary_body.as_deref(),
236 clock,
237 headerless_form,
238 );
239 let mut prepared = PreparedArtifact::from_request_at(&url_request, clock);
240 prepared.mark_complete_http_request();
241 if decoded.form {
242 prepared.set_form_body(decoded.entity_text.as_deref().unwrap_or_default());
243 }
244 if let Some(fields) = &decoded.multipart_fields {
245 prepared.set_form_fields(fields.clone());
246 }
247 if decoded.unavailable_form {
248 prepared.mark_unavailable_form_body();
249 }
250 let body_request = decoded.json_body.as_ref().map(|body| ValidationRequest {
251 artifact_kind: ArtifactKind::JsonPayload,
252 artifact: body.clone(),
253 claimed_vendor: request.claimed_vendor.clone(),
254 expansion_state: request.expansion_state,
255 });
256 let body = body_request
257 .as_ref()
258 .map(|body| PreparedArtifact::from_request_at(body, clock));
259 let excluded: BTreeSet<_> = options
260 .except_rulepacks
261 .iter()
262 .map(String::as_str)
263 .collect();
264 let explicit = !options.only_rulepacks.is_empty();
265 let plugins: Vec<&crate::PluginEntry> = if explicit {
266 options
267 .only_rulepacks
268 .iter()
269 .filter(|id| id.as_str() != DIRECTORY_ID && !excluded.contains(id.as_str()))
270 .filter_map(|id| self.plugins.get(id))
271 .collect()
272 } else {
273 self.candidate_ids(&prepared)
274 .into_iter()
275 .filter(|id| !excluded.contains(id))
276 .filter_map(|id| self.plugins.get(id))
277 .filter(|entry| entry.plugin.supports_http(&prepared))
278 .collect()
279 };
280 if plugins.is_empty()
281 && !(explicit && options.only_rulepacks.iter().all(|id| id == DIRECTORY_ID))
282 {
283 return Err(if !explicit && excluded.is_empty() {
284 EngineError::NoMatchingPlugin
285 } else {
286 EngineError::NoRulepacksSelected
287 });
288 }
289 for entry in &plugins {
290 entry.plugin.prepare_vendor_context(&mut prepared);
291 }
292 let vendor_rulepacks = plugins
293 .iter()
294 .filter(|entry| {
295 entry.plugin.metadata().id != "core"
296 && entry.plugin.metadata().id != DIRECTORY_ID
297 && entry.plugin.supports_http(&prepared)
298 })
299 .map(|entry| entry.plugin.metadata().id.clone())
300 .collect::<Vec<_>>();
301 let mut sdk_violations = Vec::new();
302 let content_types: Vec<_> = header_lines(&capture.headers)
303 .into_iter()
304 .filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
305 .map(|(_, value)| value)
306 .collect();
307 let sdk_text = content_types.first().filter(|mime| {
308 mime.split(';')
309 .next()
310 .is_some_and(|essence| essence.trim().eq_ignore_ascii_case("text/plain"))
311 });
312 let core_selected = plugins
313 .iter()
314 .any(|entry| entry.plugin.metadata().id == "core");
315 let mut reports: Vec<_> = plugins
316 .iter()
317 .map(|entry| {
318 let preparation = entry
319 .sdk_body_profile
320 .as_ref()
321 .filter(|profile| {
322 entry.plugin.supports_http(&endpoint)
323 && profile.endpoint(&capture.method, &endpoint)
324 && !decoded.unsupported
325 && decoded.json_body.is_none()
326 && sdk_text.is_some()
327 && !context.body_unavailable()
328 && !context.header_unknown("content-type", true)
329 })
330 .and_then(|profile| {
331 decoded
332 .entity_text
333 .as_deref()
334 .filter(|text| text.contains('='))
335 .map(|text| {
336 profile.prepare(
337 text,
338 sdk_text.expect("checked"),
339 endpoint
340 .params(crate::manifest::ParamStyle::Query)
341 .iter()
342 .any(|field| field.name == "data"),
343 )
344 })
345 });
346 if let Some(preparation) = preparation {
347 let mut owner = PreparedArtifact::from_request_at(&url_request, clock);
348 owner.mark_complete_http_request();
349 let mut sdk_secrets = Vec::new();
350 match preparation {
351 crate::sdk_body::Preparation::Data(data) => {
352 sdk_secrets = data.secrets;
353 owner.set_form_fields(vec![crate::sdk_body::field(data.data)]);
354 }
355 crate::sdk_body::Preparation::Deferred(problem) => {
356 owner.mark_unavailable_form_body();
357 sdk_violations.push(problem.finding());
358 }
359 }
360 entry.plugin.prepare_vendor_context(&mut owner);
361 let mut report = entry.plugin.validate_http(
362 &owner,
363 body.as_ref(),
364 decoded.entity_bytes,
365 &decoded.normalized,
366 core_selected,
367 );
368 let secrets = sdk_secrets;
369 let prefix = format!("{}.body.", entry.plugin.metadata().id.replace('/', "."));
370 for violation in report
371 .violations
372 .iter_mut()
373 .filter(|finding| finding.code.starts_with(&prefix))
374 {
375 violation.message = sdk_redact(&violation.message, &secrets);
376 if let Some(hint) = &mut violation.fix_hint {
377 *hint = sdk_redact(hint, &secrets);
378 }
379 }
380 report
381 } else {
382 entry.plugin.validate_http(
383 &prepared,
384 body.as_ref(),
385 decoded.entity_bytes,
386 &decoded.normalized,
387 core_selected,
388 )
389 }
390 })
391 .collect();
392 let mut input_violations = decoded.violations;
393 input_violations.extend(sdk_violations);
394 if let Some(body) = &body {
395 input_violations.extend(CoreRulePack::default().validate_prepared(body).violations);
396 }
397 if !input_violations.is_empty() {
398 if let Some(core) = reports.iter_mut().find(|report| report.plugin_id == "core") {
399 core.violations.extend(input_violations);
400 } else {
401 reports.insert(0, core_report(input_violations));
402 }
403 }
404 if let Some(report) = self.directory_report(&prepared, options, &reports, &vendor_rulepacks)
405 {
406 reports.push(report);
407 }
408 let mut secrets = secret_values(&capture);
411 if binary_body.is_some()
412 && let Some(json) = decoded.json_body.as_deref()
413 && let Ok(value) = serde_json::from_str::<Value>(json)
414 {
415 collect_entity_secrets(&value, false, &mut secrets);
416 }
417 for field in prepared.params(crate::manifest::ParamStyle::Query) {
418 let name = field.name.to_ascii_lowercase();
419 if name.contains("key")
420 || name.contains("token")
421 || name.contains("secret")
422 || name.contains("password")
423 {
424 secrets.push(field.value.to_string());
425 }
426 }
427 secrets.retain(|value| !value.is_empty());
428 secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
429 secrets.dedup();
430 for report in &mut reports {
431 for violation in &mut report.violations {
432 violation.targets.clear();
433 for secret in &secrets {
434 violation.message = violation.message.replace(secret, "[redacted]");
435 if let Some(hint) = &mut violation.fix_hint {
436 *hint = hint.replace(secret, "[redacted]");
437 }
438 }
439 }
440 }
441 Ok(ValidationSummary::from_evaluated(reports, vendor_rulepacks))
442 }
443}
444
445fn core_report(violations: Vec<Violation>) -> ValidationReport {
446 ValidationReport {
447 plugin_id: "core".into(),
448 detected_vendor: None,
449 violations,
450 }
451}
452
453fn request_violation(
454 code: &str,
455 field: Option<&str>,
456 message: &str,
457 severity: Severity,
458) -> Violation {
459 Violation {
460 code: format!("core.request.{code}"),
461 message: message.into(),
462 severity,
463 field: field.map(str::to_owned),
464 fix_hint: None,
465 source: RuleSource::normative(
466 "HTTP Semantics (RFC 9110)",
467 "https://www.rfc-editor.org/rfc/rfc9110",
468 ),
469 targets: Vec::new(),
470 }
471}
472
473fn token(value: &str) -> bool {
474 !value.is_empty()
475 && value
476 .bytes()
477 .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
478}
479
480fn insert_repeated(object: &mut Map<String, Value>, name: String, value: String) {
481 match object.get_mut(&name) {
482 None => {
483 object.insert(name, Value::String(value));
484 }
485 Some(Value::Array(values)) => values.push(Value::String(value)),
486 Some(previous) => {
487 *previous = Value::Array(vec![previous.take(), Value::String(value)]);
488 }
489 }
490}
491
492fn header_lines(headers: &HttpHeaders) -> Vec<(&str, &str)> {
493 match headers {
494 HttpHeaders::Object(headers) => headers
495 .iter()
496 .map(|(name, value)| (name.as_str(), value.as_str()))
497 .collect(),
498 HttpHeaders::List(headers) => headers
499 .iter()
500 .map(|header| (header.name.as_str(), header.value.as_str()))
501 .collect(),
502 }
503}
504
505fn capture_allows_headerless_form(request: &HttpRequest, context: &HttpCaptureContext) -> bool {
508 let headers = header_lines(&request.headers);
509 let present = |name: &str| {
510 headers
511 .iter()
512 .any(|(key, _)| key.eq_ignore_ascii_case(name))
513 };
514 !context.header_unknown("content-type", present("content-type"))
515 && !context.header_unknown("content-encoding", present("content-encoding"))
516 && !context.body_unavailable()
517 && context.body != Some(HttpBodyAvailability::Absent)
518}
519
520fn decode_request_with_form_hint(
521 request: &HttpRequest,
522 context: &HttpCaptureContext,
523 binary_body: Option<&str>,
524 clock: i64,
525 headerless_form: bool,
526) -> DecodedRequest {
527 let mut violations = Vec::new();
528 if !token(&request.method) && detect_macro_spans(&request.method).is_empty() {
529 violations.push(request_violation(
530 "invalid_method",
531 Some("method"),
532 "HTTP method must be a nonempty token.",
533 Severity::Error,
534 ));
535 }
536 let mut headers = Map::new();
537 for (name, value) in header_lines(&request.headers) {
538 if !token(name) && detect_macro_spans(name).is_empty() {
539 violations.push(request_violation(
540 "invalid_header",
541 Some("headers"),
542 "HTTP header names must be nonempty tokens.",
543 Severity::Error,
544 ));
545 }
546 if context.redacted_header(name) {
547 continue;
548 }
549 if value.bytes().any(|byte| {
550 byte == 0
551 || byte == b'\r'
552 || byte == b'\n'
553 || (byte < 32 && byte != b'\t')
554 || byte == 127
555 }) {
556 violations.push(request_violation(
557 "invalid_header",
558 Some("headers"),
559 "HTTP header values cannot contain control characters other than horizontal tab.",
560 Severity::Error,
561 ));
562 }
563 insert_repeated(
564 &mut headers,
565 name.to_ascii_lowercase(),
566 value.trim_matches([' ', '\t']).into(),
567 );
568 }
569 let ambiguous_content_type = headers.get("content-type").is_some_and(Value::is_array);
570 let content_type = headers
571 .get("content-type")
572 .and_then(Value::as_str)
573 .map(|value| {
574 value
575 .split(';')
576 .next()
577 .unwrap_or_default()
578 .trim()
579 .to_ascii_lowercase()
580 });
581 let has_body = request.body.is_some() || binary_body.is_some();
582 let encoding = match (has_body, content_type.as_deref()) {
583 (false, _) => "none",
584 (true, Some("application/x-www-form-urlencoded")) => "form",
585 (true, None) if headerless_form && !headers.contains_key("content-type") => "form",
586 (true, Some("application/x-ndjson")) => "ndjson",
587 (true, Some("multipart/form-data")) => "multipart",
588 (true, Some(mime)) if mime == "application/json" || mime.ends_with("+json") => "json",
589 _ => "text",
590 };
591 let mut entity_text = request.body.clone();
592 let mut wire_bytes = request.body.as_ref().map_or(0, String::len);
593 let capture_body_unavailable = context.body_unavailable();
594 let mut unavailable_paths = Vec::new();
595 if context.headers_unavailable {
596 unavailable_paths.push("headers".to_string());
597 }
598 for name in context
599 .unavailable_headers
600 .iter()
601 .chain(&context.redacted_headers)
602 {
603 let name = name.to_ascii_lowercase();
604 if context.header_unknown(&name, headers.contains_key(&name)) {
605 unavailable_paths.push(crate::json::member_path("headers", &name));
606 }
607 }
608 if context.header_unknown("authorization", headers.contains_key("authorization")) {
609 unavailable_paths.extend(["authorization_scheme".into(), "basic_auth".into()]);
610 }
611 let unavailable_content_type =
612 context.header_unknown("content-type", headers.contains_key("content-type"));
613 let unavailable_content_encoding =
614 context.header_unknown("content-encoding", headers.contains_key("content-encoding"));
615 if unavailable_content_type {
616 unavailable_paths.extend(["content_type".into(), "body_encoding".into()]);
617 }
618 if capture_body_unavailable {
619 unavailable_paths.extend(["body".into(), "body_encoding".into()]);
620 }
621 let decoder_context_unavailable =
622 has_body && (unavailable_content_type || unavailable_content_encoding);
623 if decoder_context_unavailable {
624 unavailable_paths.extend(["body".into(), "body_encoding".into()]);
625 }
626 if !unavailable_paths.is_empty() {
627 let mut finding = request_violation(
628 "capture_incomplete",
629 Some("capture"),
630 "Declared capture fields are unavailable or redacted. Checks that depend on them remain unvalidated; observed method, URL and other fields still run.",
631 Severity::Info,
632 );
633 finding.source = RuleSource::normative(
634 "HTTP Archive 1.2 request representation",
635 "https://webperfwg.org/specs/HAR/Overview.html#request",
636 );
637 violations.push(finding);
638 }
639 let compressed = headers.get("content-encoding").is_some_and(|value| {
640 value
641 .as_str()
642 .is_none_or(|value| !value.eq_ignore_ascii_case("identity"))
643 });
644 let gzip = headers
645 .get("content-encoding")
646 .and_then(Value::as_str)
647 .is_some_and(|value| value.eq_ignore_ascii_case("gzip"));
648 let mut unsupported = capture_body_unavailable
649 || decoder_context_unavailable
650 || (has_body
651 && ((compressed && !(binary_body.is_some() && gzip))
652 || ambiguous_content_type
653 || content_type.as_deref().is_some_and(|mime| {
654 mime.starts_with("multipart/") && mime != "multipart/form-data"
655 })));
656 if unsupported && !capture_body_unavailable && !decoder_context_unavailable {
657 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));
658 }
659 if !unsupported && let Some(encoded) = binary_body {
660 match crate::binary_body::decode(encoded, gzip) {
661 Ok(decoded) => {
662 wire_bytes = decoded.wire_bytes;
663 entity_text = Some(decoded.text);
664 }
665 Err(problem) => {
666 let (code, severity, message, reference) = match problem {
667 crate::binary_body::BinaryBodyError::Base64 => (
668 "invalid_base64_body",
669 Severity::Error,
670 "body_base64 must contain canonical standard Base64 with required padding and no whitespace.",
671 "https://www.rfc-editor.org/rfc/rfc4648",
672 ),
673 crate::binary_body::BinaryBodyError::Gzip => (
674 "invalid_gzip_body",
675 Severity::Error,
676 "The captured gzip entity has invalid framing, compressed data, checksum, size, truncation or trailing bytes.",
677 "https://www.rfc-editor.org/rfc/rfc1952",
678 ),
679 crate::binary_body::BinaryBodyError::Utf8
680 if matches!(encoding, "json" | "ndjson") =>
681 {
682 (
683 "invalid_body_utf8",
684 Severity::Error,
685 "The captured JSON entity is not UTF-8 encoded.",
686 "https://www.rfc-editor.org/rfc/rfc8259#section-8.1",
687 )
688 }
689 crate::binary_body::BinaryBodyError::Utf8 => (
690 "unsupported_body_encoding",
691 Severity::Info,
692 "The captured binary entity is outside local UTF-8 text inspection. Payload checks remain unvalidated; no vendor rejection is inferred.",
693 "https://github.com/aleksUIX/pixellint/blob/main/docs/HTTP_REQUEST_SCHEMA.md",
694 ),
695 crate::binary_body::BinaryBodyError::Limit => (
696 "body_decode_limit",
697 Severity::Info,
698 "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.",
699 "https://github.com/aleksUIX/pixellint/blob/main/docs/HTTP_REQUEST_SCHEMA.md",
700 ),
701 };
702 let mut finding = request_violation(code, Some("body_base64"), message, severity);
703 finding.source = if severity == Severity::Info {
704 RuleSource {
705 level: crate::RuleSourceLevel::Heuristic,
706 name: "Pixellint local binary inspection scope".into(),
707 reference: Some(reference.into()),
708 }
709 } else {
710 RuleSource::normative("Captured binary entity decoding", reference)
711 };
712 violations.push(finding);
713 entity_text = None;
714 unsupported = true;
715 }
716 }
717 }
718 let wire = entity_text.as_deref();
719 let mut decoded_body = Value::Null;
720 let mut json_body = None;
721 let mut multipart_fields = None;
722 if !unsupported && let Some(body) = wire {
723 match encoding {
724 "multipart" => {
725 let raw_type = headers
726 .get("content-type")
727 .and_then(Value::as_str)
728 .unwrap_or_default();
729 match crate::multipart::decode(raw_type, body) {
730 Ok(fields) => {
731 let mut values = Map::new();
732 for field in &fields {
733 insert_repeated(
734 &mut values,
735 field.name.to_string(),
736 field.value.to_string(),
737 );
738 }
739 decoded_body = Value::Object(values);
740 multipart_fields = Some(fields);
741 }
742 Err(problem) => {
743 let (code, severity, message) = match problem {
744 crate::multipart::MultipartError::Invalid(message) => {
745 ("invalid_multipart", Severity::Error, message)
746 }
747 crate::multipart::MultipartError::Unsupported(message) => {
748 ("unsupported_body_encoding", Severity::Info, message)
749 }
750 };
751 let mut finding = request_violation(code, Some("body"), message, severity);
752 finding.source = RuleSource::normative(
753 "Multipart form data (RFC 7578 and RFC 2046)",
754 "https://www.rfc-editor.org/rfc/rfc7578",
755 );
756 violations.push(finding);
757 unsupported = true;
758 }
759 }
760 }
761 "form" if body.trim_start().starts_with(['{', '[']) => {
762 json_body = Some(body.into());
765 decoded_body = decode_json_entity(body);
766 }
767 "form" => {
768 let mut fields = Map::new();
769 for (name, value) in url::form_urlencoded::parse(body.as_bytes()) {
770 insert_repeated(&mut fields, name.into_owned(), value.into_owned());
771 }
772 decoded_body = Value::Object(fields);
773 }
774 "ndjson" => {
775 let mut values = Vec::new();
776 let mut valid = true;
777 for line in body.lines().filter(|line| !line.trim().is_empty()) {
778 match serde_json::from_str::<Value>(line) {
779 Ok(value) => values.push(value),
780 Err(_) => {
781 valid = false;
782 break;
783 }
784 }
785 }
786 if valid {
787 decoded_body = Value::Array(values);
788 json_body = Some(decoded_body.to_string());
789 } else {
790 violations.push(request_violation(
791 "invalid_ndjson",
792 Some("body"),
793 "Each nonempty NDJSON line must be valid JSON.",
794 Severity::Error,
795 ));
796 }
797 }
798 _ if encoding == "json" || body.trim_start().starts_with(['{', '[']) => {
799 json_body = Some(body.into());
800 decoded_body = decode_json_entity(body);
801 }
802 _ => decoded_body = Value::String(body.into()),
803 }
804 }
805 if json_body
806 .as_ref()
807 .is_some_and(|body| crate::json::JsonDocument::parse(body).is_err())
808 {
809 decoded_body = Value::Null;
810 }
811 let mut normalized = Map::new();
812 normalized.insert("url".into(), Value::String(request.url.clone()));
813 normalized.insert("method".into(), Value::String(request.method.clone()));
814 let url_request = ValidationRequest {
815 artifact_kind: ArtifactKind::Url,
816 artifact: request.url.clone(),
817 claimed_vendor: None,
818 expansion_state: crate::ExpansionState::Unknown,
819 };
820 let parsed = PreparedArtifact::from_request_at(&url_request, clock);
821 if let Some(url) = parsed.url() {
822 normalized.insert("path".into(), Value::String(url.path.to_string()));
823 if let Some(host) = &url.host {
824 normalized.insert("host".into(), Value::String(host.to_string()));
825 }
826 }
827 let mut query = Map::new();
828 let without_fragment = request.url.split('#').next().unwrap_or_default();
829 if let Some((_, query_text)) = without_fragment.split_once('?') {
830 for (name, value) in
831 url::form_urlencoded::parse(query_text.split('#').next().unwrap_or_default().as_bytes())
832 {
833 insert_repeated(&mut query, name.into_owned(), value.into_owned());
834 }
835 }
836 normalized.insert("query".into(), Value::Object(query));
837 if let Some(content_type) = &content_type {
838 normalized.insert("content_type".into(), Value::String(content_type.clone()));
839 }
840 if let Some(auth) = headers.get("authorization").and_then(Value::as_str)
841 && let Some((scheme, credentials)) = auth.split_once([' ', '\t'])
842 {
843 normalized.insert(
844 "authorization_scheme".into(),
845 Value::String(scheme.to_ascii_lowercase()),
846 );
847 if scheme.eq_ignore_ascii_case("basic")
848 && let Ok(decoded) =
849 crate::manifest::decode_base64_json(credentials.trim_start_matches([' ', '\t']))
850 {
851 let (username, password, has_colon) = match decoded.split_once(':') {
852 Some((username, password)) => (username, password, true),
853 None => (decoded.as_str(), "", false),
854 };
855 normalized.insert("basic_auth".into(), serde_json::json!({ "username":username, "password":password, "has_colon":has_colon }));
856 }
857 }
858 if ambiguous_content_type || unavailable_content_type {
859 normalized.insert("content_type_unavailable".into(), Value::Bool(true));
860 }
861 if binary_body.is_some() && unsupported {
862 unavailable_paths.extend(["wire_body_bytes".into(), "entity_body_bytes".into()]);
863 }
864 if !unavailable_paths.is_empty() {
865 normalized.insert(
866 "capture_unavailable_paths".into(),
867 serde_json::json!(unavailable_paths),
868 );
869 }
870 normalized.insert("headers".into(), Value::Object(headers));
871 if binary_body.is_some() && !unsupported {
872 normalized.insert("wire_body_bytes".into(), serde_json::json!(wire_bytes));
873 normalized.insert(
874 "entity_body_bytes".into(),
875 serde_json::json!(wire.map_or(0, str::len)),
876 );
877 }
878 normalized.insert("body".into(), decoded_body);
879 normalized.insert(
880 "body_encoding".into(),
881 Value::String(if unsupported { "unsupported" } else { encoding }.into()),
882 );
883 let unavailable_form = unsupported
884 && (ambiguous_content_type
885 || encoding == "form"
886 || decoder_context_unavailable
887 || (capture_body_unavailable && content_type.is_none())
888 || content_type_from_headers(request)
889 .is_some_and(|mime| mime.starts_with("multipart/")));
890 let form = encoding == "form" && !unsupported && json_body.is_none();
891 DecodedRequest {
892 normalized: Value::Object(normalized).to_string(),
893 json_body,
894 form,
895 multipart_fields,
896 unavailable_form,
897 entity_bytes: wire.map_or(0, str::len),
898 unsupported,
899 entity_text,
900 violations,
901 }
902}
903
904fn content_type_from_headers(request: &HttpRequest) -> Option<String> {
905 header_lines(&request.headers)
906 .into_iter()
907 .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
908 .map(|(_, value)| {
909 value
910 .split(';')
911 .next()
912 .unwrap_or_default()
913 .trim()
914 .to_ascii_lowercase()
915 })
916}
917
918fn decode_json_entity(body: &str) -> Value {
919 if crate::json::JsonDocument::parse(body).is_err() {
922 return Value::Null;
923 }
924 serde_json::from_str(body).unwrap_or(Value::Null)
925}
926
927fn secret_values(request: &HttpRequest) -> Vec<String> {
928 let mut values = Vec::new();
929 for (name, value) in header_lines(&request.headers) {
930 let name = name.to_ascii_lowercase();
931 if name.contains("authorization")
932 || name.contains("cookie")
933 || name.contains("key")
934 || name.contains("token")
935 || name == "authentication"
936 {
937 values.push(value.to_owned());
938 let value = value.trim_matches([' ', '\t']);
939 values.push(value.to_owned());
940 if let Some((scheme, credential)) = value.split_once([' ', '\t']) {
941 let credential = credential.trim_start_matches([' ', '\t']);
942 values.push(credential.to_owned());
943 if scheme.eq_ignore_ascii_case("basic")
944 && let Ok(decoded) = crate::manifest::decode_base64_json(credential)
945 {
946 values.push(decoded.clone());
947 if let Some((username, password)) = decoded.split_once(':') {
948 values.extend([username.to_owned(), password.to_owned()]);
949 }
950 }
951 }
952 }
953 }
954 values.retain(|value| !value.is_empty());
955 values.sort_by_key(|value| std::cmp::Reverse(value.len()));
956 values.dedup();
957 values
958}
959
960fn collect_entity_secrets(value: &Value, secret: bool, values: &mut Vec<String>) {
961 match value {
962 Value::Object(object) => {
963 for (name, value) in object {
964 let name = name.to_ascii_lowercase();
965 let sensitive = secret
966 || [
967 "authorization",
968 "cookie",
969 "key",
970 "token",
971 "secret",
972 "password",
973 ]
974 .iter()
975 .any(|part| name.contains(part));
976 collect_entity_secrets(value, sensitive, values);
977 }
978 }
979 Value::Array(array) => {
980 for value in array {
981 collect_entity_secrets(value, secret, values);
982 }
983 }
984 Value::String(value) if secret => values.push(value.clone()),
985 _ if secret && !value.is_null() => values.push(value.to_string()),
986 _ => {}
987 }
988}
989
990fn sdk_redact(text: &str, secrets: &[String]) -> String {
992 if secrets.is_empty() {
993 return text.into();
994 }
995 let mut output = String::new();
996 let mut index = 0;
997 while index < text.len() {
998 let tail = &text[index..];
999 if let Some(secret) = secrets
1000 .iter()
1001 .find(|secret| tail.starts_with(secret.as_str()))
1002 {
1003 output.push_str("[redacted]");
1004 index += secret.len();
1005 } else {
1006 let character = tail.chars().next().expect("remaining text");
1007 output.push(character);
1008 index += character.len_utf8();
1009 }
1010 }
1011 output
1012}
1013
1014#[cfg(test)]
1015mod sdk_sanitizer_tests {
1016 use super::sdk_redact;
1017 #[test]
1018 fn replacement_text_is_not_rescanned_for_short_or_overlapping_secrets() {
1019 let secrets = vec!["redacted".into(), "a".into()];
1020 assert_eq!(sdk_redact("a redacted", &secrets), "[redacted] [redacted]");
1021 assert_eq!(sdk_redact("雪a😀", &secrets), "雪[redacted]😀");
1022 }
1023}