Skip to content

Commit 1aed1d7

Browse files
authored
Merge pull request #738 from DataDog/jf/K9VULN-1999
[K9VULN-1999] Fix and improve usefulness of JavaScript stack trace
2 parents 41121f6 + aec1697 commit 1aed1d7

10 files changed

Lines changed: 343 additions & 138 deletions

File tree

crates/static-analysis-kernel/src/analysis/ddsa_lib/common.rs

Lines changed: 105 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,36 +45,70 @@ pub enum DDSAJsRuntimeError {
4545
#[derive(Debug, Clone)]
4646
pub struct JsError {
4747
pub message: String,
48-
pub stack_trace: Vec<String>,
48+
pub stack_trace_frames: Vec<JsCallSite>,
49+
}
50+
51+
#[derive(Debug, Clone, Eq, PartialEq)]
52+
pub struct JsCallSite {
53+
pub type_name: Option<String>,
54+
pub function_name: Option<String>,
55+
pub method_name: Option<String>,
56+
pub file_name: Option<String>,
57+
pub line_number: usize,
58+
pub column_number: usize,
59+
/// The string representation of this call site, as formatted by v8 (see [stack trace format] documentation).
60+
///
61+
/// [stack trace format]: https://v8.dev/docs/stack-trace-api#appendix%3A-stack-trace-format
62+
v8_formatted_string: String,
63+
}
64+
65+
impl Display for JsCallSite {
66+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
67+
write!(f, "{}", self.v8_formatted_string)
68+
}
4969
}
5070

