From 0e5f137d37c6f52efa897bef674996ad490e5160 Mon Sep 17 00:00:00 2001 From: mldangelo-oai Date: Tue, 9 Jun 2026 04:45:11 -0700 Subject: [PATCH] fix: reject undersized nested pickle budgets --- modelaudit/scanners/picklescan_adapter.py | 2 +- packages/modelaudit-picklescan/CHANGELOG.md | 1 + .../modelaudit-picklescan/rust/src/nested.rs | 304 +++++++++++++-- .../modelaudit-picklescan/rust/src/options.rs | 12 +- .../modelaudit-picklescan/rust/src/state.rs | 22 +- .../src/modelaudit_picklescan/options.py | 5 +- .../modelaudit-picklescan/tests/test_api.py | 19 + .../tests/test_nested_budget_limits.py | 361 ++++++++++++++++++ .../tests/test_options.py | 18 +- tests/conftest.py | 1 + tests/scanners/test_picklescan_adapter.py | 4 + 11 files changed, 697 insertions(+), 52 deletions(-) create mode 100644 packages/modelaudit-picklescan/tests/test_nested_budget_limits.py diff --git a/modelaudit/scanners/picklescan_adapter.py b/modelaudit/scanners/picklescan_adapter.py index 76f051d73..919ea0f43 100644 --- a/modelaudit/scanners/picklescan_adapter.py +++ b/modelaudit/scanners/picklescan_adapter.py @@ -167,7 +167,7 @@ def scan_options_from_config(config: Mapping[str, Any]) -> ScanOptions: _DEFAULT_SCAN_OPTIONS.max_nested_pickle_bytes, ), _DEFAULT_SCAN_OPTIONS.max_nested_pickle_bytes, - minimum=0, + minimum=2, ), max_nested_depth=_parse_min_int( config.get( diff --git a/packages/modelaudit-picklescan/CHANGELOG.md b/packages/modelaudit-picklescan/CHANGELOG.md index 4cf5448a9..f1b327186 100644 --- a/packages/modelaudit-picklescan/CHANGELOG.md +++ b/packages/modelaudit-picklescan/CHANGELOG.md @@ -78,6 +78,7 @@ and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug Fixes +- reject nested-pickle analysis budgets below two bytes, recognize explicit protocol 0 headers, and use bounded structural probes to avoid prose false positives without missing valid suffixes - preserve fail-closed nested protocol 0 analysis after `INST` opcodes - fail closed when known-size pickle boundaries cannot be verified or contain trailing bytes, and bind file scans to one descriptor - fail closed before copying protocol 0 line operands larger than 8 MiB diff --git a/packages/modelaudit-picklescan/rust/src/nested.rs b/packages/modelaudit-picklescan/rust/src/nested.rs index b5300424a..edc922c31 100644 --- a/packages/modelaudit-picklescan/rust/src/nested.rs +++ b/packages/modelaudit-picklescan/rust/src/nested.rs @@ -1,10 +1,13 @@ use crate::opcode::{parse_opcode, ParseError, ParsedOpcode}; +use crate::options::DEFAULT_MAX_STRING_LITERAL_SCAN_CHARS; +use crate::policy::global_severity; const BASE64_LITERAL_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; const HEX_LITERAL_CHARS: &[u8] = b"0123456789abcdefABCDEF"; const ENCODED_LITERAL_PROBE_CHARS: usize = 64; const PROTOCOL0_SCALAR_PREFIX_PROBE_BYTES: usize = 256; +const NESTED_STRUCTURAL_PROBE_BYTES: usize = 1024; pub(crate) const MAX_NESTED_PAYLOAD_PROBES: usize = 64; const MAX_ENCODED_LITERAL_MID_SCAN_BYTES: usize = 1024 * 1024; pub(crate) const MAX_ENCODED_LITERAL_PREFIX_SCAN_BYTES: usize = @@ -48,12 +51,13 @@ pub(crate) fn decode_possible_encoded_pickle( } let mut decoded_values: Vec = Vec::new(); - let max_base64_nested_pickle_chars = max_nested_pickle_bytes.div_ceil(3) * 4; - let max_hex_nested_pickle_chars = max_nested_pickle_bytes * 2; - let max_escaped_hex_nested_pickle_chars = max_nested_pickle_bytes * 4; + let decoded_probe_bytes = max_nested_pickle_bytes.max(DEFAULT_MAX_STRING_LITERAL_SCAN_CHARS); + let max_base64_nested_pickle_chars = decoded_probe_bytes.div_ceil(3).saturating_mul(4); + let max_hex_nested_pickle_chars = decoded_probe_bytes.saturating_mul(2); + let max_escaped_hex_nested_pickle_chars = decoded_probe_bytes.saturating_mul(4); for candidate in encoded_literal_candidates(stripped) { - if base64_prefix_has_pickle_prefix(&candidate) { + if base64_structural_prefix_has_pickle_prefix(&candidate) { for bounded_base64 in base64_literal_candidates( &candidate, max_base64_nested_pickle_chars, @@ -83,7 +87,7 @@ pub(crate) fn decode_possible_encoded_pickle( } } - if hex_prefix_has_pickle_prefix(&candidate) { + if hex_structural_prefix_has_pickle_prefix(&candidate) { let hex_token = take_hex_literal_prefix(&candidate, candidate.len()); let encoding = hex_encoding_label(hex_token); let bounded_hex = @@ -170,10 +174,12 @@ pub(crate) fn detect_oversized_encoded_pickle_prefixes( } let mut detected = Vec::new(); - let probe_decoded_bytes = (max_nested_pickle_bytes + 1).max(2); + let probe_decoded_bytes = max_nested_pickle_bytes + .saturating_add(1) + .max(DEFAULT_MAX_STRING_LITERAL_SCAN_CHARS); - if base64_prefix_has_pickle_prefix(stripped) { - let max_base64_probe_chars = (probe_decoded_bytes.div_ceil(3) * 4).max(16); + if base64_structural_prefix_has_pickle_prefix(stripped) { + let max_base64_probe_chars = probe_decoded_bytes.div_ceil(3).saturating_mul(4).max(16); let oversized_prefix_found = base64_literal_candidates(stripped, max_base64_probe_chars, stripped.len()) .into_iter() @@ -182,7 +188,10 @@ pub(crate) fn detect_oversized_encoded_pickle_prefixes( .as_deref() .and_then(decode_base64) .is_some_and(|decoded| { - decoded.len() > max_nested_pickle_bytes && has_pickle_prefix(&decoded) + oversized_decoded_pickle_prefix_requires_fail_closed( + &decoded, + max_nested_pickle_bytes, + ) }) }); if oversized_prefix_found { @@ -195,10 +204,12 @@ pub(crate) fn detect_oversized_encoded_pickle_prefixes( } } - if hex_prefix_has_pickle_prefix(stripped) { + if hex_structural_prefix_has_pickle_prefix(stripped) { let hex_token = take_hex_literal_prefix(stripped, stripped.len()); let encoding = hex_encoding_label(hex_token); - let max_hex_probe_chars = (probe_decoded_bytes * 4).max(ENCODED_LITERAL_PROBE_CHARS); + let max_hex_probe_chars = probe_decoded_bytes + .saturating_mul(4) + .max(ENCODED_LITERAL_PROBE_CHARS); let bounded_hex = take_hex_literal_prefix(hex_token, max_hex_probe_chars); let mut hex_candidate = strip_escaped_hex_markers(bounded_hex); hex_candidate.truncate(hex_candidate.len() - (hex_candidate.len() % 2)); @@ -207,7 +218,10 @@ pub(crate) fn detect_oversized_encoded_pickle_prefixes( && !is_repeated_single_char(&hex_candidate) { if let Some(decoded) = decode_hex(&hex_candidate) { - if decoded.len() > max_nested_pickle_bytes && has_pickle_prefix(&decoded) { + if oversized_decoded_pickle_prefix_requires_fail_closed( + &decoded, + max_nested_pickle_bytes, + ) { detected.push((encoding, estimate_hex_decoded_size(hex_token))); } } @@ -217,6 +231,21 @@ pub(crate) fn detect_oversized_encoded_pickle_prefixes( detected } +fn oversized_decoded_pickle_prefix_requires_fail_closed( + decoded: &[u8], + max_nested_pickle_bytes: usize, +) -> bool { + nested_pickle_probe_offsets_unbounded(decoded) + .into_iter() + .any(|offset| { + let candidate = &decoded[offset..]; + if candidate.len() <= max_nested_pickle_bytes { + return false; + } + bounded_truncated_pickle_prefix_requires_fail_closed(candidate, max_nested_pickle_bytes) + }) +} + pub(crate) fn looks_like_pickle_payload(value: &[u8], max_bytes: usize) -> bool { pickle_payload_extent(value, max_bytes).is_some() } @@ -435,6 +464,13 @@ pub(crate) fn has_pickle_prefix(value: &[u8]) -> bool { } fn has_nested_probe_prefix(value: &[u8]) -> bool { + match value.first() { + Some(b'c') => { + return protocol0_global_prefix_is_nested_probe(value); + } + Some(b'd' | b'i' | b'l') => return false, + _ => {} + } has_pickle_prefix(value) && (!value .first() @@ -442,6 +478,32 @@ fn has_nested_probe_prefix(value: &[u8]) -> bool { || protocol0_scalar_prefix_has_bounded_line(value)) } +fn protocol0_global_prefix_is_nested_probe(value: &[u8]) -> bool { + let probe_len = value.len().min(NESTED_STRUCTURAL_PROBE_BYTES); + let probe = &value[..probe_len]; + let Ok(global) = parse_opcode(probe, 0, probe.len()) else { + return false; + }; + if global.name != "GLOBAL" || !protocol0_opcode_operands_are_plausible(&global, probe) { + return false; + } + let (module, name) = global.arg.global_parts(probe); + if !is_protocol0_import_reference(module.as_bytes()) + || !is_protocol0_import_reference(name.as_bytes()) + { + return false; + } + if global_severity(&module, &name).is_some() { + return true; + } + if value.len() <= DEFAULT_MAX_STRING_LITERAL_SCAN_CHARS + && pickle_payload_extent_result(value, value.len()).is_ok_and(|extent| extent.is_some()) + { + return true; + } + pickle_prefix_has_structured_opcodes_with_minimum_anchors(probe, false, 2) +} + fn protocol0_scalar_prefix_has_bounded_line(value: &[u8]) -> bool { value .first() @@ -454,15 +516,30 @@ fn protocol0_scalar_prefix_has_bounded_line(value: &[u8]) -> bool { } pub(crate) fn has_binary_pickle_prefix(value: &[u8]) -> bool { - value.len() >= 2 && value[0] == 0x80 && matches!(value[1], 1..=5) + value.len() >= 2 && value[0] == 0x80 && matches!(value[1], 0..=5) } pub(crate) fn truncated_pickle_prefix_requires_fail_closed(value: &[u8]) -> bool { - (has_binary_pickle_prefix(value) && pickle_prefix_has_structured_opcodes(value, true)) + is_minimum_binary_pickle_prefix(value) + || (has_binary_pickle_prefix(value) && pickle_prefix_has_structured_opcodes(value, true)) || protocol0_global_or_inst_prefix_has_lines(value) || has_execution_opcode(value) } +pub(crate) fn bounded_truncated_pickle_prefix_requires_fail_closed( + value: &[u8], + max_nested_pickle_bytes: usize, +) -> bool { + let probe_len = value + .len() + .min(max_nested_pickle_bytes.max(NESTED_STRUCTURAL_PROBE_BYTES)); + truncated_pickle_prefix_requires_fail_closed(&value[..probe_len]) +} + +fn is_minimum_binary_pickle_prefix(value: &[u8]) -> bool { + value.len() == 2 && has_binary_pickle_prefix(value) +} + pub(crate) fn protocol0_global_or_inst_prefix_has_import_reference_lines(value: &[u8]) -> bool { if !matches!(value.first().copied(), Some(b'c' | b'i')) { return false; @@ -478,11 +555,19 @@ pub(crate) fn protocol0_global_or_inst_prefix_has_import_reference_lines(value: } fn pickle_prefix_has_structured_opcodes(value: &[u8], allow_truncated: bool) -> bool { + pickle_prefix_has_structured_opcodes_with_minimum_anchors(value, allow_truncated, 1) +} + +fn pickle_prefix_has_structured_opcodes_with_minimum_anchors( + value: &[u8], + allow_truncated: bool, + minimum_incomplete_anchors: usize, +) -> bool { let mut index = 0usize; let mut stack_depth = 0usize; let mut mark_depths = Vec::new(); let mut parsed_count = 0usize; - let mut saw_probe_anchor = false; + let mut probe_anchor_count = 0usize; while index < value.len() && parsed_count < 4 { let parsed = match parse_opcode(value, index, value.len()) { Ok(parsed) => parsed, @@ -490,20 +575,27 @@ fn pickle_prefix_has_structured_opcodes(value: &[u8], allow_truncated: bool) -> if allow_truncated { return parsed_count > 0 && value.get(index).is_some_and(is_pickle_opcode_byte); } - return saw_probe_anchor && parsed_count >= 2; + return probe_anchor_count >= minimum_incomplete_anchors && parsed_count >= 2; } }; + if matches!(parsed.name, "GLOBAL" | "INST") + && !protocol0_opcode_operands_are_import_references(&parsed, value) + { + return false; + } if !validate_pickle_stack_effect(&parsed, &mut stack_depth, &mut mark_depths) { return false; } - saw_probe_anchor |= is_structured_nested_probe_anchor(parsed.name); + probe_anchor_count += usize::from(is_structured_nested_probe_anchor(parsed.name)); parsed_count += 1; index = parsed.next; if parsed.name == "STOP" { - return stack_depth > 0 && (allow_truncated || saw_probe_anchor); + return stack_depth > 0 && (allow_truncated || probe_anchor_count > 0); } } - parsed_count >= 2 && stack_depth > 0 && (allow_truncated || saw_probe_anchor) + parsed_count >= 2 + && stack_depth > 0 + && (allow_truncated || probe_anchor_count >= minimum_incomplete_anchors) } fn is_pickle_opcode_byte(byte: &u8) -> bool { @@ -613,7 +705,7 @@ fn protocol0_global_or_inst_prefix_has_lines(value: &[u8]) -> bool { return false; }; let Some(name) = fields.next() else { - return false; + return is_protocol0_global_operand(module); }; is_protocol0_global_operand(module) && (name.is_empty() || is_protocol0_global_operand(name)) } @@ -637,6 +729,12 @@ fn protocol0_opcode_operands_are_plausible(opcode: &ParsedOpcode, value: &[u8]) is_protocol0_global_operand(module.as_bytes()) && is_protocol0_global_operand(name.as_bytes()) } +fn protocol0_opcode_operands_are_import_references(opcode: &ParsedOpcode, value: &[u8]) -> bool { + let (module, name) = opcode.arg.global_parts(value); + is_protocol0_import_reference(module.as_bytes()) + && is_protocol0_import_reference(name.as_bytes()) +} + fn is_protocol0_global_operand(value: &[u8]) -> bool { !value.is_empty() && value.len() <= 256 @@ -783,7 +881,7 @@ pub(crate) fn encoded_literal_may_contain_pickle(value: &str) -> bool { if stripped.len() < 16 { return false; } - if encoded_prefix_has_pickle_prefix(stripped) { + if encoded_structural_prefix_has_pickle_prefix(stripped) { return true; } @@ -805,8 +903,9 @@ pub(crate) fn encoded_literal_may_contain_pickle(value: &str) -> bool { pub(crate) fn encoded_pickle_consumes_literal(value: &str) -> bool { let stripped = value.trim(); - let prefix_has_base64_pickle = base64_prefix_has_pickle_prefix(stripped); - let prefix_has_hex_pickle = !prefix_has_base64_pickle && hex_prefix_has_pickle_prefix(stripped); + let prefix_has_base64_pickle = base64_structural_prefix_has_pickle_prefix(stripped); + let prefix_has_hex_pickle = + !prefix_has_base64_pickle && hex_structural_prefix_has_pickle_prefix(stripped); encoded_prefix_consumes_literal(stripped, prefix_has_base64_pickle, prefix_has_hex_pickle) } @@ -871,6 +970,11 @@ fn encoded_prefix_has_pickle_prefix(value: &str) -> bool { base64_prefix_has_pickle_prefix(value) || hex_prefix_has_pickle_prefix(value) } +fn encoded_structural_prefix_has_pickle_prefix(value: &str) -> bool { + base64_structural_prefix_has_pickle_prefix(value) + || hex_structural_prefix_has_pickle_prefix(value) +} + fn encoded_prefix_consumes_literal( value: &str, prefix_has_base64_pickle: bool, @@ -1067,24 +1171,71 @@ fn base64_prefix_has_pickle_prefix(value: &str) -> bool { base64_prefix_has_pickle_prefix_with_limit(value, MAX_ENCODED_LITERAL_PREFIX_SCAN_BYTES) } +fn base64_structural_prefix_has_pickle_prefix(value: &str) -> bool { + let max_encoded_chars = NESTED_STRUCTURAL_PROBE_BYTES.div_ceil(3) * 4; + base64_prefix_has_pickle_prefix_with_probe_chars( + value, + MAX_ENCODED_LITERAL_PREFIX_SCAN_BYTES, + max_encoded_chars, + ) +} + fn base64_prefix_has_pickle_prefix_with_limit(value: &str, max_input_bytes: usize) -> bool { - base64_literal_candidates(value, ENCODED_LITERAL_PROBE_CHARS, max_input_bytes) - .into_iter() - .any(|prefix| { - canonical_base64_candidate(&prefix) - .as_deref() - .and_then(decode_base64) - .is_some_and(|decoded| has_nested_probe_prefix(&decoded)) - }) + base64_prefix_has_pickle_prefix_with_probe_chars( + value, + max_input_bytes, + ENCODED_LITERAL_PROBE_CHARS, + ) +} + +fn base64_prefix_has_pickle_prefix_with_probe_chars( + value: &str, + max_input_bytes: usize, + max_encoded_chars: usize, +) -> bool { + let strict = take_base64_literal_prefix(value, max_encoded_chars.min(max_input_bytes)); + if canonical_base64_candidate(strict) + .as_deref() + .and_then(decode_base64) + .is_some_and(|decoded| decoded_contains_pickle_prefix(&decoded)) + { + return true; + } + + let lenient = normalize_base64_literal(value, max_encoded_chars, max_input_bytes); + lenient != strict + && canonical_base64_candidate(&lenient) + .as_deref() + .and_then(decode_base64) + .is_some_and(|decoded| has_nested_probe_prefix(&decoded)) } fn hex_prefix_has_pickle_prefix(value: &str) -> bool { hex_prefix_has_pickle_prefix_with_limit(value, MAX_ENCODED_LITERAL_PREFIX_SCAN_BYTES) } +fn hex_structural_prefix_has_pickle_prefix(value: &str) -> bool { + hex_prefix_has_pickle_prefix_with_probe_digits( + value, + MAX_ENCODED_LITERAL_PREFIX_SCAN_BYTES, + NESTED_STRUCTURAL_PROBE_BYTES * 2, + ) +} + fn hex_prefix_has_pickle_prefix_with_limit(value: &str, max_input_bytes: usize) -> bool { - let mut hex_candidate = - normalize_hex_literal(value, ENCODED_LITERAL_PROBE_CHARS * 4, max_input_bytes); + hex_prefix_has_pickle_prefix_with_probe_digits( + value, + max_input_bytes, + ENCODED_LITERAL_PROBE_CHARS * 4, + ) +} + +fn hex_prefix_has_pickle_prefix_with_probe_digits( + value: &str, + max_input_bytes: usize, + max_hex_digits: usize, +) -> bool { + let mut hex_candidate = normalize_hex_literal(value, max_hex_digits, max_input_bytes); if hex_candidate.len() % 2 == 1 { hex_candidate.pop(); } @@ -1092,10 +1243,14 @@ fn hex_prefix_has_pickle_prefix_with_limit(value: &str, max_input_bytes: usize) return false; } decode_hex(&hex_candidate) - .map(|decoded| has_nested_probe_prefix(&decoded)) + .map(|decoded| decoded_contains_pickle_prefix(&decoded)) .unwrap_or(false) } +fn decoded_contains_pickle_prefix(decoded: &[u8]) -> bool { + (0..decoded.len().saturating_sub(1)).any(|offset| has_pickle_prefix(&decoded[offset..])) +} + fn take_base64_literal_prefix(value: &str, max_chars: usize) -> &str { let bytes = value.as_bytes(); let limit = bytes.len().min(max_chars); @@ -1467,15 +1622,17 @@ mod tests { } #[test] - fn encoded_prefix_gates_recognize_binary_protocols_1_to_5() { - for encoded in ["gAF9Lg==", "gAJ9Lg==", "gAN9Lg==", "gAR9Lg==", "gAV9Lg=="] { + fn encoded_prefix_gates_recognize_binary_protocols_0_to_5() { + for encoded in [ + "gAB9Lg==", "gAF9Lg==", "gAJ9Lg==", "gAN9Lg==", "gAR9Lg==", "gAV9Lg==", + ] { assert!(base64_prefix_has_pickle_prefix(encoded)); let wrapped = format!("prefix-{encoded}-suffix"); let windows = encoded_nested_literal_probe_windows(&wrapped, 64); assert!(windows.iter().any(|window| window.starts_with(encoded))); } - for protocol in 1..=5 { + for protocol in 0..=5 { let encoded = format!("800{protocol}7d2e"); assert!(hex_prefix_has_pickle_prefix(&encoded)); let wrapped = format!("prefix-{encoded}-suffix"); @@ -1832,7 +1989,14 @@ mod tests { } #[test] - fn truncated_prefix_fail_closed_ignores_structural_protocol0_near_matches() { + fn truncated_prefix_fail_closed_handles_minimum_evidence_and_near_matches() { + assert!(truncated_pickle_prefix_requires_fail_closed(b"\x80\x00")); + assert!(truncated_pickle_prefix_requires_fail_closed(b"\x80\x04")); + assert!(!truncated_pickle_prefix_requires_fail_closed(b"\x80\x06")); + assert!(truncated_pickle_prefix_requires_fail_closed(b"cp")); + assert!(truncated_pickle_prefix_requires_fail_closed(b"cposix\n")); + assert!(!truncated_pickle_prefix_requires_fail_closed(b"c")); + assert!(!truncated_pickle_prefix_requires_fail_closed(b"c\xff")); assert!(truncated_pickle_prefix_requires_fail_closed( b"\x80\x04]K\x01aK\x02aK\x03a" )); @@ -1866,6 +2030,70 @@ mod tests { assert!(!truncated_pickle_prefix_requires_fail_closed( b"}q\x00BBBBBBBB" )); + assert_eq!( + nested_pickle_probe_offsets_unbounded(b"cxxx\nxxx\n)R."), + vec![0] + ); + assert!(nested_pickle_probe_offsets_unbounded(b"config\nvalue\nplain prose").is_empty()); + assert!(nested_pickle_probe_offsets_unbounded(b"config\nvalue\nplain prose\n").is_empty()); + assert!(nested_pickle_probe_offsets_unbounded(b"instance\nvalue\nplain prose").is_empty()); + assert!( + nested_pickle_probe_offsets_unbounded(b"instance\nvalue\nplain prose\n").is_empty() + ); + assert!( + nested_pickle_probe_offsets_unbounded( + b"instance prose instance instance audit\nlist status prose nested config nested list\nclient budget value\nstatus audit audit audit nested plain client\n", + ) + .is_empty() + ); + assert!( + nested_pickle_probe_offsets_unbounded( + b"client config instance\nvalue list budget client\nclient nested model\naudit instance plain config budget client report\n", + ) + .is_empty() + ); + assert!( + nested_pickle_probe_offsets_unbounded( + b"client plain config report instance list client instance\nstatus\nconfig\ninstance budget report value\n", + ) + .is_empty() + ); + assert!(nested_pickle_probe_offsets_unbounded( + b"audit client\nbudget instance instance budget instance\nplain\nprose client status\n", + ) + .is_empty()); + assert!( + nested_pickle_probe_offsets_unbounded(b"client\nstatus\njust plain prose\nget\n") + .is_empty() + ); + let mut long_global = b"cattacker\nfactory\n(".to_vec(); + long_global.extend_from_slice(&vec![b'N'; 2000]); + long_global.extend_from_slice(b"tR."); + assert!(nested_pickle_probe_offsets_unbounded(&long_global).contains(&0)); + let mut long_global_operand = b"cattacker\nfactory\n(V".to_vec(); + long_global_operand.extend_from_slice(&vec![b'A'; 2000]); + long_global_operand.extend_from_slice(b"\ntR."); + assert!(nested_pickle_probe_offsets_unbounded(&long_global_operand).contains(&0)); + let mut long_binary_operand = b"cattacker\nfactory\nX".to_vec(); + long_binary_operand.extend_from_slice(&2000u32.to_le_bytes()); + long_binary_operand.extend_from_slice(&vec![b'A'; 2000]); + long_binary_operand.extend_from_slice(b"\x85R."); + assert!(nested_pickle_probe_offsets_unbounded(&long_binary_operand).contains(&0)); + assert!(nested_pickle_probe_offsets_unbounded(b"cos\nsystem\nAAAAAAAA").contains(&0)); + assert_eq!( + nested_pickle_probe_offsets_unbounded(b"(ios\nsystem\n."), + vec![0] + ); + assert_eq!(nested_pickle_probe_offsets_unbounded(b"(d."), vec![0]); + assert_eq!(nested_pickle_probe_offsets_unbounded(b"(l."), vec![0]); + assert!(oversized_decoded_pickle_prefix_requires_fail_closed( + b"\x80\x06cos\nsystem\n)R.", + 2 + )); + assert!(!oversized_decoded_pickle_prefix_requires_fail_closed( + b"config\nvalue\nplain prose", + 2 + )); } #[test] diff --git a/packages/modelaudit-picklescan/rust/src/options.rs b/packages/modelaudit-picklescan/rust/src/options.rs index 273dc8e1e..ee41e0a7e 100644 --- a/packages/modelaudit-picklescan/rust/src/options.rs +++ b/packages/modelaudit-picklescan/rust/src/options.rs @@ -39,7 +39,8 @@ impl ScanOptions { options, "max_nested_pickle_bytes", DEFAULT_MAX_NESTED_PICKLE_BYTES, - )?, + ) + .and_then(|value| require_minimum_usize(value, "max_nested_pickle_bytes", 2))?, max_nested_depth: option_usize(options, "max_nested_depth", DEFAULT_MAX_NESTED_DEPTH)?, }) } @@ -73,6 +74,15 @@ fn option_usize(options: &Bound<'_, PyDict>, key: &str, default: usize) -> PyRes } } +fn require_minimum_usize(value: usize, key: &str, minimum: usize) -> PyResult { + if value < minimum { + return Err(PyValueError::new_err(format!( + "{key} must be at least {minimum}" + ))); + } + Ok(value) +} + fn option_f64(options: &Bound<'_, PyDict>, key: &str, default: f64) -> PyResult { let value = match options.get_item(key)? { Some(value) => value.extract::(), diff --git a/packages/modelaudit-picklescan/rust/src/state.rs b/packages/modelaudit-picklescan/rust/src/state.rs index 5099e8eb2..25d80d6f8 100644 --- a/packages/modelaudit-picklescan/rust/src/state.rs +++ b/packages/modelaudit-picklescan/rust/src/state.rs @@ -10,14 +10,14 @@ use crate::expansion::{ ExpansionHeuristicState, }; use crate::nested::{ - decode_possible_encoded_pickle, detect_oversized_encoded_pickle_prefixes, - encoded_literal_may_contain_pickle, encoded_nested_literal_probe_coverage_incomplete, + bounded_truncated_pickle_prefix_requires_fail_closed, decode_possible_encoded_pickle, + detect_oversized_encoded_pickle_prefixes, encoded_literal_may_contain_pickle, + encoded_nested_literal_probe_coverage_incomplete, encoded_nested_literal_probe_windows_with_limit, encoded_nested_window_char_limit, encoded_pickle_consumes_literal, has_binary_pickle_prefix, has_execution_opcode, has_pickle_prefix, looks_like_pickle_payload, nested_pickle_probe_offsets, pickle_payload_extent_result, protocol0_global_or_inst_prefix_has_import_reference_lines, - truncated_pickle_prefix_requires_fail_closed, DecodedNestedPayload, NestedProbeOffsets, - MAX_NESTED_PAYLOAD_PROBES, + DecodedNestedPayload, NestedProbeOffsets, MAX_NESTED_PAYLOAD_PROBES, }; use crate::nested_surface::{ encoded_nested_payload_finding, is_allowlisted_nested_constructor_ref, @@ -5856,7 +5856,12 @@ impl<'a> ScanState<'a> { return; } let candidate_truncated = remaining_len > self.options.max_nested_pickle_bytes; - if candidate_truncated && truncated_pickle_prefix_requires_fail_closed(probe) { + if candidate_truncated + && bounded_truncated_pickle_prefix_requires_fail_closed( + &value[offset..], + self.options.max_nested_pickle_bytes, + ) + { self.add_nested_payload_finding( raw_nested_payload_finding(remaining_len, position + offset, true, false), true, @@ -5894,7 +5899,10 @@ impl<'a> ScanState<'a> { && (has_binary_pickle_prefix(probe) || protocol0_global_or_inst_prefix_has_import_reference_lines(probe)); let truncated_payload = remaining_len > self.options.max_nested_pickle_bytes - && truncated_pickle_prefix_requires_fail_closed(probe); + && bounded_truncated_pickle_prefix_requires_fail_closed( + &value[offset..], + self.options.max_nested_pickle_bytes, + ); if !complete_payload && !operand_limit_exceeded && !malformed_payload @@ -6065,7 +6073,7 @@ impl<'a> ScanState<'a> { position, ); } - false + whole_literal_is_encoded_pickle } fn scan_encoded_nested_pickle_candidate(&mut self, value: &str, position: usize) -> bool { diff --git a/packages/modelaudit-picklescan/src/modelaudit_picklescan/options.py b/packages/modelaudit-picklescan/src/modelaudit_picklescan/options.py index 124c8ab29..e29d010d4 100644 --- a/packages/modelaudit-picklescan/src/modelaudit_picklescan/options.py +++ b/packages/modelaudit-picklescan/src/modelaudit_picklescan/options.py @@ -88,11 +88,10 @@ def __post_init__(self) -> None: if ( isinstance(max_nested_pickle_bytes, bool) or not isinstance(max_nested_pickle_bytes, int) - or max_nested_pickle_bytes < 0 + or max_nested_pickle_bytes < 2 ): raise ValueError( - "max_nested_pickle_bytes must be greater than or equal to 0 and an integer, " - f"got {max_nested_pickle_bytes!r}", + f"max_nested_pickle_bytes must be at least 2 and an integer, got {max_nested_pickle_bytes!r}", ) max_nested_depth: object = self.max_nested_depth diff --git a/packages/modelaudit-picklescan/tests/test_api.py b/packages/modelaudit-picklescan/tests/test_api.py index 21bbc5682..13382d4e5 100644 --- a/packages/modelaudit-picklescan/tests/test_api.py +++ b/packages/modelaudit-picklescan/tests/test_api.py @@ -7572,6 +7572,25 @@ def test_scan_bytes_surfaces_nested_pickle_inner_findings() -> None: ) +def test_scan_bytes_zero_nested_depth_still_flags_nested_pickle() -> None: + nested_payload = pickle.dumps(MaliciousPayload(), protocol=4) + + report = scan_bytes( + pickle.dumps({"outer": nested_payload}, protocol=4), + source="zero-depth-nested-malicious.pkl", + options=ScanOptions(max_nested_depth=0), + ) + + assert report.status == ScanStatus.INCONCLUSIVE + assert report.verdict == SafetyVerdict.MALICIOUS + assert any( + finding.rule_code == "S213" + and finding.details.get("analysis_incomplete") is True + and finding.details.get("incomplete_reason") == "max_nested_depth" + for finding in report.findings + ) + + def test_scan_bytes_surfaces_deep_nested_pickle_findings_without_parse_incomplete() -> None: deepest_payload = pickle.dumps(MaliciousPayload(), protocol=4) nested_payload = pickle.dumps({"middle": deepest_payload}, protocol=4) diff --git a/packages/modelaudit-picklescan/tests/test_nested_budget_limits.py b/packages/modelaudit-picklescan/tests/test_nested_budget_limits.py new file mode 100644 index 000000000..2f4d1bb55 --- /dev/null +++ b/packages/modelaudit-picklescan/tests/test_nested_budget_limits.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import base64 +import os +import pickle + +import pytest + +from modelaudit_picklescan import SafetyVerdict, ScanOptions, ScanStatus, scan_bytes + + +class MaliciousPayload: + def __reduce__(self) -> tuple[object, tuple[str]]: + return os.system, ("echo pwned",) + + +LONG_PROTOCOL0_LITERAL_PAYLOADS = [ + pytest.param(b"cattacker\nfactory\n(V" + (b"A" * 2000) + b"\ntR.", id="unicode"), + pytest.param( + b"cattacker\nfactory\nX" + (2000).to_bytes(4, "little") + (b"A" * 2000) + b"\x85R.", + id="binunicode", + ), +] + + +def _encode_nested_value(payload: bytes, encoding: str) -> bytes | str: + if encoding == "raw": + return payload + if encoding == "unicode": + return payload.decode("latin1") + if encoding == "base64": + return base64.b64encode(payload).decode("ascii") + return payload.hex() + + +@pytest.mark.parametrize( + ("encoding", "expected_rule"), + [ + ("raw", "S213"), + ("unicode", "S213"), + ("base64", "S601"), + ("hex", "S602"), + ], +) +@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) +def test_minimum_nested_budget_fails_closed( + protocol: int, + encoding: str, + expected_rule: str, +) -> None: + nested_payload = pickle.dumps(MaliciousPayload(), protocol=protocol) + nested_value: bytes | str + if encoding == "raw": + nested_value = nested_payload + elif encoding == "unicode": + nested_value = nested_payload.decode("latin1") + elif encoding == "base64": + nested_value = base64.b64encode(nested_payload).decode("ascii") + else: + nested_value = nested_payload.hex() + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"minimum-budget-protocol-{protocol}-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=2), + ) + + assert report.status == ScanStatus.INCONCLUSIVE + assert report.verdict == SafetyVerdict.MALICIOUS + assert any(finding.rule_code == expected_rule for finding in report.findings) + + +def test_minimum_nested_budget_fails_closed_for_unknown_protocol0_global() -> None: + nested_payload = b"cxxx\nxxx\n)R." + + report = scan_bytes( + pickle.dumps({"outer": nested_payload}, protocol=4), + source="minimum-budget-unknown-protocol0-global.pkl", + options=ScanOptions(max_nested_pickle_bytes=2), + ) + + assert report.status == ScanStatus.INCONCLUSIVE + assert report.verdict == SafetyVerdict.MALICIOUS + assert any(finding.rule_code == "S213" for finding in report.findings) + + +@pytest.mark.parametrize( + ("encoding", "expected_rule"), + [ + ("raw", "S213"), + ("unicode", "S213"), + ("base64", "S601"), + ("hex", "S602"), + ], +) +def test_minimum_nested_budget_fails_closed_for_mark_prefixed_inst( + encoding: str, + expected_rule: str, +) -> None: + nested_value = _encode_nested_value(b"(ios\nsystem\n.", encoding) + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"minimum-budget-inst-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=2), + ) + + assert report.status == ScanStatus.INCONCLUSIVE + assert report.verdict == SafetyVerdict.MALICIOUS + assert any(finding.rule_code == expected_rule for finding in report.findings) + + +@pytest.mark.parametrize( + ("encoding", "expected_rule"), + [ + ("raw", "S213"), + ("unicode", "S213"), + ("base64", "S601"), + ("hex", "S602"), + ], +) +def test_minimum_nested_budget_fails_closed_for_explicit_protocol0_header( + encoding: str, + expected_rule: str, +) -> None: + nested_payload = b"\x80\x00X\x08\x00\x00\x00attackerQ." + nested_value: bytes | str + if encoding == "raw": + nested_value = nested_payload + elif encoding == "unicode": + nested_value = nested_payload.decode("latin1") + elif encoding == "base64": + nested_value = base64.b64encode(nested_payload).decode("ascii") + else: + nested_value = nested_payload.hex() + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"minimum-budget-explicit-protocol0-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=2), + ) + + assert report.status == ScanStatus.INCONCLUSIVE + assert report.verdict == SafetyVerdict.MALICIOUS + assert any(finding.rule_code == expected_rule for finding in report.findings) + + +@pytest.mark.parametrize( + "nested_value", + [ + b"cp", + b"cposix\n", + b"(instance)", + ], +) +def test_minimum_nested_budget_leaves_protocol0_near_matches_clean( + nested_value: bytes, +) -> None: + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source="minimum-budget-protocol0-near-match.pkl", + options=ScanOptions(max_nested_pickle_bytes=2), + ) + + assert report.status == ScanStatus.COMPLETE + assert report.verdict == SafetyVerdict.CLEAN + assert not any(finding.rule_code == "S213" for finding in report.findings) + + +@pytest.mark.parametrize("encoding", ["raw", "unicode", "base64", "hex"]) +def test_minimum_nested_budget_leaves_invalid_protocol6_header_clean(encoding: str) -> None: + invalid_payload = b"\x80\x06not-a-pickle" + nested_value = _encode_nested_value(invalid_payload, encoding) + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"minimum-budget-invalid-protocol6-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=2), + ) + + assert report.status == ScanStatus.COMPLETE + assert report.verdict == SafetyVerdict.CLEAN + assert not any(finding.rule_code in {"S213", "S601", "S602"} for finding in report.findings) + + +@pytest.mark.parametrize( + ("encoding", "expected_rule"), + [ + ("raw", "S213"), + ("unicode", "S213"), + ("base64", "S601"), + ("hex", "S602"), + ], +) +@pytest.mark.parametrize("budget", [2, 8, 16, 64]) +@pytest.mark.parametrize("filler_len", [0, 1, 47, 48, 63, 64]) +def test_nested_budget_detects_valid_pickle_after_invalid_protocol_header( + encoding: str, + expected_rule: str, + budget: int, + filler_len: int, +) -> None: + nested_payload = b"\x80\x06" + (b"!" * filler_len) + b"cos\nsystem\n)R." + nested_value = _encode_nested_value(nested_payload, encoding) + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"invalid-protocol-with-valid-suffix-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=budget), + ) + + expected_status = ScanStatus.INCONCLUSIVE if budget < len(b"cos\nsystem\n)R.") else ScanStatus.COMPLETE + assert report.status == expected_status + assert report.verdict == SafetyVerdict.MALICIOUS + assert any(finding.rule_code == expected_rule for finding in report.findings) + + +@pytest.mark.parametrize("encoding", ["raw", "unicode", "base64", "hex"]) +@pytest.mark.parametrize("budget", [2, 8, 16, ScanOptions().max_nested_pickle_bytes]) +@pytest.mark.parametrize( + "prose", + [ + b"config\nvalue\nplain prose", + b"config\nvalue\nplain prose\n", + b"instance\nvalue\nplain prose", + b"instance\nvalue\nplain prose\n", + b"client\nlist\ndetailed prose", + ( + b"instance prose instance instance audit\n" + b"list status prose nested config nested list\n" + b"client budget value\n" + b"status audit audit audit nested plain client\n" + ), + ( + b"client config instance\n" + b"value list budget client\n" + b"client nested model\n" + b"audit instance plain config budget client report\n" + ), + (b"client plain config report instance list client instance\nstatus\nconfig\ninstance budget report value\n"), + b"client\nstatus\njust plain prose\nget\n", + b"config\nvalue\n(Value " + (b"x" * 2000) + b"\n", + ], +) +def test_nested_budget_leaves_multiline_opcode_like_prose_clean( + prose: bytes, + budget: int, + encoding: str, +) -> None: + nested_value = _encode_nested_value(prose, encoding) + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"multiline-prose-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=budget), + ) + + assert report.status == ScanStatus.COMPLETE + assert report.verdict == SafetyVerdict.CLEAN + assert not any(finding.rule_code in {"S213", "S601", "S602"} for finding in report.findings) + + +@pytest.mark.parametrize("encoding", ["raw", "unicode", "base64", "hex"]) +def test_truncated_opaque_binary_prefix_remains_clean(encoding: str) -> None: + opaque_value = b"\x80\x04" + (b"A" * 8) + nested_value: bytes | str + if encoding == "raw": + nested_value = opaque_value + elif encoding == "unicode": + nested_value = opaque_value.decode("latin1") + elif encoding == "base64": + nested_value = base64.b64encode(opaque_value).decode("ascii") + else: + nested_value = opaque_value.hex() + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"opaque-binary-prefix-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=8), + ) + + assert report.status == ScanStatus.COMPLETE + assert report.verdict == SafetyVerdict.CLEAN + assert not any(finding.rule_code in {"S213", "S601", "S602"} for finding in report.findings) + + +@pytest.mark.parametrize( + ("encoding", "expected_rule"), + [ + ("raw", "S213"), + ("unicode", "S213"), + ("base64", "S601"), + ("hex", "S602"), + ], +) +@pytest.mark.parametrize("budget", [2, 8, 1024]) +def test_minimum_nested_budget_fails_closed_for_long_protocol0_global( + encoding: str, + expected_rule: str, + budget: int, +) -> None: + nested_payload = b"cattacker\nfactory\n(" + (b"N" * 2000) + b"tR." + nested_value = _encode_nested_value(nested_payload, encoding) + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"minimum-budget-long-global-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=budget), + ) + + assert report.status == ScanStatus.INCONCLUSIVE + assert report.verdict == SafetyVerdict.MALICIOUS + assert any(finding.rule_code == expected_rule for finding in report.findings) + + +@pytest.mark.parametrize( + ("encoding", "expected_rule"), + [ + ("raw", "S213"), + ("unicode", "S213"), + ("base64", "S601"), + ("hex", "S602"), + ], +) +@pytest.mark.parametrize("budget", [2, 8, 1024]) +@pytest.mark.parametrize("nested_payload", LONG_PROTOCOL0_LITERAL_PAYLOADS) +def test_nested_budget_fails_closed_for_long_protocol0_literal( + encoding: str, + expected_rule: str, + budget: int, + nested_payload: bytes, +) -> None: + nested_value = _encode_nested_value(nested_payload, encoding) + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"minimum-budget-long-literal-{encoding}.pkl", + options=ScanOptions(max_nested_pickle_bytes=budget), + ) + + assert report.status == ScanStatus.INCONCLUSIVE + assert report.verdict == SafetyVerdict.MALICIOUS + assert any(finding.rule_code == expected_rule for finding in report.findings) + + +@pytest.mark.parametrize("encoding", ["raw", "unicode", "base64", "hex"]) +@pytest.mark.parametrize("nested_payload", LONG_PROTOCOL0_LITERAL_PAYLOADS) +def test_default_nested_budget_scans_long_protocol0_literal( + encoding: str, + nested_payload: bytes, +) -> None: + nested_value = _encode_nested_value(nested_payload, encoding) + + report = scan_bytes( + pickle.dumps({"outer": nested_value}, protocol=4), + source=f"default-budget-long-literal-{encoding}.pkl", + ) + + assert report.status == ScanStatus.COMPLETE + assert report.verdict == SafetyVerdict.SUSPICIOUS + assert any(finding.rule_code == "NON_ALLOWLISTED_GLOBAL" for finding in report.findings) diff --git a/packages/modelaudit-picklescan/tests/test_options.py b/packages/modelaudit-picklescan/tests/test_options.py index c677a21d2..a0611fe31 100644 --- a/packages/modelaudit-picklescan/tests/test_options.py +++ b/packages/modelaudit-picklescan/tests/test_options.py @@ -15,6 +15,7 @@ def test_scan_options_defaults_are_safe_and_finite() -> None: assert options.max_opcodes > 0 assert options.post_budget_scan_bytes >= 0 assert options.max_known_stream_read_bytes > 0 + assert options.max_nested_pickle_bytes >= 2 def test_scan_options_clamps_excessive_timeout() -> None: @@ -41,8 +42,10 @@ def test_scan_options_clamps_excessive_timeout() -> None: ({"max_known_stream_read_bytes": True}, "max_known_stream_read_bytes must be greater than 0"), ({"max_string_literal_scan_chars": -1}, "max_string_literal_scan_chars must be greater than or equal to 0"), ({"max_string_literal_scan_chars": True}, "max_string_literal_scan_chars must be greater than or equal to 0"), - ({"max_nested_pickle_bytes": -1}, "max_nested_pickle_bytes must be greater than or equal to 0"), - ({"max_nested_pickle_bytes": False}, "max_nested_pickle_bytes must be greater than or equal to 0"), + ({"max_nested_pickle_bytes": 0}, "max_nested_pickle_bytes must be at least 2"), + ({"max_nested_pickle_bytes": 1}, "max_nested_pickle_bytes must be at least 2"), + ({"max_nested_pickle_bytes": -1}, "max_nested_pickle_bytes must be at least 2"), + ({"max_nested_pickle_bytes": False}, "max_nested_pickle_bytes must be at least 2"), ({"max_nested_depth": -1}, "max_nested_depth must be greater than or equal to 0"), ({"max_nested_depth": 1.5}, "max_nested_depth must be greater than or equal to 0"), ], @@ -53,3 +56,14 @@ def test_scan_options_reject_invalid_resource_limits( ) -> None: with pytest.raises(ValueError, match=expected_error): ScanOptions(**kwargs) + + +def test_native_scan_rejects_undersized_nested_pickle_budget() -> None: + rust = pytest.importorskip("modelaudit_picklescan._rust") + payload = b"\x80\x04N." + + for limit in (0, 1): + with pytest.raises(ValueError, match="max_nested_pickle_bytes must be at least 2"): + rust.scan_bytes(payload, "native-options.pkl", {"max_nested_pickle_bytes": limit}) + + rust.scan_bytes(payload, "native-options.pkl", {"max_nested_pickle_bytes": 2}) diff --git a/tests/conftest.py b/tests/conftest.py index 73fb56dbe..7280b918e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -95,6 +95,7 @@ def pytest_runtest_setup(item): "test_xgboost_scanner.py", "test_pickle_scanner.py", "test_picklescan_adapter.py", + "test_nested_budget_limits.py", "test_basic.py", # ScanResult private-metadata merge regressions "test_joblib_scanner.py", "test_scanner_registry.py", # Scanner registry routing and metadata tests diff --git a/tests/scanners/test_picklescan_adapter.py b/tests/scanners/test_picklescan_adapter.py index 9e7e7d13f..081574414 100644 --- a/tests/scanners/test_picklescan_adapter.py +++ b/tests/scanners/test_picklescan_adapter.py @@ -46,12 +46,14 @@ def test_scan_options_from_config_parses_string_values() -> None: { "timeout": "2.5", "max_opcodes": "4096", + "max_nested_pickle_bytes": "2", "post_budget_global_scan_limit_bytes": "8192", } ) assert parsed.timeout_s == 2.5 assert parsed.max_opcodes == 4096 + assert parsed.max_nested_pickle_bytes == 2 assert parsed.post_budget_scan_bytes == 8192 @@ -68,12 +70,14 @@ def test_scan_options_from_config_falls_back_for_bad_values() -> None: { "timeout": float("inf"), "max_opcodes": 0, + "max_nested_pickle_bytes": 0, "post_budget_global_scan_limit_bytes": -1, } ) assert fallback.timeout_s == defaults.timeout_s assert fallback.max_opcodes == defaults.max_opcodes + assert fallback.max_nested_pickle_bytes == defaults.max_nested_pickle_bytes assert fallback.post_budget_scan_bytes == defaults.post_budget_scan_bytes nan_fallback = scan_options_from_config({"timeout": "nan"})