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(
108    result: &ExecutionResult,
109    producer: &JsdetProducer,
110) -> VulnIRGraph<VulnNode, VulnEdge> {
111    let mut graph = VulnIRGraph::new();
112    let mut node_indices: Vec<NodeIndex> = Vec::new();
113
114    // First pass: create nodes for all observations
115    for (idx, obs) in result.observations.iter().enumerate() {
116        let node_idx = observation_to_node(&mut graph, obs, idx, producer);
117        if let Some(idx) = node_idx {
118            node_indices.push(idx);
119        }
120    }
121
122    // Second pass: create edges for taint flows between nodes
123    create_taint_edges(&mut graph, &result.observations, &node_indices, producer);
124
125    graph
126}
127
128/// Converts a single observation into a VulnIR node.
129///
130/// Returns `Some(NodeIndex)` if the observation maps to a node,
131/// `None` if the observation doesn't have a security-relevant representation.
132fn observation_to_node(
133    graph: &mut VulnIRGraph<VulnNode, VulnEdge>,
134    obs: &Observation,
135    idx: usize,
136    producer: &JsdetProducer,
137) -> Option<NodeIndex> {
138    match obs {
139        // eval, Function constructor → Capability node (code-execution)
140        Observation::DynamicCodeExec {
141            source,
142            code_preview,
143        } => {
144            let source_type = match source {
145                crate::observation::DynamicCodeSource::Eval => "eval",
146                crate::observation::DynamicCodeSource::Function => "function-constructor",
147                crate::observation::DynamicCodeSource::SetTimeoutString => "settimeout-string",
148                crate::observation::DynamicCodeSource::SetIntervalString => "setinterval-string",
149                crate::observation::DynamicCodeSource::ImportScripts => "importscripts",
150            };
151
152            let node = VulnNode::Capability {
153                id: format!("dynamic-code-exec-{idx}"),
154                resource: "code-execution".to_string(),
155                permission: "execute".to_string(),
156                target: Some(code_preview.chars().take(100).collect()),
157                arguments: vec![source_type.to_string()],
158                duration_ns: None,
159                provenance: vec![producer.create_provenance(Some(SourceLocation {
160                    file: None,
161                    artifact: None,
162                    line: None,
163                    column: None,
164                }))],
165                metadata: Default::default(),
166            };
167
168            Some(graph.add_node(node))
169        }
170
171        // Network requests → Capability node (network)
172        Observation::NetworkRequest {
173            url,
174            method,
175            headers: _,
176            body: _,
177        } => {
178            let node = VulnNode::Capability {
179                id: format!("network-request-{idx}"),
180                resource: "network".to_string(),
181                permission: method.to_lowercase(),
182                target: Some(url.clone()),
183                arguments: vec![],
184                duration_ns: None,
185                provenance: vec![producer.create_provenance(Some(SourceLocation {
186                    file: None,
187                    artifact: None,
188                    line: None,
189                    column: None,
190                }))],
191                metadata: Default::default(),
192            };
193
194            Some(graph.add_node(node))
195        }
196
197        // API calls - may be module imports, filesystem operations, or code execution
198        Observation::ApiCall {
199            api,
200            args,
201            result: _,
202        } => {
203            if is_code_execution(api) {
204                // eval, setTimeout with string, etc. → Capability node (code-execution)
205                let code_preview = args
206                    .first()
207                    .and_then(|v| v.as_str())
208                    .map(|s| s.chars().take(100).collect())
209                    .unwrap_or_else(|| "<empty>".to_string());
210
211                let node = VulnNode::Capability {
212                    id: format!("dynamic-code-exec-{idx}"),
213                    resource: "code-execution".to_string(),
214                    permission: "execute".to_string(),
215                    target: Some(code_preview),
216                    arguments: vec![api.clone()],
217                    duration_ns: None,
218                    provenance: vec![producer.create_provenance(Some(SourceLocation {
219                        file: None,
220                        artifact: None,
221                        line: None,
222                        column: None,
223                    }))],
224                    metadata: Default::default(),
225                };
226
227                Some(graph.add_node(node))
228            } else if is_module_import(api) {
229                // require/import → TrustBoundary node
230                let module_name = extract_module_name(api, args);
231
232                let node = VulnNode::TrustBoundary {
233                    id: format!("module-import-{idx}"),
234                    description: format!("Module import: {module_name}"),
235                    source_type: "module-import".to_string(),
236                    confidence: ConfidenceValue::from(1.0),
237                    taint_id: None,
238                    created_ns: None,
239                    provenance: vec![producer.create_provenance(Some(SourceLocation {
240                        file: None,
241                        artifact: None,
242                        line: None,
243                        column: None,
244                    }))],
245                    metadata: Default::default(),
246                };
247
248                Some(graph.add_node(node))
249            } else if is_filesystem_operation(api) {
250                // fs.readFile/writeFile → Capability node (filesystem)
251                let permission = if api.contains("read") || api.contains("readdir") {
252                    "read"
253                } else if api.contains("write") {
254                    "write"
255                } else {
256                    "access"
257                };
258
259                let target = args.first().and_then(|v| v.as_str()).map(String::from);
260
261                let node = VulnNode::Capability {
262                    id: format!("filesystem-{idx}"),
263                    resource: "filesystem".to_string(),
264                    permission: permission.to_string(),
265                    target,
266                    arguments: vec![api.clone()],
267                    duration_ns: None,
268                    provenance: vec![producer.create_provenance(Some(SourceLocation {
269                        file: None,
270                        artifact: None,
271                        line: None,
272                        column: None,
273                    }))],
274                    metadata: Default::default(),
275                };
276
277                Some(graph.add_node(node))
278            } else {
279                None
280            }
281        }
282
283        // DOM mutations may indicate XSS or other injections
284        Observation::DomMutation {
285            kind,
286            target,
287            detail,
288        } => {
289            let _description = format!("DOM {kind:?} on {target}: {detail}");
290
291            let node = VulnNode::Capability {
292                id: format!("dom-mutation-{idx}"),
293                resource: "dom-manipulation".to_string(),
294                permission: "modify".to_string(),
295                target: Some(target.clone()),
296                arguments: vec![format!("{kind:?}"), detail.clone()],
297                duration_ns: None,
298                provenance: vec![producer.create_provenance(Some(SourceLocation {
299                    file: None,
300                    artifact: None,
301                    line: None,
302                    column: None,
303                }))],
304                metadata: Default::default(),
305            };
306
307            Some(graph.add_node(node))
308        }
309
310        // Cookie access → TrustBoundary (data crossing boundary)
311        Observation::CookieAccess {
312            operation,
313            name,
314            value: _,
315        } => {
316            let description = format!("Cookie {operation:?}: {name}");
317
318            let node = VulnNode::TrustBoundary {
319                id: format!("cookie-access-{idx}"),
320                description,
321                source_type: "cookie-access".to_string(),
322                confidence: ConfidenceValue::from(1.0),
323                taint_id: None,
324                created_ns: None,
325                provenance: vec![producer.create_provenance(Some(SourceLocation {
326                    file: None,
327                    artifact: None,
328                    line: None,
329                    column: None,
330                }))],
331                metadata: Default::default(),
332            };
333
334            Some(graph.add_node(node))
335        }
336
337        // WebAssembly instantiation
338        Observation::WasmInstantiation {
339            module_size,
340            import_names,
341            export_names,
342        } => {
343            let node = VulnNode::Capability {
344                id: format!("wasm-instantiation-{idx}"),
345                resource: "webassembly".to_string(),
346                permission: "instantiate".to_string(),
347                target: Some(format!("{module_size} bytes")),
348                arguments: vec![
349                    format!("imports: {:?}", import_names),
350                    format!("exports: {:?}", export_names),
351                ],
352                duration_ns: None,
353                provenance: vec![producer.create_provenance(Some(SourceLocation {
354                    file: None,
355                    artifact: None,
356                    line: None,
357                    column: None,
358                }))],
359                metadata: Default::default(),
360            };
361
362            Some(graph.add_node(node))
363        }
364
365        // Context messages indicate cross-context communication
366        Observation::ContextMessage {
367            from_context,
368            to_context,
369            payload,
370        } => {
371            let description = format!("Message from {from_context} to {to_context}: {payload}");
372
373            let node = VulnNode::TrustBoundary {
374                id: format!("context-message-{idx}"),
375                description,
376                source_type: "cross-context".to_string(),
377                confidence: ConfidenceValue::from(1.0),
378                taint_id: None,
379                created_ns: None,
380                provenance: vec![producer.create_provenance(Some(SourceLocation {
381                    file: None,
382                    artifact: None,
383                    line: None,
384                    column: None,
385                }))],
386                metadata: Default::default(),
387            };
388
389            Some(graph.add_node(node))
390        }
391
392        // Other observations don't map to VulnIR nodes directly
393        _ => None,
394    }
395}
396
397/// Creates TaintReach edges between nodes based on taint flow observations.
398fn create_taint_edges(
399    graph: &mut VulnIRGraph<VulnNode, VulnEdge>,
400    observations: &[Observation],
401    node_indices: &[NodeIndex],
402    producer: &JsdetProducer,
403) {
404    for obs in observations {
405        if let Observation::ApiCall { api, args, .. } = obs {
406            // Check if any arguments are tainted
407            let tainted_positions: Vec<usize> = args
408                .iter()
409                .enumerate()
410                .filter(|(_, v)| is_value_tainted(v))
411                .map(|(idx, _)| idx)
412                .collect();
413
414            if !tainted_positions.is_empty() && !node_indices.is_empty() {
415                // Find or create a source node (first tainted argument source)
416                // and a sink node (this API call)
417                // For simplicity, we connect the last node to this observation's node
418                // This is a simplified taint flow representation
419
420                let evidence = vec![producer.create_evidence(format!(
421                    "Tainted data reached sink '{api}' at argument positions {:?}",
422                    tainted_positions
423                ))];
424
425                // Try to find a previous capability or trust boundary as source
426                // and connect to a sink capability
427                if node_indices.len() >= 2 {
428                    let source_idx = node_indices[node_indices.len() - 2];
429                    let sink_idx = node_indices[node_indices.len() - 1];
430
431                    let edge = VulnEdge::TaintReach {
432                        path: format!("{api}<-arg[{tainted_positions:?}]"),
433                        confidence: ConfidenceValue::from(1.0),
434                        observed_dynamically: true,
435                        transform_chain: vec![],
436                        evidence,
437                    };
438
439                    graph.add_edge(source_idx, sink_idx, edge);
440                }
441            }
442        }
443    }
444}
445
446/// Checks if a value is tainted.
447fn is_value_tainted(value: &Value) -> bool {
448    match value {
449        Value::String(_, label) | Value::Json(_, label) => label.is_tainted(),
450        _ => false,
451    }
452}
453
454/// Checks if an API call is a code execution operation.
455fn is_code_execution(api: &str) -> bool {
456    api == "eval"
457        || api == "Function"
458        || api == "setTimeout"
459        || api == "setInterval"
460        || api == "importScripts"
461        || api.contains("executeScript")
462        || api.contains("execScript")
463}
464
465/// Checks if an API call is a module import operation.
466fn is_module_import(api: &str) -> bool {
467    api == "require"
468        || api == "import"
469        || api.starts_with("module.require")
470        || api.contains("__webpack_require__")
471        || api.contains("dynamicImport")
472}
473
474/// Extracts the module name from import arguments.
475fn extract_module_name(api: &str, args: &[Value]) -> String {
476    if let Some(first_arg) = args.first()
477        && let Some(s) = first_arg.as_str()
478    {
479        return s.to_string();
480    }
481    api.to_string()
482}
483
484/// Checks if an API call is a filesystem operation.
485fn is_filesystem_operation(api: &str) -> bool {
486    api.starts_with("fs.")
487        || api.starts_with("node:fs")
488        || api.contains("readFile")
489        || api.contains("writeFile")
490        || api.contains("readdir")
491        || api.contains("unlink")
492        || api.contains("mkdir")
493        || api.contains("rmdir")
494}
495
496/// Extension trait for ExecutionResult to provide VulnIR conversion.
497pub trait ToVulnIR {
498    /// Converts this execution result to a VulnIR graph.
499    ///
500    /// Uses the default jsdet producer identity.
501    fn to_vulnir_graph(&self) -> VulnIRGraph<VulnNode, VulnEdge>;
502
503    /// Converts this execution result to a VulnIR graph with a custom producer.
504    fn to_vulnir_graph_with_producer(
505        &self,
506        producer: &JsdetProducer,
507    ) -> VulnIRGraph<VulnNode, VulnEdge>;
508}
509
510impl ToVulnIR for ExecutionResult {
511    fn to_vulnir_graph(&self) -> VulnIRGraph<VulnNode, VulnEdge> {
512        let producer = JsdetProducer::default_producer();
513        to_vulnir_graph(self, &producer)
514    }
515
516    fn to_vulnir_graph_with_producer(
517        &self,
518        producer: &JsdetProducer,
519    ) -> VulnIRGraph<VulnNode, VulnEdge> {
520        to_vulnir_graph(self, producer)
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::observation::{DynamicCodeSource, Observation, TaintLabel, Value};
528    use vulnir::VulnIRGraphExt;
529
530    #[test]
531    fn eval_produces_capability_node() {
532        let result = ExecutionResult {
533            observations: vec![Observation::DynamicCodeExec {
534                source: DynamicCodeSource::Eval,
535                code_preview: "console.log('test')".to_string(),
536            }],
537            scripts_executed: 1,
538            errors: vec![],
539            duration_us: 1000,
540            timed_out: false,
541        };
542
543        let graph = result.to_vulnir_graph();
544
545        assert_eq!(graph.node_count(), 1);
546        assert_eq!(graph.edge_count(), 0);
547
548        // Check it's a Capability node with code-execution resource
549        let attack_surface = graph.attack_surface();
550        assert!(attack_surface.is_empty()); // Capabilities are not attack surfaces
551    }
552
553    #[test]
554    fn require_produces_trust_boundary_node() {
555        let result = ExecutionResult {
556            observations: vec![Observation::ApiCall {
557                api: "require".to_string(),
558                args: vec![Value::string("fs")],
559                result: Value::Null,
560            }],
561            scripts_executed: 1,
562            errors: vec![],
563            duration_us: 1000,
564            timed_out: false,
565        };
566
567        let graph = result.to_vulnir_graph();
568
569        assert_eq!(graph.node_count(), 1);
570        assert_eq!(graph.edge_count(), 0);
571
572        // Check it's a TrustBoundary node (attack surface)
573        let attack_surface = graph.attack_surface();
574        assert_eq!(attack_surface.len(), 1);
575    }
576
577    #[test]
578    fn network_and_eval_produces_taint_reach_edge() {
579        let result = ExecutionResult {
580            observations: vec![
581                Observation::NetworkRequest {
582                    url: "https://evil.com/payload".to_string(),
583                    method: "GET".to_string(),
584                    headers: vec![],
585                    body: None,
586                },
587                Observation::ApiCall {
588                    api: "eval".to_string(),
589                    args: vec![Value::tainted_string(
590                        "console.log('pwned')",
591                        TaintLabel::new(1),
592                    )],
593                    result: Value::Null,
594                },
595            ],
596            scripts_executed: 1,
597            errors: vec![],
598            duration_us: 1000,
599            timed_out: false,
600        };
601
602        let graph = result.to_vulnir_graph();
603
604        // Should have 2 nodes: network capability and the eval call
605        assert_eq!(graph.node_count(), 2);
606
607        // Should have 1 taint reach edge
608        assert_eq!(graph.edge_count(), 1);
609    }
610
611    #[test]
612    fn empty_result_produces_empty_graph() {
613        let result = ExecutionResult {
614            observations: vec![],
615            scripts_executed: 0,
616            errors: vec![],
617            duration_us: 0,
618            timed_out: false,
619        };
620
621        let graph = result.to_vulnir_graph();
622
623        assert_eq!(graph.node_count(), 0);
624        assert_eq!(graph.edge_count(), 0);
625    }
626
627    #[test]
628    fn filesystem_operations_produce_capability_nodes() {
629        let result = ExecutionResult {
630            observations: vec![Observation::ApiCall {
631                api: "fs.readFileSync".to_string(),
632                args: vec![Value::string("/etc/passwd")],
633                result: Value::Null,
634            }],
635            scripts_executed: 1,
636            errors: vec![],
637            duration_us: 1000,
638            timed_out: false,
639        };
640
641        let graph = result.to_vulnir_graph();
642
643        assert_eq!(graph.node_count(), 1);
644
645        // Check the node is a filesystem capability
646        let caps = graph.reachable_capabilities(NodeIndex::new(0));
647        assert_eq!(caps.len(), 1);
648    }
649
650    #[test]
651    fn jsdet_producer_trait_implementation() {
652        let producer = JsdetProducer::new("test-producer", "1.0.0");
653        let p = producer.producer();
654
655        assert_eq!(p.name, "test-producer");
656        assert_eq!(p.version, Some("1.0.0".to_string()));
657        assert_eq!(p.kind, ProducerKind::Dynamic);
658    }
659
660    #[test]
661    fn custom_producer_used_in_graph() {
662        let result = ExecutionResult {
663            observations: vec![Observation::DynamicCodeExec {
664                source: DynamicCodeSource::Function,
665                code_preview: "return 1".to_string(),
666            }],
667            scripts_executed: 1,
668            errors: vec![],
669            duration_us: 1000,
670            timed_out: false,
671        };
672
673        let producer = JsdetProducer::new("custom-scanner", "2.0.0");
674        let graph = result.to_vulnir_graph_with_producer(&producer);
675
676        assert_eq!(graph.node_count(), 1);
677    }
678}