5171
impl From<deno_core::error::JsError> for JsError {
5272
fn from(value: deno_core::error::JsError) -> Self {
53-
// `deno_core` formats the `deno_core::error::JsError::stack` such that the first line contains
54-
// the error message, and all subsequent lines are a human-friendly stack trace. For example:
55-
// ```
56-
// TypeError: Cannot read properties of undefined (reading 'someProp')
57-
// at SomeClass.someOtherFunction (ext:ddsa_lib/someModule:572:29)
58-
// at SomeClass.someFunction (ext:ddsa_lib/someModule:157:22)
59-
// at <anonymous>:1:13
60-
// ```
61-
let stack_trace = value.stack.map_or(vec![], |str| {
73+
// v8 formats an exception error message with a stable format described at
74+
// https://v8.dev/docs/stack-trace-api#appendix%3A-stack-trace-format. `deno_core` splits
75+
// this error message by line and assigns the resulting `Vec<String>` to the `stack` field.
76+
// At the same time, it constructs individual JsStackFrame structs with a typed breakdown
77+
// of v8's string format.
78+
//
79+
// The first line is the exception message (this is implicitly tested in the
80+
// `v8_exception_stack_trace_parsing` test, however it's listed here to communicate the expectation)
81+
// This uses a fuzzy check because there might be qualifiers like "Uncaught" before the message.
82+
debug_assert!(value
83+
.exception_message
84+
.contains(value.stack.as_ref().unwrap().lines().next().unwrap()),);
85+
// ...so it is skipped to only collect the call sites.
86+
let stack_trace_frames = value.stack.map_or(vec![], |str| {
6287
str.lines()
6388
.skip(1)
64-
.map(ToString::to_string)
89+
.zip(value.frames)
90+
.map(|(display_str, frame)| JsCallSite {
91+
type_name: frame.type_name,
92+
function_name: frame.function_name,
93+
method_name: frame.method_name,
94+
file_name: frame.file_name,
95+
line_number: frame.line_number.unwrap_or_default() as usize,
96+
column_number: frame.column_number.unwrap_or_default() as usize,
97+
v8_formatted_string: display_str.to_string(),
98+
})
6599
.collect::<Vec<_>>()
66100
});
67101
Self {
68102
message: value.exception_message,
69-
stack_trace,
103+
stack_trace_frames,
70104
}
71105
}
72106
}
73107

74108
impl Display for JsError {
75109
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
76110
writeln!(f, "{}", self.message)?;
77-
for line in &self.stack_trace {
111+
for line in &self.stack_trace_frames {
78112
writeln!(f, "{}", line)?;
79113
}
80114
Ok(())
@@ -158,10 +192,8 @@ pub fn v8_interned<'s>(scope: &mut HandleScope<'s>, str: &str) -> v8::Local<'s,
158192
}
159193

160194
/// Creates a [`Normal`](v8::string::NewStringType::Normal) v8 string, which always allocates memory
161-
/// to create the string, even if it has been seen by the runtime before.
162-
///
163-
/// # Panics
164-
/// Panics if `str` is longer than the v8 string length limit.
195+
/// to create the string, even if it has been seen by the runtime before. An empty string is
196+
/// returned if the string is larger than the v8 string length limit.
165197
#[inline(always)]
166198
pub fn v8_string<'s>(scope: &mut HandleScope<'s>, str: &str) -> v8::Local<'s, v8::String> {
167199
v8::String::new_from_utf8(scope, str.as_bytes(), v8::NewStringType::Normal)
@@ -298,14 +330,15 @@ pub fn set_key_value<G>(
298330
v8_object.set(scope, v8_key.into(), v8_value);
299331
}
300332

301-
/// Creates a `v8::Global` [`UnboundScript`](v8::UnboundScript) from the given code.
333+
/// Creates a `v8::Global` [`UnboundScript`](v8::UnboundScript) from the given code and origin.
302334
pub fn compile_script(
303335
scope: &mut HandleScope,
304336
code: &str,
337+
origin: Option<&v8::ScriptOrigin>,
305338
) -> Result<v8::Global<v8::UnboundScript>, DDSAJsRuntimeError> {
306339
let code_str = v8_string(scope, code);
307340
let tc_scope = &mut v8::TryCatch::new(scope);
308-
let script_result = v8::Script::compile(tc_scope, code_str, None);
341+
let script_result = v8::Script::compile(tc_scope, code_str, origin);
309342

310343
let script = script_result.ok_or_else(|| {
311344
let exception = tc_scope
@@ -347,7 +380,7 @@ pub fn swallow_v8_error<F: FnOnce() -> T, T>(f: F) -> T {
347380
#[cfg(test)]
348381
mod tests {
349382
use crate::analysis::ddsa_lib::common::{
350-
compile_script, v8_interned, v8_string, DDSAJsRuntimeError,
383+
compile_script, v8_interned, v8_string, DDSAJsRuntimeError, JsCallSite,
351384
};
352385
use crate::analysis::ddsa_lib::runtime::inner_make_deno_core_runtime;
353386
use crate::analysis::ddsa_lib::test_utils::{cfg_test_v8, try_execute};
@@ -358,7 +391,7 @@ mod tests {
358391
let invalid_js = r#"
359392
const invalidSyntax = const;
360393
"#;
361-
let err = compile_script(&mut runtime.v8_handle_scope(), invalid_js).unwrap_err();
394+
let err = compile_script(&mut runtime.v8_handle_scope(), invalid_js, None).unwrap_err();
362395
let DDSAJsRuntimeError::Interpreter { reason } = err else {
363396
panic!("error variant should be `Interpreter`");
364397
};
@@ -371,7 +404,7 @@ const invalidSyntax = const;
371404
let invalid_js = r#"
372405
const validSyntax = 123;
373406
"#;
374-
assert!(compile_script(&mut runtime.v8_handle_scope(), invalid_js).is_ok());
407+
assert!(compile_script(&mut runtime.v8_handle_scope(), invalid_js, None).is_ok());
375408
}
376409

377410
/// Tests that [`inner_make_deno_core_runtime`] can modify the default v8::Context for
@@ -395,7 +428,13 @@ const validSyntax = 123;
395428
None,
396429
);
397430
let value = try_execute(&mut runtime.handle_scope(), code).unwrap_err();
398-
assert_eq!(value, "ReferenceError: Map is not defined".to_string());
431+
let DDSAJsRuntimeError::Execution { error: js_error } = value else {
432+
panic!("should be this variant")
433+
};
434+
assert_eq!(
435+
js_error.message,
436+
"Uncaught ReferenceError: Map is not defined"
437+
);
399438
}
400439

401440
// One byte characters
@@ -444,4 +483,47 @@ const validSyntax = 123;
444483
assert!(result.is_err());
445484
}
446485
}
486+
487+
/// The v8 exception stack trace string is properly parsed
488+
#[test]
489+
fn v8_exception_stack_trace_parsing() {
490+
let mut runtime = cfg_test_v8().deno_core_rt();
491+
// language=javascript
492+
let code = "\
493+
function someFunction() {
494+
someFunctionThatDoesntExist();
495+
}
496+
someFunction();
497+
";
498+
let DDSAJsRuntimeError::Execution { error: js_error } =
499+
try_execute(&mut runtime.handle_scope(), code).unwrap_err()
500+
else {
501+
panic!("should be this variant")
502+
};
503+
let expected = vec![
504+
JsCallSite {
505+
type_name: None,
506+
function_name: Some("someFunction".to_string()),
507+
method_name: Some("someFunction".to_string()),
508+
file_name: None,
509+
line_number: 2,
510+
column_number: 5,
511+
v8_formatted_string: " at someFunction (<anonymous>:2:5)".to_string(),
512+
},
513+
JsCallSite {
514+
type_name: None,
515+
function_name: None,
516+
method_name: None,
517+
file_name: None,
518+
line_number: 4,
519+
column_number: 1,
520+
v8_formatted_string: " at <anonymous>:4:1".to_string(),
521+
},
522+
];
523+
assert_eq!(js_error.stack_trace_frames, expected);
524+
assert_eq!(
525+
js_error.message,
526+
"Uncaught ReferenceError: someFunctionThatDoesntExist is not defined"
527+
);
528+
}
447529
}

crates/static-analysis-kernel/src/analysis/ddsa_lib/js/capture.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ assert(CAPTURE.name === "alpha", "name was incorrect");
139139
assert(CAPTURE.nodeId === 16, "nodeId was incorrect");
140140
"#;
141141
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
142-
assert_eq!(result, Ok("undefined".to_string()));
142+
assert_eq!(result.unwrap(), "undefined");
143143
}
144144

