1use petgraph::graph::NodeIndex;
25use vulnir::{
26 ConfidenceValue, Evidence, Producer, ProducerKind, Provenance, SourceLocation, VulnEdge,
27 VulnIRGraph, VulnNode, VulnProducer,
28};
29
30use crate::observation::{Observation, Value};
31use crate::sandbox::ExecutionResult;
32
33#[derive(Debug, Clone)]
38pub struct JsdetProducer {
39 producer: Producer,
40}
41
42impl JsdetProducer {
43 #[must_use]
45 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
46 Self {
47 producer: Producer::new(name, ProducerKind::Dynamic).with_version(version),
48 }
49 }
50
51 #[must_use]
53 pub fn default_producer() -> Self {
54 Self::new("jsdet-core", env!("CARGO_PKG_VERSION"))
55 }
56
57 fn create_provenance(&self, location: Option<SourceLocation>) -> Provenance {
59 let mut prov = Provenance::new(self.producer.clone());
60 prov.location = location;
61 prov
62 }
63
64 fn create_evidence(&self, details: impl Into<String>) -> Evidence {
66 Evidence::new(
67 self.producer.name.clone(),
68 details,
69 1.0, true,
71 )
72 }
73}
74
75impl VulnProducer for JsdetProducer {
76 fn producer(&self) -> Producer {
77 self.producer.clone()
78 }
79}
80
81impl Default for JsdetProducer {
82 fn default() -> Self {
83 Self::default_producer()
84 }
85}
86
87#[must_use]
107pub fn to_vulnir_graph(result: &ExecutionResult, producer: &JsdetProducer) -> VulnIRGraph {
108 let mut graph = VulnIRGraph::new();
109 let mut node_indices: Vec<NodeIndex> = Vec::new();
110
111 for (idx, obs) in result.observations.iter().enumerate() {
113 let node_idx = observation_to_node(&mut graph, obs, idx, producer);
114 if let Some(idx) = node_idx {
115 node_indices.push(idx);
116 }
117 }
118
119 create_taint_edges(&mut graph, &result.observations, &node_indices, producer);
121
122 graph
123}
124
125fn observation_to_node(
130 graph: &mut VulnIRGraph,
131 obs: &Observation,
132 idx: usize,
133 producer: &JsdetProducer,
134) -> Option<NodeIndex> {
135 match obs {
136 Observation::DynamicCodeExec {
138 source,
139 code_preview,
140 } => {
141 let source_type = match source {
142 crate::observation::DynamicCodeSource::Eval => "eval",
143 crate::observation::DynamicCodeSource::Function => "function-constructor",
144 crate::observation::DynamicCodeSource::SetTimeoutString => "settimeout-string",
145 crate::observation::DynamicCodeSource::SetIntervalString => "setinterval-string",
146 crate::observation::DynamicCodeSource::ImportScripts => "importscripts",
147 };
148
149 let node = VulnNode::Capability {
150 id: format!("dynamic-code-exec-{idx}"),
151 resource: "code-execution".to_string(),
152 permission: "execute".to_string(),
153 target: Some(code_preview.chars().take(100).collect()),
154 arguments: vec![source_type.to_string()],
155 duration_ns: None,
156 provenance: vec![producer.create_provenance(Some(SourceLocation {
157 artifact: None,
158 line: None,
159 column: None,
160 }))],
161 metadata: Default::default(),
162 };
163
164 Some(graph.add_node(node))
165 }
166
167 Observation::NetworkRequest {
169 url,
170 method,
171 headers: _,
172 body: _,
173 } => {
174 let node = VulnNode::Capability {
175 id: format!("network-request-{idx}"),
176 resource: "network".to_string(),
177 permission: method.to_lowercase(),
178 target: Some(url.clone()),
179 arguments: vec![],
180 duration_ns: None,
181 provenance: vec![producer.create_provenance(Some(SourceLocation {
182 artifact: None,
183 line: None,
184 column: None,
185 }))],
186 metadata: Default::default(),
187 };
188
189 Some(graph.add_node(node))
190 }
191
192 Observation::ApiCall {
194 api,
195 args,
196 result: _,
197 } => {
198 if is_code_execution(api) {
199 let code_preview = args
201 .first()
202 .and_then(|v| v.as_str())
203 .map(|s| s.chars().take(100).collect())
204 .unwrap_or_else(|| "<empty>".to_string());
205
206 let node = VulnNode::Capability {
207 id: format!("dynamic-code-exec-{idx}"),
208 resource: "code-execution".to_string(),
209 permission: "execute".to_string(),
210 target: Some(code_preview),
211 arguments: vec![api.clone()],
212 duration_ns: None,
213 provenance: vec![producer.create_provenance(Some(SourceLocation {
214 artifact: None,
215 line: None,
216 column: None,
217 }))],
218 metadata: Default::default(),
219 };
220
221 Some(graph.add_node(node))
222 } else if is_module_import(api) {
223 let module_name = extract_module_name(api, args);
225
226 let node = VulnNode::TrustBoundary {
227 id: format!("module-import-{idx}"),
228 description: format!("Module import: {module_name}"),
229 source_type: "module-import".to_string(),
230 confidence: ConfidenceValue::from(1.0),
231 taint_id: None,
232 created_ns: None,
233 provenance: vec![producer.create_provenance(Some(SourceLocation {
234 artifact: None,
235 line: None,
236 column: None,
237 }))],
238 metadata: Default::default(),
239 };
240
241 Some(graph.add_node(node))
242 } else if is_filesystem_operation(api) {
243 let permission = if api.contains("read") || api.contains("readdir") {
245 "read"
246 } else if api.contains("write") {
247 "write"
248 } else {
249 "access"
250 };
251
252 let target = args.first().and_then(|v| v.as_str()).map(String::from);
253
254 let node = VulnNode::Capability {
255 id: format!("filesystem-{idx}"),
256 resource: "filesystem".to_string(),
257 permission: permission.to_string(),
258 target,
259 arguments: vec![api.clone()],
260 duration_ns: None,
261 provenance: vec![producer.create_provenance(Some(SourceLocation {
262 artifact: None,
263 line: None,
264 column: None,
265 }))],
266 metadata: Default::default(),
267 };
268
269 Some(graph.add_node(node))
270 } else {
271 None
272 }
273 }
274
275 Observation::DomMutation {
277 kind,
278 target,
279 detail,
280 } => {
281 let _description = format!("DOM {kind:?} on {target}: {detail}");
282
283 let node = VulnNode::Capability {
284 id: format!("dom-mutation-{idx}"),
285 resource: "dom-manipulation".to_string(),
286 permission: "modify".to_string(),
287 target: Some(target.clone()),
288 arguments: vec![format!("{kind:?}"), detail.clone()],
289 duration_ns: None,
290 provenance: vec![producer.create_provenance(Some(SourceLocation {
291 artifact: None,
292 line: None,
293 column: None,
294 }))],
295 metadata: Default::default(),
296 };
297
298 Some(graph.add_node(node))
299 }
300
301 Observation::CookieAccess {
303 operation,
304 name,
305 value: _,
306 } => {
307 let description = format!("Cookie {operation:?}: {name}");
308
309 let node = VulnNode::TrustBoundary {
310 id: format!("cookie-access-{idx}"),
311 description,
312 source_type: "cookie-access".to_string(),
313 confidence: ConfidenceValue::from(1.0),
314 taint_id: None,
315 created_ns: None,
316 provenance: vec![producer.create_provenance(Some(SourceLocation {
317 artifact: None,
318 line: None,
319 column: None,
320 }))],
321 metadata: Default::default(),
322 };
323
324 Some(graph.add_node(node))
325 }
326
327 Observation::WasmInstantiation {
329 module_size,
330 import_names,
331 export_names,
332 } => {
333 let node = VulnNode::Capability {
334 id: format!("wasm-instantiation-{idx}"),
335 resource: "webassembly".to_string(),
336 permission: "instantiate".to_string(),
337 target: Some(format!("{module_size} bytes")),
338 arguments: vec![
339 format!("imports: {:?}", import_names),
340 format!("exports: {:?}", export_names),
341 ],
342 duration_ns: None,
343 provenance: vec![producer.create_provenance(Some(SourceLocation {
344 artifact: None,
345 line: None,
346 column: None,
347 }))],
348 metadata: Default::default(),
349 };
350
351 Some(graph.add_node(node))
352 }
353
354 Observation::ContextMessage {
356 from_context,
357 to_context,
358 payload,
359 } => {
360 let description = format!("Message from {from_context} to {to_context}: {payload}");
361
362 let node = VulnNode::TrustBoundary {
363 id: format!("context-message-{idx}"),
364 description,
365 source_type: "cross-context".to_string(),
366 confidence: ConfidenceValue::from(1.0),
367 taint_id: None,
368 created_ns: None,
369 provenance: vec![producer.create_provenance(Some(SourceLocation {
370 artifact: None,
371 line: None,
372 column: None,
373 }))],
374 metadata: Default::default(),
375 };
376
377 Some(graph.add_node(node))
378 }
379
380 _ => None,
382 }
383}
384
385fn create_taint_edges(
387 graph: &mut VulnIRGraph,
388 observations: &[Observation],
389 node_indices: &[NodeIndex],
390 producer: &JsdetProducer,
391) {
392 for obs in observations {
393 if let Observation::ApiCall { api, args, .. } = obs {
394 let tainted_positions: Vec<usize> = args
396 .iter()
397 .enumerate()
398 .filter(|(_, v)| is_value_tainted(v))
399 .map(|(idx, _)| idx)
400 .collect();
401
402 if !tainted_positions.is_empty() && !node_indices.is_empty() {
403 let evidence = vec![producer.create_evidence(format!(
409 "Tainted data reached sink '{api}' at argument positions {:?}",
410 tainted_positions
411 ))];
412
413 if node_indices.len() >= 2 {
416 let source_idx = node_indices[node_indices.len() - 2];
417 let sink_idx = node_indices[node_indices.len() - 1];
418
419 let edge = VulnEdge::TaintReach {
420 path: format!("{api}<-arg[{tainted_positions:?}]"),
421 confidence: ConfidenceValue::from(1.0),
422 observed_dynamically: true,
423 transform_chain: vec![],
424 evidence,
425 };
426
427 graph.add_edge(source_idx, sink_idx, edge);
428 }
429 }
430 }
431 }
432}
433
434fn is_value_tainted(value: &Value) -> bool {
436 match value {
437 Value::String(_, label) | Value::Json(_, label) => label.is_tainted(),
438 _ => false,
439 }
440}
441
442fn is_code_execution(api: &str) -> bool {
444 api == "eval"
445 || api == "Function"
446 || api == "setTimeout"
447 || api == "setInterval"
448 || api == "importScripts"
449 || api.contains("executeScript")
450 || api.contains("execScript")
451}
452
453fn is_module_import(api: &str) -> bool {
455 api == "require"
456 || api == "import"
457 || api.starts_with("module.require")
458 || api.contains("__webpack_require__")
459 || api.contains("dynamicImport")
460}
461
462fn extract_module_name(api: &str, args: &[Value]) -> String {
464 if let Some(first_arg) = args.first()
465 && let Some(s) = first_arg.as_str()
466 {
467 return s.to_string();
468 }
469 api.to_string()
470}
471
472fn is_filesystem_operation(api: &str) -> bool {
474 api.starts_with("fs.")
475 || api.starts_with("node:fs")
476 || api.contains("readFile")
477 || api.contains("writeFile")
478 || api.contains("readdir")
479 || api.contains("unlink")
480 || api.contains("mkdir")
481 || api.contains("rmdir")
482}
483
484pub trait ToVulnIR {
486 fn to_vulnir_graph(&self) -> VulnIRGraph;
490
491 fn to_vulnir_graph_with_producer(&self, producer: &JsdetProducer) -> VulnIRGraph;
493}
494
495impl ToVulnIR for ExecutionResult {
496 fn to_vulnir_graph(&self) -> VulnIRGraph {
497 let producer = JsdetProducer::default_producer();
498 to_vulnir_graph(self, &producer)
499 }
500
501 fn to_vulnir_graph_with_producer(&self, producer: &JsdetProducer) -> VulnIRGraph {
502 to_vulnir_graph(self, producer)
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::observation::{DynamicCodeSource, Observation, TaintLabel, Value};
510
511 #[test]
512 fn eval_produces_capability_node() {
513 let result = ExecutionResult {
514 observations: vec![Observation::DynamicCodeExec {
515 source: DynamicCodeSource::Eval,
516 code_preview: "console.log('test')".to_string(),
517 }],
518 scripts_executed: 1,
519 errors: vec![],
520 duration_us: 1000,
521 timed_out: false,
522 };
523
524 let graph = result.to_vulnir_graph();
525
526 assert_eq!(graph.node_count(), 1);
527 assert_eq!(graph.edge_count(), 0);
528
529 let attack_surface = graph.attack_surface();
531 assert!(attack_surface.is_empty()); }
533
534 #[test]
535 fn require_produces_trust_boundary_node() {
536 let result = ExecutionResult {
537 observations: vec![Observation::ApiCall {
538 api: "require".to_string(),
539 args: vec![Value::string("fs")],
540 result: Value::Null,
541 }],
542 scripts_executed: 1,
543 errors: vec![],
544 duration_us: 1000,
545 timed_out: false,
546 };
547
548 let graph = result.to_vulnir_graph();
549
550 assert_eq!(graph.node_count(), 1);
551 assert_eq!(graph.edge_count(), 0);
552
553 let attack_surface = graph.attack_surface();
555 assert_eq!(attack_surface.len(), 1);
556 }
557
558 #[test]
559 fn network_and_eval_produces_taint_reach_edge() {
560 let result = ExecutionResult {
561 observations: vec![
562 Observation::NetworkRequest {
563 url: "https://evil.com/payload".to_string(),
564 method: "GET".to_string(),
565 headers: vec![],
566 body: None,
567 },
568 Observation::ApiCall {
569 api: "eval".to_string(),
570 args: vec![Value::tainted_string(
571 "console.log('pwned')",
572 TaintLabel::new(1),
573 )],
574 result: Value::Null,
575 },
576 ],
577 scripts_executed: 1,
578 errors: vec![],
579 duration_us: 1000,
580 timed_out: false,
581 };
582
583 let graph = result.to_vulnir_graph();
584
585 assert_eq!(graph.node_count(), 2);
587
588 assert_eq!(graph.edge_count(), 1);
590 }
591
592 #[test]
593 fn empty_result_produces_empty_graph() {
594 let result = ExecutionResult {
595 observations: vec![],
596 scripts_executed: 0,
597 errors: vec![],
598 duration_us: 0,
599 timed_out: false,
600 };
601
602 let graph = result.to_vulnir_graph();
603
604 assert_eq!(graph.node_count(), 0);
605 assert_eq!(graph.edge_count(), 0);
606 }
607
608 #[test]
609 fn filesystem_operations_produce_capability_nodes() {
610 let result = ExecutionResult {
611 observations: vec![Observation::ApiCall {
612 api: "fs.readFileSync".to_string(),
613 args: vec![Value::string("/etc/passwd")],
614 result: Value::Null,
615 }],
616 scripts_executed: 1,
617 errors: vec![],
618 duration_us: 1000,
619 timed_out: false,
620 };
621
622 let graph = result.to_vulnir_graph();
623
624 assert_eq!(graph.node_count(), 1);
625
626 let caps = graph.reachable_capabilities(NodeIndex::new(0));
628 assert_eq!(caps.len(), 1);
629 }
630
631 #[test]
632 fn jsdet_producer_trait_implementation() {
633 let producer = JsdetProducer::new("test-producer", "1.0.0");
634 let p = producer.producer();
635
636 assert_eq!(p.name, "test-producer");
637 assert_eq!(p.version, Some("1.0.0".to_string()));
638 assert_eq!(p.kind, ProducerKind::Dynamic);
639 }
640
641 #[test]
642 fn custom_producer_used_in_graph() {
643 let result = ExecutionResult {
644 observations: vec![Observation::DynamicCodeExec {
645 source: DynamicCodeSource::Function,
646 code_preview: "return 1".to_string(),
647 }],
648 scripts_executed: 1,
649 errors: vec![],
650 duration_us: 1000,
651 timed_out: false,
652 };
653
654 let producer = JsdetProducer::new("custom-scanner", "2.0.0");
655 let graph = result.to_vulnir_graph_with_producer(&producer);
656
657 assert_eq!(graph.node_count(), 1);
658 }
659}