Skip to main content

jsdet_core/
vulnir_producer.rs

1//! VulnIR producer implementation for jsdet.
2//!
3//! This module converts jsdet observations and execution results into
4//! VulnIR graph representations for security analysis.
5//!
6//! # Example
7//!
8//! ```
9//! use jsdet_core::{ExecutionResult, JsdetProducer};
10//! use jsdet_core::vulnir_producer::to_vulnir_graph;
11//!
12//! let result = ExecutionResult {
13//!     observations: vec![],
14//!     scripts_executed: 1,
15//!     errors: vec![],
16//!     duration_us: 1000,
17//!     timed_out: false,
18//! };
19//!
20//! let producer = JsdetProducer::new("jsdet-core", "1.0.0");
21//! let graph = to_vulnir_graph(&result, &producer);
22//! ```
23
24use 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/// Producer identity for jsdet VulnIR emission.
34///
35/// Implements the `VulnProducer` trait to provide stable identity
36/// for all VulnIR graphs produced by jsdet.
37#[derive(Debug, Clone)]
38pub struct JsdetProducer {
39    producer: Producer,
40}
41
42impl JsdetProducer {
43    /// Creates a new jsdet producer with the given name and version.
44    #[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    /// Creates a producer with default identity.
52    #[must_use]
53    pub fn default_producer() -> Self {
54        Self::new("jsdet-core", env!("CARGO_PKG_VERSION"))
55    }
56
57    /// Creates a provenance record for a node or edge.
58    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    /// Creates evidence from an observation.
65    fn create_evidence(&self, details: impl Into<String>) -> Evidence {
66        Evidence::new(
67            self.producer.name.clone(),
68            details,
69            1.0, // High confidence for dynamic observations
70            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/// Converts an ExecutionResult into a VulnIR graph.
88///
89/// This function processes all observations in the execution result and
90/// creates appropriate VulnIR nodes and edges:
91///
92/// - `DynamicCodeExec` (eval, Function) → `Capability` node with `code-execution` resource
93/// - `ApiCall` for require/import → `TrustBoundary` node with `module-import` source_type
94/// - `NetworkRequest` (fetch, XHR) → `Capability` node with `network` resource
95/// - `ApiCall` for fs operations → `Capability` node with `filesystem` resource
96/// - Taint flows → `TaintReach` edges between nodes
97///
98/// # Arguments
99///
100/// * `result` - The execution result containing observations
101/// * `producer` - The producer identity for provenance
102///
103/// # Returns
104///
105/// A `VulnIRGraph` representing the security-relevant behavior
106#[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    // First pass: create nodes for all observations
112    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    // Second pass: create edges for taint flows between nodes
120    create_taint_edges(&mut graph, &result.observations, &node_indices, producer);
121
122    graph
123}
124
125/// Converts a single observation into a VulnIR node.
126///
127/// Returns `Some(NodeIndex)` if the observation maps to a node,
128/// `None` if the observation doesn't have a security-relevant representation.
129fn observation_to_node(
130    graph: &mut VulnIRGraph,
131    obs: &Observation,
132    idx: usize,
133    producer: &JsdetProducer,
134) -> Option<NodeIndex> {
135    match obs {
136        // eval, Function constructor → Capability node (code-execution)
137        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        // Network requests → Capability node (network)
168        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        // API calls - may be module imports, filesystem operations, or code execution
193        Observation::ApiCall {
194            api,
195            args,
196            result: _,
197        } => {
198            if is_code_execution(api) {
199                // eval, setTimeout with string, etc. → Capability node (code-execution)
200                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                // require/import → TrustBoundary node
224                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                // fs.readFile/writeFile → Capability node (filesystem)
244                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        // DOM mutations may indicate XSS or other injections
276        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        // Cookie access → TrustBoundary (data crossing boundary)
302        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        // WebAssembly instantiation
328        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        // Context messages indicate cross-context communication
355        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        // Other observations don't map to VulnIR nodes directly
381        _ => None,
382    }
383}
384
385/// Creates TaintReach edges between nodes based on taint flow observations.
386fn 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            // Check if any arguments are tainted
395            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                // Find or create a source node (first tainted argument source)
404                // and a sink node (this API call)
405                // For simplicity, we connect the last node to this observation's node
406                // This is a simplified taint flow representation
407
408                let evidence = vec![producer.create_evidence(format!(
409                    "Tainted data reached sink '{api}' at argument positions {:?}",
410                    tainted_positions
411                ))];
412
413                // Try to find a previous capability or trust boundary as source
414                // and connect to a sink capability
415                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
434/// Checks if a value is tainted.
435fn is_value_tainted(value: &Value) -> bool {
436    match value {
437        Value::String(_, label) | Value::Json(_, label) => label.is_tainted(),
438        _ => false,
439    }
440}
441
442/// Checks if an API call is a code execution operation.
443fn 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
453/// Checks if an API call is a module import operation.
454fn 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
462/// Extracts the module name from import arguments.
463fn 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
472/// Checks if an API call is a filesystem operation.
473fn 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
484/// Extension trait for ExecutionResult to provide VulnIR conversion.
485pub trait ToVulnIR {
486    /// Converts this execution result to a VulnIR graph.
487    ///
488    /// Uses the default jsdet producer identity.
489    fn to_vulnir_graph(&self) -> VulnIRGraph;
490
491    /// Converts this execution result to a VulnIR graph with a custom producer.
492    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        // Check it's a Capability node with code-execution resource
530        let attack_surface = graph.attack_surface();
531        assert!(attack_surface.is_empty()); // Capabilities are not attack surfaces
532    }
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        // Check it's a TrustBoundary node (attack surface)
554        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        // Should have 2 nodes: network capability and the eval call
586        assert_eq!(graph.node_count(), 2);
587
588        // Should have 1 taint reach edge
589        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        // Check the node is a filesystem capability
627        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}