1use 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}