Skip to main content

document_svg/document/
stix.rs

1//! Bounded OASIS STIX 2.1 JSON threat-intelligence previews.
2//!
3//! STIX bundles contain domain objects, cyber-observable objects and relationship
4//! objects. This adapter renders object type/id and safe structural metadata;
5//! descriptions, indicator patterns, hashes, URLs, labels and reference payloads
6//! remain inert and no TAXII/API/network operation is performed.
7
8use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_STIX_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_STIX_DEPTH: usize = 100;
18const MAX_STIX_VALUES: usize = 300_000;
19const MAX_STIX_OBJECTS: usize = 100_000;
20const MAX_STIX_ROWS: usize = 200_000;
21const MAX_STIX_STRING_BYTES: usize = 2 * 1024 * 1024;
22const MAX_STIX_DISPLAY_BYTES: usize = 256;
23
24pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
25    let text = String::from_utf8_lossy(prefix);
26    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
27    if !trimmed.starts_with('{') {
28        return false;
29    }
30    if let Ok(Value::Object(object)) = serde_json::from_str::<Value>(trimmed) {
31        let Some(kind) = object.get("type").and_then(Value::as_str) else {
32            return false;
33        };
34        if kind == "bundle" {
35            return object.get("objects").and_then(Value::as_array).is_some();
36        }
37        let canonical_id = object
38            .get("id")
39            .and_then(Value::as_str)
40            .is_some_and(|id| id.contains("--"));
41        return canonical_id
42            && (object.get("spec_version").is_some()
43                || object.get("created").is_some()
44                || object.get("modified").is_some());
45    }
46    text.contains("\"type\"")
47        && text.contains("\"id\"")
48        && text.contains("--")
49        && (text.contains("\"spec_version\"") || text.contains("\"created\""))
50}
51
52struct StixPageSink<'a> {
53    inner: &'a mut dyn PageConsumer,
54    warnings: &'a [String],
55}
56
57impl PageConsumer for StixPageSink<'_> {
58    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
59        page.source_format = "stix-json".into();
60        if page.title.is_empty() {
61            page.title = "STIX 2.1 JSON".into();
62        }
63        page.description =
64            "STIX threat-intelligence object metadata is rendered inertly; patterns, payloads, references and network operations are not displayed or resolved".into();
65        for warning in self.warnings {
66            page.warn(warning.clone());
67        }
68        self.inner.consume(page)
69    }
70}
71
72pub(crate) fn convert(
73    path: &Path,
74    options: &ConvertOptions,
75    sink: &mut dyn PageConsumer,
76) -> Result<Vec<String>> {
77    let bytes = read_limited_file(
78        path,
79        options.max_input_bytes.min(MAX_STIX_BYTES),
80        "STIX JSON input",
81    )?;
82    let text = String::from_utf8(bytes)
83        .map_err(|error| Error::InvalidInput(format!("STIX JSON must be UTF-8 JSON: {error}")))?;
84    let (table, metadata, warnings) = parse(&text)?;
85    let blocks = vec![
86        HtmlBlock::Heading {
87            level: 1,
88            text: "STIX 2.1 JSON".into(),
89        },
90        HtmlBlock::Paragraph { text: metadata },
91        HtmlBlock::Table(table),
92    ];
93    let mut page_sink = StixPageSink {
94        inner: sink,
95        warnings: &warnings,
96    };
97    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
98    Ok(warnings)
99}
100
101#[derive(Default)]
102struct Summary {
103    rows: Vec<Vec<String>>,
104    objects: usize,
105    relationships: usize,
106    labels: usize,
107    refs: usize,
108    indicators: usize,
109}
110
111fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
112    if text.len() as u64 > MAX_STIX_BYTES {
113        return Err(Error::LimitExceeded(format!(
114            "STIX JSON exceeds {MAX_STIX_BYTES} bytes"
115        )));
116    }
117    preflight_depth(text)?;
118    let value: Value = serde_json::from_str(text)
119        .map_err(|error| Error::InvalidInput(format!("invalid STIX JSON: {error}")))?;
120    let mut values = 0usize;
121    count_values(&value, 0, &mut values)?;
122    let root = value
123        .as_object()
124        .ok_or_else(|| Error::InvalidInput("STIX JSON root must be an object".into()))?;
125    let mut summary = Summary::default();
126    let mut warnings = Vec::new();
127    let root_type = root
128        .get("type")
129        .and_then(Value::as_str)
130        .ok_or_else(|| Error::InvalidInput("STIX object requires a string type".into()))?;
131    if root_type == "bundle" {
132        let objects = root
133            .get("objects")
134            .and_then(Value::as_array)
135            .ok_or_else(|| Error::InvalidInput("STIX bundle requires an objects array".into()))?;
136        if objects.len() > MAX_STIX_OBJECTS {
137            return Err(Error::LimitExceeded(format!(
138                "STIX objects exceed {MAX_STIX_OBJECTS}"
139            )));
140        }
141        for object in objects {
142            add_object(object, &mut summary)?;
143        }
144    } else {
145        add_object(&value, &mut summary)?;
146    }
147    if summary.rows.is_empty() {
148        return Err(Error::InvalidInput(
149            "STIX JSON contains no object rows".into(),
150        ));
151    }
152    if summary.indicators > 0 {
153        warnings.push(
154            "STIX indicator pattern text is omitted and never evaluated as a detection query"
155                .into(),
156        );
157    }
158    warnings.push("STIX descriptions, labels, hashes, URLs, pattern payloads, reference values, marking content and custom properties are omitted; no TAXII/API/network operation runs".into());
159    warnings.push("STIX object IDs are shown as inert identifiers; relationship and reference counts are not dereferenced or semantically validated".into());
160    let metadata = format!(
161        "Objects: {}\nRelationships: {}\nLabels: {}\nReference arrays: {}\nIndicators: {}\nRoot: {}",
162        summary.objects,
163        summary.relationships,
164        summary.labels,
165        summary.refs,
166        summary.indicators,
167        root_type
168    );
169    Ok((
170        TableData {
171            headers: vec![
172                "Type".into(),
173                "ID".into(),
174                "Created / modified".into(),
175                "Labels".into(),
176                "Structure".into(),
177            ],
178            rows: summary.rows,
179            alignments: vec![TableAlign::Left; 5],
180            raw_source: String::new(),
181        },
182        metadata,
183        warnings,
184    ))
185}
186
187fn add_object(value: &Value, summary: &mut Summary) -> Result<()> {
188    let object = value
189        .as_object()
190        .ok_or_else(|| Error::InvalidInput("STIX object entry must be an object".into()))?;
191    let kind = required_string(object, "type")?;
192    let id = required_string(object, "id")?;
193    let created = object.get("created").and_then(Value::as_str).unwrap_or("—");
194    let modified = object
195        .get("modified")
196        .and_then(Value::as_str)
197        .unwrap_or("—");
198    let labels = object
199        .get("labels")
200        .and_then(Value::as_array)
201        .map_or(0, Vec::len);
202    let relationships = usize::from(kind == "relationship")
203        + object.get("objects").map_or(0, count_reference_array)
204        + object.get("object_refs").map_or(0, count_reference_array)
205        + object.get("created_by_ref").map_or(0, |_| 1);
206    let refs = object
207        .iter()
208        .filter(|(key, value)| {
209            key.ends_with("_ref")
210                || key.ends_with("_refs")
211                || value.is_array() && key.contains("refs")
212        })
213        .map(|(_, value)| {
214            if value.is_array() {
215                value.as_array().map_or(0, Vec::len)
216            } else {
217                1
218            }
219        })
220        .sum::<usize>();
221    let structure = format!("refs {refs} · properties omitted");
222    summary.objects = summary.objects.saturating_add(1);
223    summary.relationships = summary.relationships.saturating_add(relationships);
224    summary.labels = summary.labels.saturating_add(labels);
225    summary.refs = summary.refs.saturating_add(refs);
226    if kind == "indicator" {
227        summary.indicators = summary.indicators.saturating_add(1);
228    }
229    if summary.rows.len() >= MAX_STIX_ROWS {
230        return Err(Error::LimitExceeded(format!(
231            "STIX rows exceed {MAX_STIX_ROWS}"
232        )));
233    }
234    summary.rows.push(vec![
235        truncate(kind),
236        truncate(id),
237        format!("{} / {}", truncate(created), truncate(modified)),
238        labels.to_string(),
239        structure,
240    ]);
241    Ok(())
242}
243
244fn count_reference_array(value: &Value) -> usize {
245    value.as_array().map_or(0, Vec::len)
246}
247
248fn required_string<'a>(object: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a str> {
249    let value = object
250        .get(key)
251        .and_then(Value::as_str)
252        .ok_or_else(|| Error::InvalidInput(format!("STIX object requires string {key}")))?;
253    if value.is_empty() {
254        return Err(Error::InvalidInput(format!("STIX {key} must not be empty")));
255    }
256    Ok(value)
257}
258
259fn truncate(value: &str) -> String {
260    if value.len() <= MAX_STIX_DISPLAY_BYTES {
261        return value.to_owned();
262    }
263    let mut end = MAX_STIX_DISPLAY_BYTES;
264    while !value.is_char_boundary(end) {
265        end -= 1;
266    }
267    format!("{}…", &value[..end])
268}
269
270fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
271    if depth > MAX_STIX_DEPTH {
272        return Err(Error::LimitExceeded(format!(
273            "STIX JSON nesting exceeds {MAX_STIX_DEPTH} levels"
274        )));
275    }
276    *count = count.saturating_add(1);
277    if *count > MAX_STIX_VALUES {
278        return Err(Error::LimitExceeded(format!(
279            "STIX JSON contains more than {MAX_STIX_VALUES} values"
280        )));
281    }
282    match value {
283        Value::Array(values) => {
284            for item in values {
285                count_values(item, depth + 1, count)?;
286            }
287        }
288        Value::Object(map) => {
289            for item in map.values() {
290                count_values(item, depth + 1, count)?;
291            }
292        }
293        Value::String(value) if value.len() > MAX_STIX_STRING_BYTES => {
294            return Err(Error::LimitExceeded(format!(
295                "STIX JSON string exceeds {MAX_STIX_STRING_BYTES} bytes"
296            )));
297        }
298        _ => {}
299    }
300    Ok(())
301}
302
303fn preflight_depth(text: &str) -> Result<()> {
304    let mut depth = 0usize;
305    let mut quoted = false;
306    let mut escaped = false;
307    for byte in text.bytes() {
308        if quoted {
309            if escaped {
310                escaped = false;
311            } else if byte == b'\\' {
312                escaped = true;
313            } else if byte == b'"' {
314                quoted = false;
315            }
316            continue;
317        }
318        match byte {
319            b'"' => quoted = true,
320            b'{' | b'[' => {
321                depth += 1;
322                if depth > MAX_STIX_DEPTH {
323                    return Err(Error::LimitExceeded(format!(
324                        "STIX JSON nesting exceeds {MAX_STIX_DEPTH} levels"
325                    )));
326                }
327            }
328            b'}' | b']' => depth = depth.saturating_sub(1),
329            _ => {}
330        }
331    }
332    Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn recognizes_stix_bundle_and_object() {
341        assert!(looks_like_prefix(
342            br#"{"type":"bundle","id":"bundle--1","objects":[]}"#
343        ));
344        assert!(looks_like_prefix(
345            br#"{"type":"indicator","id":"indicator--1","spec_version":"2.1"}"#
346        ));
347        assert!(!looks_like_prefix(br#"{"type":"thing"}"#));
348    }
349
350    #[test]
351    fn summarizes_bundle_without_indicator_pattern_or_description() {
352        let (table, metadata, warnings) = parse(
353            r#"{"type":"bundle","id":"bundle--1","objects":[{"type":"indicator","id":"indicator--1","created":"2024-01-01T00:00:00Z","modified":"2024-01-02T00:00:00Z","labels":["malicious"],"pattern":"[file:hashes.MD5 = 'secret']","description":"private description"},{"type":"relationship","id":"relationship--1","created":"2024-01-01T00:00:00Z","modified":"2024-01-01T00:00:00Z","source_ref":"indicator--1","target_ref":"file--1"}]}"#,
354        ).unwrap();
355        assert_eq!(table.rows.len(), 2);
356        assert!(metadata.contains("Indicators: 1"));
357        assert!(
358            !table
359                .rows
360                .iter()
361                .flatten()
362                .any(|value| value.contains("secret") || value.contains("malicious"))
363        );
364        assert!(warnings.iter().any(|warning| warning.contains("pattern")));
365    }
366}