145145
/// Verifies the object shape of a `MultiCapture`.
@@ -158,7 +158,7 @@ assert(Array.isArray(CAPTURE.nodeIds), "nodeIds had wrong type");
158158
assert(CAPTURE.nodeIds.join(",") === "16,32,48", "nodeIds were incorrect");
159159
"#;
160160
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
161-
assert_eq!(result, Ok("undefined".to_string()));
161+
assert_eq!(result.unwrap(), "undefined");
162162

163163
// Single capture within an array should still be an array
164164
let capture = template.new_instance(scope, "bravo", &[16]);
@@ -168,6 +168,6 @@ assert(Array.isArray(CAPTURE.nodeIds), "nodeIds had wrong type");
168168
assert(CAPTURE.nodeIds.join(",") === "16", "nodeIds were incorrect");
169169
"#;
170170
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
171-
assert_eq!(result, Ok("undefined".to_string()));
171+
assert_eq!(result.unwrap(), "undefined");
172172
}
173173
}

crates/static-analysis-kernel/src/analysis/ddsa_lib/js/flow/graph.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@ graph.adjacencyList;
802802
js_script += "\
803803
[graph.adjacencyList, transpose(graph.adjacencyList)];
804804
";
805-
let js_script = compile_script(&mut rt.v8_handle_scope(), &js_script).unwrap();
805+
let js_script = compile_script(&mut rt.v8_handle_scope(), &js_script, None).unwrap();
806806

