diff --git a/docs/guide/rule-engines/javascript.md b/docs/guide/rule-engines/javascript.md index 1ec13b71..870f1d4e 100644 --- a/docs/guide/rule-engines/javascript.md +++ b/docs/guide/rule-engines/javascript.md @@ -133,6 +133,72 @@ const requestString = `${r.method} ${r.host}${r.path}`; patterns.some(pattern => pattern.test(requestString)) ``` +## Debugging with Console API + +JavaScript rules support the full console API for debugging. Each method maps to a corresponding tracing level: + +| Console Method | Tracing Level | Use Case | +|----------------|---------------|----------| +| `console.debug()` | DEBUG | Detailed troubleshooting information | +| `console.log()` | INFO | General informational messages | +| `console.info()` | INFO | Informational messages (e.g., allowed requests) | +| `console.warn()` | WARN | Warning messages (e.g., suspicious patterns) | +| `console.error()` | ERROR | Error messages (e.g., blocked threats) | + +### Example + +```javascript +// Debug: detailed information +console.debug("Evaluating request:", r.method, r.url); +console.debug("Full request:", r); + +// Info: general messages +console.info("Allowing trusted domain:", r.host); + +// Warn: suspicious patterns +console.warn("Suspicious path detected:", r.path); + +// Error: security issues +console.error("Blocked malicious request:", r.url); +``` + +### Viewing Console Output + +Set `RUST_LOG` to control which messages appear: + +```bash +# Show debug and above (debug, info, warn, error) - all console output +RUST_LOG=debug httpjail --js-file rules.js -- command + +# Show info and above (info, warn, error) - recommended for production +# Includes console.log(), console.info(), console.warn(), console.error() +RUST_LOG=info httpjail --js-file rules.js -- command + +# Show only warnings and errors +RUST_LOG=warn httpjail --js-file rules.js -- command +``` + +Example output with color coding: + +``` +DEBUG httpjail::rules::js: Evaluating request: GET https://api.github.com/users +INFO httpjail::rules::js: Allowing trusted domain: api.github.com +WARN httpjail::rules::js: Suspicious path detected: /admin +ERROR httpjail::rules::js: Blocked malicious request: https://evil.com/exploit +``` + +### Objects and Arrays + +Objects and arrays are automatically JSON-stringified: + +```javascript +console.log("Request:", r); +// Output: Request: {"url":"https://...","method":"GET",...} + +console.log("Complex:", {hosts: ["a.com", "b.com"], count: 42}); +// Output: Complex: {"hosts":["a.com","b.com"],"count":42} +``` + ## When to Use Best for: diff --git a/examples/console_log_demo.js b/examples/console_log_demo.js new file mode 100644 index 00000000..4d156f4c --- /dev/null +++ b/examples/console_log_demo.js @@ -0,0 +1,33 @@ +// Example JavaScript rule file demonstrating console API usage +// This can be used with: httpjail --js-file examples/console_log_demo.js ... +// +// Console output is visible when running with appropriate log levels: +// RUST_LOG=debug httpjail --js-file examples/console_log_demo.js ... # Shows debug/log +// RUST_LOG=info httpjail --js-file examples/console_log_demo.js ... # Shows info/warn/error +// RUST_LOG=warn httpjail --js-file examples/console_log_demo.js ... # Shows warn/error + +// Different console methods map to tracing levels: +// console.debug() -> DEBUG +// console.log() -> INFO +// console.info() -> INFO +// console.warn() -> WARN +// console.error() -> ERROR + +// Debug: detailed information for troubleshooting +console.debug("Evaluating request:", r.method, r.url); +console.debug("Full request object:", r); + +// Log: general informational messages +console.log("Requester IP:", r.requester_ip); + +// Example: Allow only GET requests to example.com +if (r.method === "GET" && r.url.includes("example.com")) { + console.info("Allowing request to example.com"); + true +} else if (r.url.includes("suspicious-site.com")) { + console.error("Blocked suspicious site:", r.url); + ({deny_message: "Blocked: suspicious site"}) +} else { + console.warn("Denying request - not example.com or not GET"); + ({deny_message: "Only GET requests to example.com are allowed"}) +} diff --git a/src/rules.rs b/src/rules.rs index 5a209e67..b2d1735c 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -1,4 +1,5 @@ pub mod common; +mod console_log; pub mod proc; pub mod shell; pub mod v8_js; diff --git a/src/rules/console_log.rs b/src/rules/console_log.rs new file mode 100644 index 00000000..4d9c126d --- /dev/null +++ b/src/rules/console_log.rs @@ -0,0 +1,100 @@ +use tracing::{debug, info, warn}; + +/// Convert a V8 value to a string, using JSON.stringify for objects +fn v8_value_to_string(scope: &mut v8::HandleScope, value: v8::Local) -> String { + // For objects and arrays, try JSON.stringify + if value.is_object() && !value.is_null() && !value.is_undefined() { + if let Some(json_str) = try_json_stringify(scope, value) { + return json_str; + } + } + + // Fallback to toString() for all types + value + .to_string(scope) + .map(|s| s.to_rust_string_lossy(scope)) + .unwrap_or_else(|| "[value]".to_string()) +} + +/// Try to JSON.stringify a value, returning None if it fails +fn try_json_stringify(scope: &mut v8::HandleScope, value: v8::Local) -> Option { + let global = scope.get_current_context().global(scope); + let json_key = v8::String::new(scope, "JSON")?; + let stringify_key = v8::String::new(scope, "stringify")?; + + let json_obj = global.get(scope, json_key.into())?.to_object(scope)?; + let stringify_fn = json_obj.get(scope, stringify_key.into())?; + let stringify_fn = v8::Local::::try_from(stringify_fn).ok()?; + + let result = stringify_fn.call(scope, json_obj.into(), &[value])?; + result + .to_string(scope) + .map(|s| s.to_rust_string_lossy(scope)) +} + +/// Format console arguments into a single string +fn format_console_args(scope: &mut v8::HandleScope, args: v8::FunctionCallbackArguments) -> String { + let mut log_parts = Vec::new(); + for i in 0..args.length() { + let arg = args.get(i); + log_parts.push(v8_value_to_string(scope, arg)); + } + log_parts.join(" ") +} + +/// Generic console callback that logs at a specific level +fn console_callback( + scope: &mut v8::HandleScope, + args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, + log_fn: fn(&str), +) { + let message = format_console_args(scope, args); + log_fn(&message); +} + +/// Macro to generate console method callbacks for each log level +macro_rules! console_method { + ($name:ident, $log_macro:path) => { + fn $name( + scope: &mut v8::HandleScope, + args: v8::FunctionCallbackArguments, + retval: v8::ReturnValue, + ) { + console_callback(scope, args, retval, |msg| { + $log_macro!(target: "httpjail::rules::js", "{}", msg) + }); + } + }; +} + +// Generate console.debug, console.log, console.info, console.warn, console.error +console_method!(console_debug, debug); +console_method!(console_log, info); +console_method!(console_info, info); +console_method!(console_warn, warn); +console_method!(console_error, tracing::error); + +/// Set up console object with debug, log, info, warn, error methods +pub fn setup_console(context_scope: &mut v8::ContextScope) { + let global = context_scope.get_current_context().global(context_scope); + let console_obj = v8::Object::new(context_scope); + + // Register each console method + macro_rules! add_console_method { + ($name:expr, $callback:expr) => { + let key = v8::String::new(context_scope, $name).unwrap(); + let func = v8::Function::new(context_scope, $callback).unwrap(); + console_obj.set(context_scope, key.into(), func.into()); + }; + } + + add_console_method!("debug", console_debug); + add_console_method!("log", console_log); + add_console_method!("info", console_info); + add_console_method!("warn", console_warn); + add_console_method!("error", console_error); + + let console_key = v8::String::new(context_scope, "console").unwrap(); + global.set(context_scope, console_key.into(), console_obj.into()); +} diff --git a/src/rules/v8_js.rs b/src/rules/v8_js.rs index f8ce51b0..4ed47f29 100644 --- a/src/rules/v8_js.rs +++ b/src/rules/v8_js.rs @@ -1,4 +1,5 @@ use crate::rules::common::{RequestInfo, RuleResponse}; +use crate::rules::console_log; use crate::rules::{EvaluationResult, RuleEngineTrait}; use async_trait::async_trait; use hyper::Method; @@ -125,6 +126,9 @@ impl V8JsRuleEngine { let context = v8::Context::new(handle_scope, Default::default()); let context_scope = &mut v8::ContextScope::new(handle_scope, context); + // Set up console object with debug, log, info, warn, error methods + console_log::setup_console(context_scope); + let global = context.global(context_scope); // Serialize RequestInfo to JSON - this is the exact same JSON sent to proc diff --git a/tests/json_parity.rs b/tests/json_parity.rs index 76464c71..aeb84dac 100644 --- a/tests/json_parity.rs +++ b/tests/json_parity.rs @@ -113,3 +113,42 @@ async fn test_response_parity() { } } } + +#[tokio::test] +async fn test_console_api() { + // Test that console API methods work without throwing errors. + // The console output is visible in test output when run with RUST_LOG=debug, + // which provides visual confirmation that the console API is working correctly. + // We don't attempt to capture/assert on logs because the global tracing subscriber + // is already initialized by tests/common/logging.rs, making log capture unreliable. + + let js_engine = V8JsRuleEngine::new( + r#" + // Test all console methods + console.debug("Test debug"); + console.log("Test log"); + console.info("Test info"); + console.warn("Test warn"); + console.error("Test error"); + + // Test object/array formatting + console.log("Object:", {foo: "bar"}); + console.log("Array:", [1, 2, 3]); + + // Test multiple arguments + console.log("Multiple", "arguments", 123); + + true + "# + .to_string(), + ) + .unwrap(); + + let result = js_engine + .evaluate(Method::GET, "https://example.com", "127.0.0.1") + .await; + + // Should allow since the expression returns true + // If console methods threw errors, the rule would fail + assert!(matches!(result.action, Action::Allow)); +}