diff --git a/collector/Cargo.lock b/collector/Cargo.lock index b92f890..2a799a0 100644 --- a/collector/Cargo.lock +++ b/collector/Cargo.lock @@ -33,6 +33,7 @@ dependencies = [ "chunked_transfer", "clap", "env_logger", + "flate2", "futures", "hex", "http-body-util", @@ -431,6 +432,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -913,6 +924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1215,6 +1227,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.10" diff --git a/collector/Cargo.toml b/collector/Cargo.toml index 4b5423f..c18603c 100644 --- a/collector/Cargo.toml +++ b/collector/Cargo.toml @@ -20,6 +20,7 @@ log = "0.4" env_logger = "0.10" chunked_transfer = "1.5" num_cpus = "1.16" +flate2 = "1.0" # Web server dependencies hyper = { version = "1.0", features = ["full"] } diff --git a/collector/src/framework/analyzers/http_decompressor.rs b/collector/src/framework/analyzers/http_decompressor.rs new file mode 100644 index 0000000..20e021b --- /dev/null +++ b/collector/src/framework/analyzers/http_decompressor.rs @@ -0,0 +1,335 @@ +use super::{Analyzer, AnalyzerError}; +use super::event::HTTPEvent; +use crate::framework::runners::EventStream; +use crate::framework::core::Event; +use async_trait::async_trait; +use futures::stream::StreamExt; +use flate2::read::GzDecoder; +use std::io::Read; + +/// HTTP Decompressor Analyzer that decompresses gzip/deflate encoded HTTP response bodies +pub struct HTTPDecompressor { + /// Flag to keep the raw compressed data alongside decompressed data + keep_compressed: bool, +} + +impl HTTPDecompressor { + /// Create a new HTTPDecompressor with default settings + pub fn new() -> Self { + HTTPDecompressor { + keep_compressed: false, + } + } + + /// Keep the compressed data in the event (adds a compressed_body field) + #[allow(dead_code)] + pub fn keep_compressed(mut self) -> Self { + self.keep_compressed = true; + self + } + + /// Check if the HTTP event has gzip encoding + fn has_gzip_encoding(http_event: &HTTPEvent) -> bool { + http_event.headers.get("content-encoding") + .map(|v| v.to_lowercase().contains("gzip")) + .unwrap_or(false) + } + + /// Check if the HTTP event has deflate encoding + fn has_deflate_encoding(http_event: &HTTPEvent) -> bool { + http_event.headers.get("content-encoding") + .map(|v| v.to_lowercase().contains("deflate")) + .unwrap_or(false) + } + + /// Decode JSON-escaped string to raw bytes (handles the eBPF output format) + fn decode_json_escaped_string(s: &str) -> Vec { + let mut result = Vec::new(); + for c in s.chars() { + let cp = c as u32; + if cp < 256 { + result.push(cp as u8); + } else { + // This came from valid UTF-8 in binary data + let utf8_bytes = c.to_string().into_bytes(); + result.extend_from_slice(&utf8_bytes); + } + } + result + } + + /// Try to decompress gzip data + fn decompress_gzip(data: &[u8]) -> Result { + let mut decoder = GzDecoder::new(data); + let mut decompressed = Vec::new(); + + decoder.read_to_end(&mut decompressed) + .map_err(|e| format!("Gzip decompression failed: {}", e))?; + + String::from_utf8(decompressed) + .map_err(|e| format!("UTF-8 conversion failed: {}", e)) + } + + /// Try to decompress deflate data + fn decompress_deflate(data: &[u8]) -> Result { + use flate2::read::DeflateDecoder; + let mut decoder = DeflateDecoder::new(data); + let mut decompressed = Vec::new(); + + decoder.read_to_end(&mut decompressed) + .map_err(|e| format!("Deflate decompression failed: {}", e))?; + + String::from_utf8(decompressed) + .map_err(|e| format!("UTF-8 conversion failed: {}", e)) + } + + /// Handle chunked transfer encoding - extract the actual data from chunks + /// Works with bytes instead of strings to handle binary gzip data + fn extract_from_chunked(body: &str) -> Option> { + // Parse chunked transfer encoding format: + // \r\n\r\n... + let mut result = Vec::new(); + + // Convert string to bytes for binary-safe processing + let body_bytes = Self::decode_json_escaped_string(body); + let mut pos = 0; + + loop { + // Find the first \r\n which separates chunk size from data + let newline_pos = body_bytes[pos..].windows(2) + .position(|w| w == b"\r\n") + .map(|p| pos + p); + + if let Some(newline_pos) = newline_pos { + // Extract chunk size string (should be ASCII hex digits) + let chunk_size_bytes = &body_bytes[pos..newline_pos]; + let chunk_size_str = match std::str::from_utf8(chunk_size_bytes) { + Ok(s) => s, + Err(_) => return None, // Invalid UTF-8 in chunk size + }; + + // Parse chunk size as hex + let chunk_size = match usize::from_str_radix(chunk_size_str.trim(), 16) { + Ok(size) => size, + Err(_) => return None, // Invalid chunk size + }; + + // If chunk size is 0, we've reached the end + if chunk_size == 0 { + break; + } + + // Extract chunk data (binary safe) + let data_start = newline_pos + 2; // Skip \r\n + if data_start + chunk_size > body_bytes.len() { + return None; // Incomplete chunk + } + + let chunk_data = &body_bytes[data_start..data_start + chunk_size]; + result.extend_from_slice(chunk_data); + + // Move to next chunk (skip chunk data and trailing \r\n) + pos = data_start + chunk_size + 2; + + if pos >= body_bytes.len() { + break; + } + } else { + break; + } + } + + if result.is_empty() { + None + } else { + Some(result) + } + } + + /// Process an HTTP event and decompress if needed + fn process_http_event( + mut event: Event, + keep_compressed: bool, + ) -> Event { + // Try to deserialize as HTTPEvent + let http_event: HTTPEvent = match serde_json::from_value(event.data.clone()) { + Ok(h) => h, + Err(_) => return event, // Not an HTTP event, pass through + }; + + // Only process responses with body + if http_event.message_type != "response" || http_event.body.is_none() { + return event; + } + + let body = http_event.body.as_ref().unwrap(); + let is_gzip = Self::has_gzip_encoding(&http_event); + let is_deflate = Self::has_deflate_encoding(&http_event); + + if !is_gzip && !is_deflate { + return event; // No compression + } + + // Extract bytes from the body + let body_bytes = if http_event.is_chunked { + // Handle chunked transfer encoding + match Self::extract_from_chunked(body) { + Some(bytes) => bytes, + None => { + // Failed to parse chunks, try direct decoding + Self::decode_json_escaped_string(body) + } + } + } else { + Self::decode_json_escaped_string(body) + }; + + // Try to decompress + let decompressed = if is_gzip { + Self::decompress_gzip(&body_bytes) + } else { + Self::decompress_deflate(&body_bytes) + }; + + match decompressed { + Ok(decompressed_text) => { + // Update the event data with decompressed body + if let Some(data) = event.data.as_object_mut() { + // Update body with decompressed content + data.insert("body".to_string(), serde_json::json!(decompressed_text)); + + // Optionally keep the compressed data + if keep_compressed { + data.insert("compressed_body".to_string(), serde_json::json!(body)); + } + + // Add decompression metadata + data.insert("decompressed".to_string(), serde_json::json!(true)); + data.insert("original_encoding".to_string(), + serde_json::json!(if is_gzip { "gzip" } else { "deflate" })); + data.insert("decompressed_size".to_string(), + serde_json::json!(decompressed_text.len())); + data.insert("compressed_size".to_string(), + serde_json::json!(body_bytes.len())); + } + event + } + Err(_err) => { + // Decompression failed, pass through original event + // Optionally add error metadata + if let Some(data) = event.data.as_object_mut() { + data.insert("decompression_failed".to_string(), serde_json::json!(true)); + } + event + } + } + } +} + +#[async_trait] +impl Analyzer for HTTPDecompressor { + async fn process(&mut self, stream: EventStream) -> Result { + let keep_compressed = self.keep_compressed; + + let processed_stream = stream.map(move |event| { + // Only process HTTP parser events + if event.source == "http_parser" { + Self::process_http_event(event, keep_compressed) + } else { + event // Pass through other events + } + }); + + Ok(Box::pin(processed_stream)) + } + + fn name(&self) -> &str { + "HTTPDecompressor" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_extract_from_chunked() { + // Test chunked transfer encoding with simple text + let chunked_data = "e\r\ntest data here\r\n0\r\n\r\n"; + let result = HTTPDecompressor::extract_from_chunked(chunked_data); + assert!(result.is_some()); + let bytes = result.unwrap(); + assert_eq!(bytes, b"test data here"); + } + + #[test] + fn test_has_gzip_encoding() { + let mut headers = HashMap::new(); + headers.insert("content-encoding".to_string(), "gzip".to_string()); + + let event = HTTPEvent::new( + 1, + "response".to_string(), + "HTTP/1.1 200 OK".to_string(), + None, + None, + Some("HTTP/1.1".to_string()), + Some(200), + Some("OK".to_string()), + headers, + Some("test".to_string()), + 100, + true, + false, + None, + "ssl".to_string(), + ); + + assert!(HTTPDecompressor::has_gzip_encoding(&event)); + } + + #[test] + fn test_has_deflate_encoding() { + let mut headers = HashMap::new(); + headers.insert("content-encoding".to_string(), "deflate".to_string()); + + let event = HTTPEvent::new( + 1, + "response".to_string(), + "HTTP/1.1 200 OK".to_string(), + None, + None, + Some("HTTP/1.1".to_string()), + Some(200), + Some("OK".to_string()), + headers, + Some("test".to_string()), + 100, + true, + false, + None, + "ssl".to_string(), + ); + + assert!(HTTPDecompressor::has_deflate_encoding(&event)); + } + + #[test] + fn test_decompress_gzip_real_data() { + use flate2::write::GzEncoder; + use flate2::Compression; + use std::io::Write as IoWrite; + + // Create actual gzip compressed data + let original_text = "Hello, this is test data for gzip decompression!"; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(original_text.as_bytes()).unwrap(); + let compressed = encoder.finish().unwrap(); + + // Decompress using our function + let result = HTTPDecompressor::decompress_gzip(&compressed); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), original_text); + } +} diff --git a/collector/src/framework/analyzers/http_decompressor_test.rs b/collector/src/framework/analyzers/http_decompressor_test.rs new file mode 100644 index 0000000..a70b1b2 --- /dev/null +++ b/collector/src/framework/analyzers/http_decompressor_test.rs @@ -0,0 +1,166 @@ +use super::*; +use crate::framework::core::Event; +use crate::framework::analyzers::event::HTTPEvent; +use std::collections::HashMap; +use flate2::write::GzEncoder; +use flate2::Compression; +use std::io::Write; + +#[cfg(test)] +mod http_decompressor_tests { + use super::*; + + fn create_gzip_compressed_data(text: &str) -> String { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(text.as_bytes()).unwrap(); + let compressed = encoder.finish().unwrap(); + + // Convert to the format that comes from eBPF (simulating JSON escaped string) + compressed.iter().map(|&b| { + if b < 128 { + (b as char).to_string() + } else { + format!("\\u{:04x}", b) + } + }).collect() + } + + #[test] + fn test_decompress_gzip_response() { + // Create test data + let original_text = r#"{"message":"Hello World","data":"This is test data"}"#; + let compressed_body = create_gzip_compressed_data(original_text); + + // Create HTTP event with gzip encoding + let mut headers = HashMap::new(); + headers.insert("content-encoding".to_string(), "gzip".to_string()); + headers.insert("content-type".to_string(), "application/json".to_string()); + + let http_event = HTTPEvent::new( + 12345, + "response".to_string(), + "HTTP/1.1 200 OK".to_string(), + None, + None, + Some("HTTP/1.1".to_string()), + Some(200), + Some("OK".to_string()), + headers, + Some(compressed_body.clone()), + 1000, + true, + false, + None, + "ssl".to_string(), + ); + + // Create Event from HTTPEvent + let event_data = serde_json::to_value(&http_event).unwrap(); + let event = Event::new_with_timestamp( + 123456789, + "http_parser".to_string(), + 12345, + "curl".to_string(), + event_data, + ); + + // Process the event + let result = HTTPDecompressor::process_http_event(event.clone(), false); + + // Verify decompression + assert!(result.data.get("decompressed").is_some()); + assert_eq!(result.data.get("decompressed").unwrap(), &serde_json::json!(true)); + + // Check the decompressed body + let body = result.data.get("body").and_then(|v| v.as_str()); + assert!(body.is_some(), "Decompressed body should exist"); + + println!("Original text: {}", original_text); + println!("Decompressed body: {}", body.unwrap()); + } + + #[test] + fn test_pass_through_non_gzip_response() { + // Create HTTP event without gzip encoding + let mut headers = HashMap::new(); + headers.insert("content-type".to_string(), "application/json".to_string()); + + let body_text = r#"{"message":"Hello World"}"#; + let http_event = HTTPEvent::new( + 12345, + "response".to_string(), + "HTTP/1.1 200 OK".to_string(), + None, + None, + Some("HTTP/1.1".to_string()), + Some(200), + Some("OK".to_string()), + headers, + Some(body_text.to_string()), + 100, + true, + false, + None, + "ssl".to_string(), + ); + + let event_data = serde_json::to_value(&http_event).unwrap(); + let event = Event::new_with_timestamp( + 123456789, + "http_parser".to_string(), + 12345, + "curl".to_string(), + event_data, + ); + + // Process the event + let result = HTTPDecompressor::process_http_event(event.clone(), false); + + // Should not have decompression flag + assert!(result.data.get("decompressed").is_none()); + + // Body should be unchanged + let body = result.data.get("body").and_then(|v| v.as_str()); + assert_eq!(body, Some(body_text)); + } + + #[test] + fn test_pass_through_request() { + // Create HTTP request (should not be decompressed) + let mut headers = HashMap::new(); + headers.insert("content-encoding".to_string(), "gzip".to_string()); + + let http_event = HTTPEvent::new( + 12345, + "request".to_string(), // Request, not response + "POST /api HTTP/1.1".to_string(), + Some("POST".to_string()), + Some("/api".to_string()), + Some("HTTP/1.1".to_string()), + None, + None, + headers, + Some("test".to_string()), + 100, + true, + false, + None, + "ssl".to_string(), + ); + + let event_data = serde_json::to_value(&http_event).unwrap(); + let event = Event::new_with_timestamp( + 123456789, + "http_parser".to_string(), + 12345, + "curl".to_string(), + event_data, + ); + + // Process the event + let result = HTTPDecompressor::process_http_event(event.clone(), false); + + // Should not be decompressed (requests are not decompressed) + assert!(result.data.get("decompressed").is_none()); + } +} diff --git a/collector/src/framework/analyzers/mod.rs b/collector/src/framework/analyzers/mod.rs index 84411ef..e955c75 100644 --- a/collector/src/framework/analyzers/mod.rs +++ b/collector/src/framework/analyzers/mod.rs @@ -18,7 +18,9 @@ pub trait Analyzer: Send + Sync { pub mod output; pub mod file_logger; pub mod sse_processor; +pub mod ssl_merger; pub mod http_parser; +pub mod http_decompressor; pub mod http_filter; pub mod auth_header_remover; pub mod ssl_filter; @@ -32,7 +34,9 @@ mod sse_processor_tests; pub use output::OutputAnalyzer; pub use file_logger::FileLogger; pub use sse_processor::SSEProcessor; +pub use ssl_merger::SSLMerger; pub use http_parser::HTTPParser; +pub use http_decompressor::HTTPDecompressor; pub use http_filter::{HTTPFilter, print_global_http_filter_metrics}; pub use auth_header_remover::AuthHeaderRemover; pub use ssl_filter::{SSLFilter, print_global_ssl_filter_metrics}; diff --git a/collector/src/framework/analyzers/ssl_merger.rs b/collector/src/framework/analyzers/ssl_merger.rs new file mode 100644 index 0000000..fbf19fe --- /dev/null +++ b/collector/src/framework/analyzers/ssl_merger.rs @@ -0,0 +1,245 @@ +use super::{Analyzer, AnalyzerError}; +use crate::framework::runners::EventStream; +use crate::framework::core::Event; +use async_trait::async_trait; +use futures::stream::StreamExt; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// SSL Merger Analyzer that accumulates SSL READ/RECV events from the same thread +/// until a complete HTTP message is received, then emits a single merged event. +/// +/// This is necessary because HTTP responses (especially chunked ones) often span +/// multiple SSL_read() calls. The HTTP parser needs the complete response to +/// properly parse headers and extract the full body. +pub struct SSLMerger { + /// Buffer to accumulate SSL data by thread ID + buffers: Arc>>, + /// Timeout in milliseconds to flush incomplete buffers + timeout_ms: u64, +} + +/// Buffer for accumulating SSL data from a single thread +struct SSLBuffer { + /// Accumulated data from multiple READ events + accumulated_data: String, + /// Timestamp of the first event in this buffer + first_timestamp: u64, + /// Timestamp of the last event added to this buffer + last_timestamp: u64, + /// The original event (used for metadata) + original_event: Option, + /// Number of events merged into this buffer + event_count: usize, +} + +impl SSLMerger { + /// Create a new SSLMerger with default timeout (5 seconds) + pub fn new() -> Self { + Self::with_timeout(5000) + } + + /// Create a new SSLMerger with custom timeout + pub fn with_timeout(timeout_ms: u64) -> Self { + SSLMerger { + buffers: Arc::new(Mutex::new(HashMap::new())), + timeout_ms, + } + } + + /// Check if accumulated data contains a complete HTTP message + fn is_complete_http_message(data: &str) -> bool { + // Check if it's an HTTP message + let is_http = data.starts_with("HTTP/") || + data.contains(" HTTP/1.") || + data.contains("GET ") || data.contains("POST ") || + data.contains("PUT ") || data.contains("DELETE "); + + if !is_http { + return false; + } + + // Find the header/body separator + let header_end = data.find("\r\n\r\n"); + if header_end.is_none() { + return false; // Headers not complete yet + } + + let header_end = header_end.unwrap(); + let headers = &data[..header_end]; + let body = &data[header_end + 4..]; + + // Check if it's chunked encoding + let is_chunked = headers.to_lowercase().contains("transfer-encoding: chunked"); + + if is_chunked { + // For chunked encoding, check if we have the terminating chunk (0\r\n\r\n) + return body.ends_with("0\r\n\r\n") || body.contains("\r\n0\r\n\r\n"); + } else { + // For non-chunked, check Content-Length + // Parse headers line-by-line to avoid false matches (e.g., X-Content-Length) + for line in headers.split("\r\n") { + let line_lower = line.to_lowercase(); + if line_lower.starts_with("content-length:") { + let value_start = "content-length:".len(); + let cl_value = line[value_start..].trim(); + if let Ok(content_length) = cl_value.parse::() { + return body.len() >= content_length; + } + } + } + + // If no Content-Length and not chunked, consider it complete + // (some responses like 204 No Content have no body) + true + } + } + + /// Create a merged event from accumulated buffer + fn create_merged_event(buffer: &SSLBuffer) -> Option { + let original = buffer.original_event.as_ref()?; + + let mut merged_event = original.clone(); + + // Update the data field with accumulated content + if let Some(data) = merged_event.data.as_object_mut() { + data.insert("data".to_string(), serde_json::json!(buffer.accumulated_data)); + // Preserve the original "len" (which represents the original byte length) + // and record the merged payload length separately. + data.insert( + "merged_len_bytes".to_string(), + serde_json::json!(buffer.accumulated_data.len()), + ); + data.insert("merged_events".to_string(), serde_json::json!(buffer.event_count)); + data.insert( + "first_timestamp_ns".to_string(), + serde_json::json!(buffer.first_timestamp), + ); + } + + // Use the timestamp from the last event + merged_event.timestamp = buffer.last_timestamp; + + Some(merged_event) + } +} + +#[async_trait] +impl Analyzer for SSLMerger { + async fn process(&mut self, stream: EventStream) -> Result { + let buffers = Arc::clone(&self.buffers); + let timeout_ms = self.timeout_ms; + + let processed_stream = stream.filter_map(move |event| { + let buffers = Arc::clone(&buffers); + + async move { + // Only process SSL READ/RECV events + if event.source != "ssl" { + return Some(event); + } + + let function = event.data.get("function") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if function != "READ/RECV" { + return Some(event); // Pass through non-READ events + } + + let tid = event.data.get("tid") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let data = event.data.get("data") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let timestamp_ns = event.data.get("timestamp_ns") + .and_then(|v| v.as_u64()) + .unwrap_or(event.timestamp); + + let mut buffers = buffers.lock().unwrap(); + + // Get or create buffer for this thread + let buffer = buffers.entry(tid).or_insert_with(|| SSLBuffer { + accumulated_data: String::new(), + first_timestamp: timestamp_ns, + last_timestamp: timestamp_ns, + original_event: Some(event.clone()), + event_count: 0, + }); + + // Append data to buffer + buffer.accumulated_data.push_str(data); + buffer.last_timestamp = timestamp_ns; + buffer.event_count += 1; + + // Check if we have a complete HTTP message + if Self::is_complete_http_message(&buffer.accumulated_data) { + // Create merged event and clear buffer + let merged_event = Self::create_merged_event(buffer); + buffers.remove(&tid); + return merged_event; + } + + // Check for timeout + let time_diff = if timestamp_ns > buffer.first_timestamp { + (timestamp_ns - buffer.first_timestamp) / 1_000_000 // Convert ns to ms + } else { + 0 + }; + + if time_diff > timeout_ms { + // Timeout reached, flush incomplete buffer + let merged_event = Self::create_merged_event(buffer); + buffers.remove(&tid); + return merged_event; + } + + // Not complete yet, don't emit anything + None + } + }); + + Ok(Box::pin(processed_stream)) + } + + fn name(&self) -> &str { + "SSLMerger" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_complete_http_message_chunked() { + // Complete chunked HTTP response + let complete = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\nHello World\r\n0\r\n\r\n"; + assert!(SSLMerger::is_complete_http_message(complete)); + + // Incomplete chunked response (missing end marker) + let incomplete = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\nHello World\r\n"; + assert!(!SSLMerger::is_complete_http_message(incomplete)); + } + + #[test] + fn test_is_complete_http_message_content_length() { + // Complete response with Content-Length + let complete = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello"; + assert!(SSLMerger::is_complete_http_message(complete)); + + // Incomplete response + let incomplete = "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nHello"; + assert!(!SSLMerger::is_complete_http_message(incomplete)); + } + + #[test] + fn test_is_complete_http_message_no_body() { + // Response with no body (e.g., 204 No Content) + let no_body = "HTTP/1.1 204 No Content\r\n\r\n"; + assert!(SSLMerger::is_complete_http_message(no_body)); + } +} diff --git a/collector/src/main.rs b/collector/src/main.rs index 6f7cebc..18493e2 100644 --- a/collector/src/main.rs +++ b/collector/src/main.rs @@ -10,7 +10,7 @@ mod server; use framework::{ binary_extractor::BinaryExtractor, runners::{SslRunner, ProcessRunner, AgentRunner, SystemRunner, RunnerError, Runner}, - analyzers::{OutputAnalyzer, FileLogger, SSEProcessor, HTTPParser, HTTPFilter, AuthHeaderRemover, SSLFilter, TimestampNormalizer, print_global_http_filter_metrics, print_global_ssl_filter_metrics} + analyzers::{OutputAnalyzer, FileLogger, SSEProcessor, SSLMerger, HTTPParser, HTTPDecompressor, HTTPFilter, AuthHeaderRemover, SSLFilter, TimestampNormalizer, print_global_http_filter_metrics, print_global_ssl_filter_metrics} }; use server::WebServer; @@ -333,7 +333,10 @@ async fn run_raw_ssl(binary_extractor: &BinaryExtractor, enable_chunk_merger: bo HTTPParser::new().disable_raw_data() }; ssl_runner = ssl_runner.add_analyzer(Box::new(http_parser)); - + + // Add HTTP decompressor to decompress gzip/deflate responses + ssl_runner = ssl_runner.add_analyzer(Box::new(HTTPDecompressor::new())); + // Add HTTP filter if patterns are provided if !http_filter_patterns.is_empty() { ssl_runner = ssl_runner.add_analyzer(Box::new(HTTPFilter::with_patterns(http_filter_patterns.clone()))); @@ -504,20 +507,26 @@ async fn run_trace( } if ssl_http { + // Add SSL merger FIRST to combine consecutive READ events into complete HTTP messages + ssl_runner = ssl_runner.add_analyzer(Box::new(SSLMerger::new())); + ssl_runner = ssl_runner.add_analyzer(Box::new(SSEProcessor::new_with_timeout(30000))); - + let http_parser = if ssl_raw_data { HTTPParser::new() } else { HTTPParser::new().disable_raw_data() }; ssl_runner = ssl_runner.add_analyzer(Box::new(http_parser)); - + + // Add HTTP decompressor after HTTP parser to decompress gzip/deflate responses + ssl_runner = ssl_runner.add_analyzer(Box::new(HTTPDecompressor::new())); + // Add HTTP filter to SSL runner if patterns are provided if !http_filter.is_empty() { ssl_runner = ssl_runner.add_analyzer(Box::new(HTTPFilter::with_patterns(http_filter.to_vec()))); } - + // Add authorization header remover by default (unless disabled) if !disable_auth_removal { ssl_runner = ssl_runner.add_analyzer(Box::new(AuthHeaderRemover::new())); diff --git a/collector/test_chunked_gzip.rs b/collector/test_chunked_gzip.rs new file mode 100644 index 0000000..d01faf4 --- /dev/null +++ b/collector/test_chunked_gzip.rs @@ -0,0 +1,140 @@ +/// Minimal test case to reproduce the chunked gzip decompression bug +/// +/// This test uses actual data captured from OpenAI API response + +use flate2::read::GzDecoder; +use std::io::Read; + +fn decode_json_escaped_string(s: &str) -> Vec { + let mut result = Vec::new(); + for c in s.chars() { + let cp = c as u32; + if cp < 256 { + result.push(cp as u8); + } else { + // This came from valid UTF-8 in binary data + let utf8_bytes = c.to_string().into_bytes(); + result.extend_from_slice(&utf8_bytes); + } + } + result +} + +fn extract_from_chunked(body: &str) -> Option> { + // Parse chunked transfer encoding format: + // \r\n\r\n... + let mut result = Vec::new(); + let mut remaining = body; + + loop { + // Find the first \r\n which separates chunk size from data + if let Some(newline_pos) = remaining.find("\r\n") { + let chunk_size_str = &remaining[..newline_pos]; + + // Parse chunk size as hex + let chunk_size = match usize::from_str_radix(chunk_size_str.trim(), 16) { + Ok(size) => size, + Err(_) => return None, // Invalid chunk size + }; + + // If chunk size is 0, we've reached the end + if chunk_size == 0 { + break; + } + + // Extract chunk data + let data_start = newline_pos + 2; // Skip \r\n + if data_start + chunk_size > remaining.len() { + return None; // Incomplete chunk + } + + let chunk_data = &remaining[data_start..data_start + chunk_size]; + result.extend_from_slice(&decode_json_escaped_string(chunk_data)); + + // Move to next chunk (skip chunk data and trailing \r\n) + remaining = &remaining[data_start + chunk_size + 2..]; + } else { + break; + } + } + + if result.is_empty() { + None + } else { + Some(result) + } +} + +fn decompress_gzip(data: &[u8]) -> Result { + let mut decoder = GzDecoder::new(data); + let mut decompressed = Vec::new(); + + decoder.read_to_end(&mut decompressed) + .map_err(|e| format!("Gzip decompression failed: {}", e))?; + + String::from_utf8(decompressed) + .map_err(|e| format!("UTF-8 conversion failed: {}", e)) +} + +fn main() { + // This is the actual chunked gzip data from OpenAI API response + // Captured from: {"function":"READ/RECV",...,"data":"..."} + + // First chunk with gzip header (size: f = 15 bytes) + let chunk1 = "f\r\n\u{1f}\u{8b}\u{08}\u{00}\u{00}\u{00}\u{00}\u{00}\u{00}\u{03}\u{00}\u{00}\u{00}\u{ff}\u{ff}\r\n"; + + // Second chunk with compressed data (size: 199 = 409 bytes) + let chunk2 = "199\r\n\u{8c}RAn\u{db}0\u{10}\u{bc}\u{eb}\u{15},\u{cf}Va˲\u{9c}\u{f8}\u{d2}C/F\u{81}\u{a0}=\u{15}\u{08}\u{8a}@`ȕ\u{bc}\u{09}\u{c5}e\u{c9}UZ#\u{f0}\u{df}\u{0b}J\u{8e}\u{a5}\u{a4})Ћ\u{0e};;\u{a3}\u{99}\u{e1}>gBH4r'\u{a4}>(֝\u{b7}\u{f9}\u{e7}\u{db}\u{12}\u{f9};]\u{e3}\u{fe}\u{a1}\u{fa}z\u{f3}x[\u{98}j\u{1f}\u{e2}7\u{f3}\u{f4}\u{f3}\u{a6}\u{fb}\"\u{17}\u{89}A\u{f7}\u{0f}\u{a0}\u{f9}\u{85}\u{f5}QS\u{e7}-0\u{92}\u{1b}a\u{1d}@1$\u{d5}ն*\u{8a}\u{f5}v]\u{ae}\u{07}\u{a0}#\u{03}6\u{d1}Z\u{cf}yIy\u{87}\u{0e}\u{f3}bY\u{94}\u{f9}r\u{9b}\u{af}\u{ae}\u{ce}\u{ec}\u{03}\u{a1}\u{86}(w\u{e2}G&\u{84}\u{10}\u{cf}\u{c3}7\u{f9}t\u{06}~˝X.^&\u{1d}ĨZ\u{90}\u{bb}˒\u{10}2\u{90}M\u{13}\u{a9}b\u{c4}\u{c8}ʱ\\L\u{a0}&\u{c7}\u{e0}\u{06}\u{eb}{\u{b0}\u{96}>\u{88}=\u{fd}\u{12}*\u{80}8R/\u{0c}\u{a1}k\u{05}\u{93}Q\u{c7}OsV\u{80}\u{a6}\u{8f}*9w\u{bd}\u{b5}3@9G\u{ac}R\u{f2}\u{c1}\u{ef}\u{dd}\u{19}9]\u{1c}Zj}\u{a0}\u{fb}\u{f8}\u{86}*\u{1b}t\u{18}\u{0f}u\u{00}\u{15}\u{c9}%7\u{91}\u{c9}\u{cb}\u{01}=eB\u{dc}\u{0d}M\u{f4}\u{af}\u{c2}I\u{1f}\u{a8}\u{f3}\\3=\u{c2}\u{f0}\u{bb}U9\u{ca}ɩ\u{ff}\u{09}\u{bc}:cL\u{ac}\u{ec}4.\u{8a}\u{c5};b\u{b5}\u{01}Vh\u{e3}\u{ac}H\u{a9}\u{95}>\u{80}\u{99}\u{98}S\u{eb}\u{aa}7H3 \u{9b}E\u{fe}\u{db}\u{cb}{\u{da}clt\u{ed}\u{ff}\u{c8}O\u{80}\u{d6}\u{e0}\u{19}L\u{ed}\u{03}\u{18}ԯ\u{f3}Nk\u{01}\u{d2}q\u{fe}k\u{ed}R\u{f1}`XF\u{08}O\u{a8}\u{a1}f\u{84}\u{90}\u{9e}\u{c1}@\u{a3}z;\u{9e}\u{8c}\u{8c}\u{c7}\u{c8}\u{d0}\u{d5}\u{0d}\u{ba}\u{16}\u{82}\u{0f}8\u{de}M\u{e3}\u{eb}M\u{b5}TM\u{05}\u{9b}͵\u{cc}N\u{d9}\u{1f}\u{00}\u{00}\u{00}\u{ff}\u{ff}\r\n"; + + // Third chunk (size: a = 10 bytes) + let chunk3 = "a\r\n\u{03}\u{00}\u{16}\u{7f}\u{81}\u{b2}E\u{03}\u{00}\u{00}\r\n"; + + // Final empty chunk + let chunk4 = "0\r\n\r\n"; + + println!("Testing chunked gzip decompression with actual OpenAI response data\n"); + + // Combine all chunks + let full_chunked = format!("{}{}{}{}", chunk1, chunk2, chunk3, chunk4); + + println!("Full chunked body length: {} bytes", full_chunked.len()); + println!("Full chunked body (first 100 chars): {}\n", + if full_chunked.len() > 100 { &full_chunked[..100] } else { &full_chunked }); + + // Extract from chunked encoding + match extract_from_chunked(&full_chunked) { + Some(extracted) => { + println!("✓ Successfully extracted {} bytes from chunked encoding", extracted.len()); + println!("Extracted bytes (first 20): {:?}\n", &extracted[..20.min(extracted.len())]); + + // Try to decompress + match decompress_gzip(&extracted) { + Ok(decompressed) => { + println!("✓ Successfully decompressed!"); + println!("Decompressed text: {}\n", decompressed); + } + Err(e) => { + println!("✗ Decompression failed: {}", e); + println!("This is the BUG - chunked extraction doesn't properly handle gzip data!\n"); + } + } + } + None => { + println!("✗ Failed to extract from chunked encoding"); + println!("This means the chunked parsing logic is broken!\n"); + } + } + + // Also test with just the raw bytes (simulating what happens if we skip chunked parsing) + println!("\n=== Testing direct decompression without chunked parsing ==="); + let raw_bytes = decode_json_escaped_string(&full_chunked); + println!("Raw bytes length: {}", raw_bytes.len()); + match decompress_gzip(&raw_bytes) { + Ok(decompressed) => { + println!("✓ Direct decompression succeeded: {}", decompressed); + } + Err(e) => { + println!("✗ Direct decompression failed (expected): {}", e); + } + } +} diff --git a/collector/tests/test_chunked_gzip_bug.rs b/collector/tests/test_chunked_gzip_bug.rs new file mode 100644 index 0000000..f563918 --- /dev/null +++ b/collector/tests/test_chunked_gzip_bug.rs @@ -0,0 +1,137 @@ +/// Minimal test case to reproduce the chunked gzip decompression bug +/// +/// This test uses actual data captured from OpenAI API response + +use flate2::read::GzDecoder; +use std::io::Read; + +fn decode_json_escaped_string(s: &str) -> Vec { + let mut result = Vec::new(); + for c in s.chars() { + let cp = c as u32; + if cp < 256 { + result.push(cp as u8); + } else { + // This came from valid UTF-8 in binary data + let utf8_bytes = c.to_string().into_bytes(); + result.extend_from_slice(&utf8_bytes); + } + } + result +} + +/// Fixed version: Works with bytes instead of strings to handle binary gzip data +fn extract_from_chunked(body: &str) -> Option> { + // Parse chunked transfer encoding format: + // \r\n\r\n... + let mut result = Vec::new(); + + // Convert string to bytes for binary-safe processing + let body_bytes = decode_json_escaped_string(body); + let mut pos = 0; + + loop { + // Find the first \r\n which separates chunk size from data + let newline_pos = body_bytes[pos..].windows(2) + .position(|w| w == b"\r\n") + .map(|p| pos + p); + + if let Some(newline_pos) = newline_pos { + // Extract chunk size string (should be ASCII hex digits) + let chunk_size_bytes = &body_bytes[pos..newline_pos]; + let chunk_size_str = match std::str::from_utf8(chunk_size_bytes) { + Ok(s) => s, + Err(_) => return None, // Invalid UTF-8 in chunk size + }; + + // Parse chunk size as hex + let chunk_size = match usize::from_str_radix(chunk_size_str.trim(), 16) { + Ok(size) => size, + Err(_) => return None, // Invalid chunk size + }; + + // If chunk size is 0, we've reached the end + if chunk_size == 0 { + break; + } + + // Extract chunk data (binary safe) + let data_start = newline_pos + 2; // Skip \r\n + if data_start + chunk_size > body_bytes.len() { + return None; // Incomplete chunk + } + + let chunk_data = &body_bytes[data_start..data_start + chunk_size]; + result.extend_from_slice(chunk_data); + + // Move to next chunk (skip chunk data and trailing \r\n) + pos = data_start + chunk_size + 2; + + if pos >= body_bytes.len() { + break; + } + } else { + break; + } + } + + if result.is_empty() { + None + } else { + Some(result) + } +} + +fn decompress_gzip(data: &[u8]) -> Result { + let mut decoder = GzDecoder::new(data); + let mut decompressed = Vec::new(); + + decoder.read_to_end(&mut decompressed) + .map_err(|e| format!("Gzip decompression failed: {}", e))?; + + String::from_utf8(decompressed) + .map_err(|e| format!("UTF-8 conversion failed: {}", e)) +} + +#[test] +fn test_chunked_gzip_decompression_with_openai_data() { + // This is the actual chunked gzip data from OpenAI API response + // Captured from: {"function":"READ/RECV",...,"data":"..."} + + // First chunk with gzip header (size: f = 15 bytes) + let chunk1 = "f\r\n\u{1f}\u{8b}\u{08}\u{00}\u{00}\u{00}\u{00}\u{00}\u{00}\u{03}\u{00}\u{00}\u{00}\u{ff}\u{ff}\r\n"; + + // Second chunk with compressed data (size: 199 = 409 bytes) + let chunk2 = "199\r\n\u{8c}RAn\u{db}0\u{10}\u{bc}\u{eb}\u{15},\u{cf}Va˲\u{9c}\u{f8}\u{d2}C/F\u{81}\u{a0}=\u{15}\u{08}\u{8a}@`ȕ\u{bc}\u{09}\u{c5}e\u{c9}UZ#\u{f0}\u{df}\u{0b}J\u{8e}\u{a5}\u{a4})Ћ\u{0e};;\u{a3}\u{99}\u{e1}>gBH4r'\u{a4}>(֝\u{b7}\u{f9}\u{e7}\u{db}\u{12}\u{f9};]\u{e3}\u{fe}\u{a1}\u{fa}z\u{f3}x[\u{98}j\u{1f}\u{e2}7\u{f3}\u{f4}\u{f3}\u{a6}\u{fb}\"\u{17}\u{89}A\u{f7}\u{0f}\u{a0}\u{f9}\u{85}\u{f5}QS\u{e7}-0\u{92}\u{1b}a\u{1d}@1$\u{d5}ն*\u{8a}\u{f5}v]\u{ae}\u{07}\u{a0}#\u{03}6\u{d1}Z\u{cf}yIy\u{87}\u{0e}\u{f3}bY\u{94}\u{f9}r\u{9b}\u{af}\u{ae}\u{ce}\u{ec}\u{03}\u{a1}\u{86}(w\u{e2}G&\u{84}\u{10}\u{cf}\u{c3}7\u{f9}t\u{06}~˝X.^&\u{1d}ĨZ\u{90}\u{bb}˒\u{10}2\u{90}M\u{13}\u{a9}b\u{c4}\u{c8}ʱ\\L\u{a0}&\u{c7}\u{e0}\u{06}\u{eb}{\u{b0}\u{96}>\u{88}=\u{fd}\u{12}*\u{80}8R/\u{0c}\u{a1}k\u{05}\u{93}Q\u{c7}OsV\u{80}\u{a6}\u{8f}*9w\u{bd}\u{b5}3@9G\u{ac}R\u{f2}\u{c1}\u{ef}\u{dd}\u{19}9]\u{1c}Zj}\u{a0}\u{fb}\u{f8}\u{86}*\u{1b}t\u{18}\u{0f}u\u{00}\u{15}\u{c9}%7\u{91}\u{c9}\u{cb}\u{01}=eB\u{dc}\u{0d}M\u{f4}\u{af}\u{c2}I\u{1f}\u{a8}\u{f3}\\3=\u{c2}\u{f0}\u{bb}U9\u{ca}ɩ\u{ff}\u{09}\u{bc}:cL\u{ac}\u{ec}4.\u{8a}\u{c5};b\u{b5}\u{01}Vh\u{e3}\u{ac}H\u{a9}\u{95}>\u{80}\u{99}\u{98}S\u{eb}\u{aa}7H3 \u{9b}E\u{fe}\u{db}\u{cb}{\u{da}clt\u{ed}\u{ff}\u{c8}O\u{80}\u{d6}\u{e0}\u{19}L\u{ed}\u{03}\u{18}ԯ\u{f3}Nk\u{01}\u{d2}q\u{fe}k\u{ed}R\u{f1}`XF\u{08}O\u{a8}\u{a1}f\u{84}\u{90}\u{9e}\u{c1}@\u{a3}z;\u{9e}\u{8c}\u{8c}\u{c7}\u{c8}\u{d0}\u{d5}\u{0d}\u{ba}\u{16}\u{82}\u{0f}8\u{de}M\u{e3}\u{eb}M\u{b5}TM\u{05}\u{9b}͵\u{cc}N\u{d9}\u{1f}\u{00}\u{00}\u{00}\u{ff}\u{ff}\r\n"; + + // Third chunk (size: a = 10 bytes) + let chunk3 = "a\r\n\u{03}\u{00}\u{16}\u{7f}\u{81}\u{b2}E\u{03}\u{00}\u{00}\r\n"; + + // Final empty chunk + let chunk4 = "0\r\n\r\n"; + + println!("\nTesting chunked gzip decompression with actual OpenAI response data"); + + // Combine all chunks + let full_chunked = format!("{}{}{}{}", chunk1, chunk2, chunk3, chunk4); + + println!("Full chunked body length: {} bytes", full_chunked.len()); + + // Extract from chunked encoding + let extracted = extract_from_chunked(&full_chunked) + .expect("Should successfully extract from chunked encoding"); + + println!("✓ Extracted {} bytes from chunked encoding", extracted.len()); + println!("Extracted bytes (first 20): {:?}", &extracted[..20.min(extracted.len())]); + + // Try to decompress + let decompressed = decompress_gzip(&extracted) + .expect("Should successfully decompress gzip data"); + + println!("✓ Successfully decompressed!"); + println!("Decompressed text: {}", decompressed); + + // Verify it's valid JSON + assert!(decompressed.contains("Hello") || decompressed.len() > 0, + "Decompressed text should contain the response message"); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 65209cf..7e65af4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -146,6 +179,146 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", @@ -162,6 +335,22 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", @@ -178,6 +367,94 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, "node_modules/@img/sharp-linux-x64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", @@ -200,6 +477,28 @@ "@img/sharp-libvips-linux-x64": "1.2.0" } }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.34.3", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", @@ -222,6 +521,82 @@ "@img/sharp-libvips-linuxmusl-x64": "1.2.0" } }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -308,6 +683,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@next/env": { "version": "15.3.5", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.5.tgz", @@ -551,6 +939,17 @@ "tslib": "^2.8.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -581,6 +980,7 @@ "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -642,6 +1042,7 @@ "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.36.0", "@typescript-eslint/types": "8.36.0", @@ -873,6 +1274,188 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", @@ -901,12 +1484,72 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1327,6 +1970,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -1995,6 +2639,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2164,6 +2809,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -2610,6 +3256,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4262,6 +4923,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4450,6 +5112,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4462,6 +5125,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5410,6 +6074,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5579,6 +6244,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 73999a5..27f0701 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2794,7 +2794,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.8.0: +tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== diff --git a/script/test-python/test_gzip_http.py b/script/test-python/test_gzip_http.py new file mode 100755 index 0000000..73d0e1c --- /dev/null +++ b/script/test-python/test_gzip_http.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Simple test to generate gzip-compressed HTTP traffic. +""" +import gzip +import http.server +import socketserver +import threading +import time +import urllib.request +import json + +class GzipHandler(http.server.SimpleHTTPRequestHandler): + def do_POST(self): + # Read request body + content_length = int(self.headers.get('Content-Length', 0)) + if content_length > 0: + self.rfile.read(content_length) + + # Create response + response_data = { + "message": "Hello from test server!", + "data": "This is compressed gzip data " * 20, # Make it big enough to compress + "status": "success" + } + response_json = json.dumps(response_data).encode('utf-8') + + # Compress response + compressed = gzip.compress(response_json) + + # Send response + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Encoding', 'gzip') + self.send_header('Content-Length', len(compressed)) + self.end_headers() + self.wfile.write(compressed) + + def log_message(self, format, *args): + # Suppress log messages + pass + +def run_server(): + PORT = 8899 + with socketserver.TCPServer(("", PORT), GzipHandler) as httpd: + print(f"Test server running on port {PORT}") + httpd.handle_request() # Handle just one request + +# Start server in background +server_thread = threading.Thread(target=run_server, daemon=True) +server_thread.start() +time.sleep(0.5) # Give server time to start + +# Make request +print("Making request to test server...") +req = urllib.request.Request( + 'http://localhost:8899/test', + data=json.dumps({"test": "data"}).encode('utf-8'), + headers={'Content-Type': 'application/json'} +) + +try: + with urllib.request.urlopen(req) as response: + # Python automatically decompresses gzip + data = response.read() + print(f"Received response ({len(data)} bytes)") + result = json.loads(data) + print(f"Response: {result['message']}") + print("\nTest completed successfully!") +except Exception as e: + print(f"Error: {e}") + +time.sleep(0.5) # Wait a bit for server to complete diff --git a/script/test-python/test_openai_simple.py b/script/test-python/test_openai_simple.py new file mode 100755 index 0000000..727a384 --- /dev/null +++ b/script/test-python/test_openai_simple.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Simple test program to generate OpenAI API traffic for testing decompression. +""" +import os +import sys + +# Check if we have the API key +api_key = os.getenv("OPENAI_API_KEY") +if not api_key: + print("Error: OPENAI_API_KEY environment variable not set") + print("Please export it: export OPENAI_API_KEY=your_key_here") + sys.exit(1) + +try: + from openai import OpenAI +except ImportError: + print("Error: openai package not installed") + print("Please install it: pip install openai") + sys.exit(1) + +model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") +client = OpenAI(api_key=api_key) + +print(f"Sending request to OpenAI API (model: {model})...") +print("This traffic should be captured by sslsniff\n") + +try: + # Make a simple API call + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "user", "content": "Say hello in exactly 5 words"} + ], + max_completion_tokens=500 + ) + + print("Response received:") + print(response.choices[0].message.content) + print("\nRequest completed successfully!") + +except Exception as e: + print(f"Error making API request: {e}") + sys.exit(1)