use petgraph::graph::NodeIndex;
use vulnir::{
ConfidenceValue, Evidence, Producer, ProducerKind, Provenance, SourceLocation, VulnEdge,
VulnIRGraph, VulnNode, VulnProducer,
};
use crate::observation::{Observation, Value};
use crate::sandbox::ExecutionResult;
#[derive(Debug, Clone)]
pub struct JsdetProducer {
producer: Producer,
}
impl JsdetProducer {
#[must_use]
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
producer: Producer::new(name, ProducerKind::Dynamic).with_version(version),
}
}
#[must_use]
pub fn default_producer() -> Self {
Self::new("jsdet-core", env!("CARGO_PKG_VERSION"))
}
fn create_provenance(&self, location: Option<SourceLocation>) -> Provenance {
let mut prov = Provenance::new(self.producer.clone());
prov.location = location;
prov
}
fn create_evidence(&self, details: impl Into<String>) -> Evidence {
Evidence::new(
self.producer.name.clone(),
details,
1.0, true,
)
}
}
impl VulnProducer for JsdetProducer {
fn producer(&self) -> Producer {
self.producer.clone()
}
}
impl Default for JsdetProducer {
fn default() -> Self {
Self::default_producer()
}
}
#[must_use]
pub fn to_vulnir_graph(
result: &ExecutionResult,
producer: &JsdetProducer,
) -> VulnIRGraph<VulnNode, VulnEdge> {
let mut graph = VulnIRGraph::new();
let mut node_indices: Vec<NodeIndex> = Vec::new();
for (idx, obs) in result.observations.iter().enumerate() {
let node_idx = observation_to_node(&mut graph, obs, idx, producer);
if let Some(idx) = node_idx {
node_indices.push(idx);
}
}
create_taint_edges(&mut graph, &result.observations, &node_indices, producer);
graph
}
fn observation_to_node(
graph: &mut VulnIRGraph<VulnNode, VulnEdge>,
obs: &Observation,
idx: usize,
producer: &JsdetProducer,
) -> Option<NodeIndex> {
match obs {
Observation::DynamicCodeExec {
source,
code_preview,
} => {
let source_type = match source {
crate::observation::DynamicCodeSource::Eval => "eval",
crate::observation::DynamicCodeSource::Function => "function-constructor",
crate::observation::DynamicCodeSource::SetTimeoutString => "settimeout-string",
crate::observation::DynamicCodeSource::SetIntervalString => "setinterval-string",
crate::observation::DynamicCodeSource::ImportScripts => "importscripts",
};
let node = VulnNode::Capability {
id: format!("dynamic-code-exec-{idx}"),
resource: "code-execution".to_string(),
permission: "execute".to_string(),
target: Some(code_preview.chars().take(100).collect()),
arguments: vec![source_type.to_string()],
duration_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
}
Observation::NetworkRequest {
url,
method,
headers: _,
body: _,
} => {
let node = VulnNode::Capability {
id: format!("network-request-{idx}"),
resource: "network".to_string(),
permission: method.to_lowercase(),
target: Some(url.clone()),
arguments: vec![],
duration_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
}
Observation::ApiCall {
api,
args,
result: _,
} => {
if is_code_execution(api) {
let code_preview = args
.first()
.and_then(|v| v.as_str())
.map(|s| s.chars().take(100).collect())
.unwrap_or_else(|| "<empty>".to_string());
let node = VulnNode::Capability {
id: format!("dynamic-code-exec-{idx}"),
resource: "code-execution".to_string(),
permission: "execute".to_string(),
target: Some(code_preview),
arguments: vec![api.clone()],
duration_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
} else if is_module_import(api) {
let module_name = extract_module_name(api, args);
let node = VulnNode::TrustBoundary {
id: format!("module-import-{idx}"),
description: format!("Module import: {module_name}"),
source_type: "module-import".to_string(),
confidence: ConfidenceValue::from(1.0),
taint_id: None,
created_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
} else if is_filesystem_operation(api) {
let permission = if api.contains("read") || api.contains("readdir") {
"read"
} else if api.contains("write") {
"write"
} else {
"access"
};
let target = args.first().and_then(|v| v.as_str()).map(String::from);
let node = VulnNode::Capability {
id: format!("filesystem-{idx}"),
resource: "filesystem".to_string(),
permission: permission.to_string(),
target,
arguments: vec![api.clone()],
duration_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
} else {
None
}
}
Observation::DomMutation {
kind,
target,
detail,
} => {
let _description = format!("DOM {kind:?} on {target}: {detail}");
let node = VulnNode::Capability {
id: format!("dom-mutation-{idx}"),
resource: "dom-manipulation".to_string(),
permission: "modify".to_string(),
target: Some(target.clone()),
arguments: vec![format!("{kind:?}"), detail.clone()],
duration_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
}
Observation::CookieAccess {
operation,
name,
value: _,
} => {
let description = format!("Cookie {operation:?}: {name}");
let node = VulnNode::TrustBoundary {
id: format!("cookie-access-{idx}"),
description,
source_type: "cookie-access".to_string(),
confidence: ConfidenceValue::from(1.0),
taint_id: None,
created_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
}
Observation::WasmInstantiation {
module_size,
import_names,
export_names,
} => {
let node = VulnNode::Capability {
id: format!("wasm-instantiation-{idx}"),
resource: "webassembly".to_string(),
permission: "instantiate".to_string(),
target: Some(format!("{module_size} bytes")),
arguments: vec![
format!("imports: {:?}", import_names),
format!("exports: {:?}", export_names),
],
duration_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
}
Observation::ContextMessage {
from_context,
to_context,
payload,
} => {
let description = format!("Message from {from_context} to {to_context}: {payload}");
let node = VulnNode::TrustBoundary {
id: format!("context-message-{idx}"),
description,
source_type: "cross-context".to_string(),
confidence: ConfidenceValue::from(1.0),
taint_id: None,
created_ns: None,
provenance: vec![producer.create_provenance(Some(SourceLocation {
file: None,
artifact: None,
line: None,
column: None,
}))],
metadata: Default::default(),
};
Some(graph.add_node(node))
}
_ => None,
}
}
fn create_taint_edges(
graph: &mut VulnIRGraph<VulnNode, VulnEdge>,
observations: &[Observation],
node_indices: &[NodeIndex],
producer: &JsdetProducer,
) {
for obs in observations {
if let Observation::ApiCall { api, args, .. } = obs {
let tainted_positions: Vec<usize> = args
.iter()
.enumerate()
.filter(|(_, v)| is_value_tainted(v))
.map(|(idx, _)| idx)
.collect();
if !tainted_positions.is_empty() && !node_indices.is_empty() {
let evidence = vec![producer.create_evidence(format!(
"Tainted data reached sink '{api}' at argument positions {:?}",
tainted_positions
))];
if node_indices.len() >= 2 {
let source_idx = node_indices[node_indices.len() - 2];
let sink_idx = node_indices[node_indices.len() - 1];
let edge = VulnEdge::TaintReach {
path: format!("{api}<-arg[{tainted_positions:?}]"),
confidence: ConfidenceValue::from(1.0),
observed_dynamically: true,
transform_chain: vec![],
evidence,
};
graph.add_edge(source_idx, sink_idx, edge);
}
}
}
}
}
fn is_value_tainted(value: &Value) -> bool {
match value {
Value::String(_, label) | Value::Json(_, label) => label.is_tainted(),
_ => false,
}
}
fn is_code_execution(api: &str) -> bool {
api == "eval"
|| api == "Function"
|| api == "setTimeout"
|| api == "setInterval"
|| api == "importScripts"
|| api.contains("executeScript")
|| api.contains("execScript")
}
fn is_module_import(api: &str) -> bool {
api == "require"
|| api == "import"
|| api.starts_with("module.require")
|| api.contains("__webpack_require__")
|| api.contains("dynamicImport")
}
fn extract_module_name(api: &str, args: &[Value]) -> String {
if let Some(first_arg) = args.first()
&& let Some(s) = first_arg.as_str()
{
return s.to_string();
}
api.to_string()
}
fn is_filesystem_operation(api: &str) -> bool {
api.starts_with("fs.")
|| api.starts_with("node:fs")
|| api.contains("readFile")
|| api.contains("writeFile")
|| api.contains("readdir")
|| api.contains("unlink")
|| api.contains("mkdir")
|| api.contains("rmdir")
}
pub trait ToVulnIR {
fn to_vulnir_graph(&self) -> VulnIRGraph<VulnNode, VulnEdge>;
fn to_vulnir_graph_with_producer(
&self,
producer: &JsdetProducer,
) -> VulnIRGraph<VulnNode, VulnEdge>;
}
impl ToVulnIR for ExecutionResult {
fn to_vulnir_graph(&self) -> VulnIRGraph<VulnNode, VulnEdge> {
let producer = JsdetProducer::default_producer();
to_vulnir_graph(self, &producer)
}
fn to_vulnir_graph_with_producer(
&self,
producer: &JsdetProducer,
) -> VulnIRGraph<VulnNode, VulnEdge> {
to_vulnir_graph(self, producer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::observation::{DynamicCodeSource, Observation, TaintLabel, Value};
use vulnir::VulnIRGraphExt;
#[test]
fn eval_produces_capability_node() {
let result = ExecutionResult {
observations: vec![Observation::DynamicCodeExec {
source: DynamicCodeSource::Eval,
code_preview: "console.log('test')".to_string(),
}],
scripts_executed: 1,
errors: vec![],
duration_us: 1000,
timed_out: false,
};
let graph = result.to_vulnir_graph();
assert_eq!(graph.node_count(), 1);
assert_eq!(graph.edge_count(), 0);
let attack_surface = graph.attack_surface();
assert!(attack_surface.is_empty()); }
#[test]
fn require_produces_trust_boundary_node() {
let result = ExecutionResult {
observations: vec![Observation::ApiCall {
api: "require".to_string(),
args: vec![Value::string("fs")],
result: Value::Null,
}],
scripts_executed: 1,
errors: vec![],
duration_us: 1000,
timed_out: false,
};
let graph = result.to_vulnir_graph();
assert_eq!(graph.node_count(), 1);
assert_eq!(graph.edge_count(), 0);
let attack_surface = graph.attack_surface();
assert_eq!(attack_surface.len(), 1);
}
#[test]
fn network_and_eval_produces_taint_reach_edge() {
let result = ExecutionResult {
observations: vec![
Observation::NetworkRequest {
url: "https://evil.com/payload".to_string(),
method: "GET".to_string(),
headers: vec![],
body: None,
},
Observation::ApiCall {
api: "eval".to_string(),
args: vec![Value::tainted_string(
"console.log('pwned')",
TaintLabel::new(1),
)],
result: Value::Null,
},
],
scripts_executed: 1,
errors: vec![],
duration_us: 1000,
timed_out: false,
};
let graph = result.to_vulnir_graph();
assert_eq!(graph.node_count(), 2);
assert_eq!(graph.edge_count(), 1);
}
#[test]
fn empty_result_produces_empty_graph() {
let result = ExecutionResult {
observations: vec![],
scripts_executed: 0,
errors: vec![],
duration_us: 0,
timed_out: false,
};
let graph = result.to_vulnir_graph();
assert_eq!(graph.node_count(), 0);
assert_eq!(graph.edge_count(), 0);
}
#[test]
fn filesystem_operations_produce_capability_nodes() {
let result = ExecutionResult {
observations: vec![Observation::ApiCall {
api: "fs.readFileSync".to_string(),
args: vec![Value::string("/etc/passwd")],
result: Value::Null,
}],
scripts_executed: 1,
errors: vec![],
duration_us: 1000,
timed_out: false,
};
let graph = result.to_vulnir_graph();
assert_eq!(graph.node_count(), 1);
let caps = graph.reachable_capabilities(NodeIndex::new(0));
assert_eq!(caps.len(), 1);
}
#[test]
fn jsdet_producer_trait_implementation() {
let producer = JsdetProducer::new("test-producer", "1.0.0");
let p = producer.producer();
assert_eq!(p.name, "test-producer");
assert_eq!(p.version, Some("1.0.0".to_string()));
assert_eq!(p.kind, ProducerKind::Dynamic);
}
#[test]
fn custom_producer_used_in_graph() {
let result = ExecutionResult {
observations: vec![Observation::DynamicCodeExec {
source: DynamicCodeSource::Function,
code_preview: "return 1".to_string(),
}],
scripts_executed: 1,
errors: vec![],
duration_us: 1000,
timed_out: false,
};
let producer = JsdetProducer::new("custom-scanner", "2.0.0");
let graph = result.to_vulnir_graph_with_producer(&producer);
assert_eq!(graph.node_count(), 1);
}
}