use std::collections::BTreeMap;
use std::path::Path;
use chrono::{DateTime, Utc};
use droidsaw_common::threat_model::{Signal, SinkKind};
use serde::{Deserialize, Serialize};
use super::{Result, ThreatModelError};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StixBundle {
pub id: String,
pub objects: Vec<StixObject>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StixObject {
Indicator(StixIndicator),
Other(serde_json::Value),
}
impl Serialize for StixObject {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
match self {
StixObject::Indicator(ind) => ind.serialize(s),
StixObject::Other(v) => v.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for StixObject {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
if ty == "indicator" {
let ind: StixIndicator = serde_json::from_value(v).map_err(serde::de::Error::custom)?;
Ok(StixObject::Indicator(ind))
} else {
Ok(StixObject::Other(v))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StixIndicator {
pub id: String,
pub spec_version: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub indicator_types: Vec<String>,
pub pattern: String,
pub pattern_type: String,
pub valid_from: DateTime<Utc>,
#[serde(default)]
pub valid_until: Option<DateTime<Utc>>,
#[serde(default)]
pub confidence: Option<u8>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub external_references: Vec<StixExternalReference>,
#[serde(flatten)]
pub extensions: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StixExternalReference {
pub source_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub external_id: Option<String>,
}
pub fn load_bundle(path: &Path) -> Result<StixBundle> {
let bytes = std::fs::read(path)?;
let bundle: StixBundle =
serde_json::from_slice(&bytes).map_err(|source| ThreatModelError::StixParse {
path: path.to_path_buf(),
source,
})?;
Ok(bundle)
}
pub fn indicator_to_signals(ind: &StixIndicator) -> Vec<Signal> {
if ind.pattern_type != "x-droidsaw-pattern" {
return Vec::new();
}
let Ok(parsed) = serde_json::from_str::<DroidsawPattern>(&ind.pattern) else {
return Vec::new();
};
parsed
.indicators
.into_iter()
.map(|p| Signal {
source_api: format!("{}:{}", p.kind, p.value),
source_api_args: None,
sink_kind: SinkKind::Other,
resolution: droidsaw_common::threat_model::Resolution::None,
completeness: droidsaw_common::threat_model::Completeness::default(),
adversary_profile_relevance: Vec::new(),
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct DroidsawPattern {
indicators: Vec<DroidsawPatternIndicator>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct DroidsawPatternIndicator {
#[serde(rename = "type")]
kind: String,
value: String,
#[serde(default, rename = "match")]
_match_mode: Option<String>,
}
pub fn load_indicators_dedup(paths: &[std::path::PathBuf]) -> Result<Vec<StixIndicator>> {
let mut seen: BTreeMap<String, StixIndicator> = BTreeMap::new();
for path in paths {
let bundle = load_bundle(path)?;
for obj in bundle.objects {
if let StixObject::Indicator(ind) = obj {
seen.entry(ind.id.clone()).or_insert(ind);
}
}
}
Ok(seen.into_values().collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_bundle_json() -> &'static str {
r#"{
"type": "bundle",
"id": "bundle--00000000-0000-7000-8000-000000000001",
"objects": [
{
"type": "indicator",
"spec_version": "2.1",
"id": "indicator--019dbba3-c987-7353-be7d-592937bd75ac",
"created": "2026-04-23T00:00:00Z",
"modified": "2026-04-23T00:00:00Z",
"name": "Mixpanel",
"description": "Mixpanel SDK.",
"indicator_types": ["malicious-activity"],
"pattern_type": "x-droidsaw-pattern",
"pattern": "{\"indicators\":[{\"type\":\"android_package_prefix\",\"value\":\"com.mixpanel.android.\",\"match\":\"literal_prefix\"}]}",
"valid_from": "2012-01-01T00:00:00Z",
"confidence": 98,
"labels": ["category/analytics"],
"x_droidsaw_kind": "tracker",
"x_droidsaw_for_detection": true
},
{
"type": "marking-definition",
"id": "marking-definition--abc",
"definition_type": "tlp",
"definition": {"tlp": "white"}
}
]
}"#
}
#[test]
fn parses_indicator_and_carries_unknown_object_to_other() {
let bundle: StixBundle = serde_json::from_str(sample_bundle_json()).expect("parse");
assert_eq!(bundle.id, "bundle--00000000-0000-7000-8000-000000000001");
assert_eq!(bundle.objects.len(), 2);
match &bundle.objects[0] {
StixObject::Indicator(ind) => {
assert_eq!(ind.id, "indicator--019dbba3-c987-7353-be7d-592937bd75ac");
assert_eq!(ind.spec_version, "2.1");
assert_eq!(ind.name.as_deref(), Some("Mixpanel"));
assert_eq!(ind.confidence, Some(98));
assert_eq!(ind.pattern_type, "x-droidsaw-pattern");
}
other => panic!("expected Indicator, got {other:?}"),
}
match &bundle.objects[1] {
StixObject::Other(v) => {
assert_eq!(v.get("type").and_then(|t| t.as_str()), Some("marking-definition"));
}
other => panic!("expected Other(marking-definition), got {other:?}"),
}
}
#[test]
fn extensions_round_trip_unknown_x_fields() {
let bundle: StixBundle = serde_json::from_str(sample_bundle_json()).expect("parse");
let StixObject::Indicator(ind) = &bundle.objects[0] else {
panic!("expected Indicator");
};
assert_eq!(
ind.extensions.get("x_droidsaw_kind").and_then(|v| v.as_str()),
Some("tracker")
);
assert_eq!(
ind.extensions.get("x_droidsaw_for_detection").and_then(|v| v.as_bool()),
Some(true)
);
}
#[test]
fn malformed_json_returns_typed_stixparse_err() {
let bytes = b"{not json";
let err: super::super::ThreatModelError = serde_json::from_slice::<StixBundle>(bytes)
.map_err(|source| super::super::ThreatModelError::StixParse {
path: std::path::PathBuf::from("/dev/null"),
source,
})
.expect_err("malformed JSON must Err");
match err {
super::super::ThreatModelError::StixParse { source, .. } => {
assert!(!source.to_string().is_empty())
}
other => panic!("expected StixParse, got {other:?}"),
}
}
#[test]
fn indicator_to_signals_decodes_droidsaw_pattern() {
let bundle: StixBundle = serde_json::from_str(sample_bundle_json()).expect("parse");
let StixObject::Indicator(ind) = &bundle.objects[0] else {
panic!("expected Indicator");
};
let signals = indicator_to_signals(ind);
assert_eq!(signals.len(), 1);
let s = &signals[0];
assert_eq!(s.source_api, "android_package_prefix:com.mixpanel.android.");
}
#[test]
fn indicator_to_signals_returns_empty_for_unsupported_pattern_type() {
let mut ind: StixIndicator = serde_json::from_str(
r#"{
"type": "indicator",
"spec_version": "2.1",
"id": "indicator--xyz",
"pattern": "[ipv4-addr:value = '1.2.3.4']",
"pattern_type": "stix",
"valid_from": "2026-01-01T00:00:00Z"
}"#,
)
.expect("parse");
ind.pattern_type = "stix".to_string();
let signals = indicator_to_signals(&ind);
assert!(signals.is_empty());
}
#[test]
fn round_trip_preserves_extensions() {
let bundle1: StixBundle = serde_json::from_str(sample_bundle_json()).expect("parse 1");
let json2 = serde_json::to_string(&bundle1).expect("serialize");
let bundle2: StixBundle = serde_json::from_str(&json2).expect("parse 2");
assert_eq!(bundle1, bundle2, "round-trip must preserve every field");
}
}