807807
let vertex_transformer = |node: &dot_structures::Node| -> Option<dot_structures::Node> {
808808
let vid = id_str(&node.id.0).parse::<u32>().unwrap();
@@ -1039,7 +1039,7 @@ const vidPaths = _findTaintFlows(adjList, cst(1), false).map((flow) => {
10391039
const serialized = vidPaths.map((flow) => DDSA_Console.stringify(flow)).join('\\n');
10401040
serialized;
10411041
";
1042-
let js_code = compile_script(&mut rt.v8_handle_scope(), js_code).unwrap();
1042+
let js_code = compile_script(&mut rt.v8_handle_scope(), js_code, None).unwrap();
10431043
let res = rt
10441044
.scoped_execute(&js_code, |sc, value| value.to_rust_string_lossy(sc), None)
10451045
.unwrap();
@@ -1062,7 +1062,7 @@ serialized;
10621062
const flow = new TaintFlow([], false);
10631063
flow instanceof Array;
10641064
";
1065-
let js_code = compile_script(&mut rt.v8_handle_scope(), js_code).unwrap();
1065+
let js_code = compile_script(&mut rt.v8_handle_scope(), js_code, None).unwrap();
10661066
let has_array_proto = rt
10671067
.scoped_execute(&js_code, |_, value| value.is_true(), None)
10681068
.unwrap();

crates/static-analysis-kernel/src/analysis/ddsa_lib/js/flow/java.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ __ddsaPrivate__.graphToDOT(methodFlow.graph, \"cst_v8_full\");
177177
",
178178
CLASS_NAME, method_decl_id
179179
);
180-
let script = compile_script(&mut rt.v8_handle_scope(), &script).unwrap();
180+
let script = compile_script(&mut rt.v8_handle_scope(), &script, None).unwrap();
181181
let full_str = rt
182182
.scoped_execute(&script, |sc, val| val.to_rust_string_lossy(sc), None)
183183
.unwrap();
@@ -289,7 +289,7 @@ public class TestClass {
289289
.unwrap_or("undefined".to_string());
290290

291291
let script = format!("{}.findContainingMethod(getNode({}))?.id;", CLASS_NAME, nid);
292-
let script = compile_script(&mut rt.v8_handle_scope(), &script).unwrap();
292+
let script = compile_script(&mut rt.v8_handle_scope(), &script, None).unwrap();
293293
let exe_result = rt.scoped_execute(&script, |sc, v| v.to_rust_string_lossy(sc), None);
294294
assert_eq!(exe_result.unwrap(), expected);
295295
}
@@ -400,7 +400,7 @@ for (const flow of [sourceFlows[0], sinkFlows[0]]) {{
400400
serialized;
401401
"#,
402402
);
403-
let script = compile_script(&mut rt.v8_handle_scope(), &script).unwrap();
403+
let script = compile_script(&mut rt.v8_handle_scope(), &script, None).unwrap();
404404
let res = rt
405405
.scoped_execute(&script, |sc, value| value.to_rust_string_lossy(sc), None)
406406
.unwrap();
@@ -456,7 +456,7 @@ const v = Violation.new("flow violation", sourceFlows[0]);
456456
v;
457457
"#,
458458
);
459-
let script = compile_script(&mut rt.v8_handle_scope(), &script).unwrap();
459+
let script = compile_script(&mut rt.v8_handle_scope(), &script, None).unwrap();
460460
let violation = rt
461461
.scoped_execute(
462462
&script,

crates/static-analysis-kernel/src/analysis/ddsa_lib/js/query_match.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ assert(cap_node_ids.length === 1, "array must have exactly one elements");
158158
assert(cap_node_ids[0] === 10, "nodeId was incorrect");
159159
"#;
160160
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
161-
assert_eq!(result, Ok("undefined".to_string()));
161+
assert_eq!(result.unwrap(), "undefined");
162162

163163
// A capture name that isn't present should return undefined
164164
let code = r#"QUERY_MATCH._getManyIds("missing_from_captures");"#;
@@ -185,7 +185,7 @@ assert(cap_node_ids.length === 3, "array must have exactly three elements");
185185
assert(cap_node_ids.join(",") === "10,20,30", "nodeIds were incorrect");
186186
"#;
187187
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
188-
assert_eq!(result, Ok("undefined".to_string()));
188+
assert_eq!(result.unwrap(), "undefined");
189189
}
190190

191191
/// Tests that a call to `_getManyIds` on a `MultiCapture` that happens to have only a single node id
@@ -207,7 +207,7 @@ assert(cap_node_ids.length === 1, "array must have exactly one elements");
207207
assert(cap_node_ids[0] === 10, "nodeId was incorrect");
208208
"#;
209209
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
210-
assert_eq!(result, Ok("undefined".to_string()));
210+
assert_eq!(result.unwrap(), "undefined");
211211
}
212212

213213
/// Tests that the [`RustConverter`] creates a JS class instance with `undefined` passed in as the captures array (`_captures`).
@@ -244,7 +244,7 @@ assert(cap_node_ids[0] === 10, "nodeId was incorrect");
244244

245245
let code = r#"assert(QUERY_MATCH.get("cap_name").id === 10);"#;
246246
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
247-
assert_eq!(result, Ok("undefined".to_string()));
247+
assert_eq!(result.unwrap(), "undefined");
248248
}
249249

250250
/// Tests that `getMany` properly retrieves nodes from the `TsNodeBridge`.
@@ -270,6 +270,6 @@ assert(stubNodes[0].id === 10);
270270
assert(stubNodes[1].id === 20);
271271
"#;
272272
let result = try_execute(scope, code).map(|v| v.to_rust_string_lossy(scope));
273-
assert_eq!(result, Ok("undefined".to_string()));
273+
assert_eq!(result.unwrap(), "undefined");
274274
}
275275
}

0 commit comments

Comments
 (0)