1use crate::error::Result;
18use crate::result::PluginResult;
19use serde::{Deserialize, Serialize};
20
21pub mod builder;
22
23pub use builder::{ReportBuilder, SectionBuilder};
24
25pub const SCHEMA_VERSION: u32 = 1;
27
28pub use malbox_plugin_transport::REPORT_RESULT_NAME;
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Report {
41 pub schema_version: u32,
43 pub plugin: PluginInfo,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub verdict: Option<Verdict>,
49
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub indicators: Vec<Indicator>,
53
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub ttps: Vec<Ttp>,
57
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub artifacts: Vec<ArtifactRef>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub summary: Option<String>,
65
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub sections: Vec<Section>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub raw: Option<serde_json::Value>,
73}
74
75impl Report {
76 pub fn into_plugin_result(self) -> Result<PluginResult> {
79 PluginResult::json(REPORT_RESULT_NAME, &self)
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct PluginInfo {
86 pub id: String,
88 pub version: String,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub display_name: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Verdict {
98 pub classification: Classification,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub score: Option<u8>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub confidence: Option<Confidence>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub labels: Vec<String>,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum Classification {
118 Clean,
120 Suspicious,
122 Malicious,
124 Unknown,
126}
127
128impl Classification {
129 pub fn severity(self) -> u8 {
131 match self {
132 Classification::Clean => 0,
133 Classification::Unknown => 1,
134 Classification::Suspicious => 2,
135 Classification::Malicious => 3,
136 }
137 }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum Confidence {
144 Low,
146 Medium,
148 High,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct Indicator {
158 pub kind: String,
160 pub value: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub context: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub first_seen: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
173pub struct Ttp {
174 pub id: String,
176 pub name: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub evidence: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ArtifactRef {
188 pub result_name: String,
190 pub kind: String,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub description: Option<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Section {
201 pub id: String,
203 pub title: String,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub blocks: Vec<Block>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type", rename_all = "snake_case")]
214pub enum Block {
215 Markdown { text: String },
217 Callout { level: CalloutLevel, text: String },
219 Heading { level: u8, text: String },
221 Divider,
223 Kv { pairs: Vec<KvPair> },
225 Table {
227 columns: Vec<Column>,
228 rows: Vec<serde_json::Value>,
229 #[serde(default)]
230 sortable: bool,
231 #[serde(default)]
232 searchable: bool,
233 },
234 Code { language: String, text: String },
236 Json {
238 data: serde_json::Value,
239 #[serde(default)]
240 collapsed: bool,
241 },
242 Hex {
244 bytes_b64: String,
245 #[serde(default)]
246 offset: u64,
247 },
248 Image {
250 artifact: String,
251 #[serde(skip_serializing_if = "Option::is_none")]
252 caption: Option<String>,
253 },
254 Download { artifact: String, label: String },
256 Iocs { items: Vec<Indicator> },
258 Ttps { items: Vec<Ttp> },
260 Tree { nodes: Vec<TreeNode> },
262 Timeline { events: Vec<TimelineEvent> },
264 Graph {
266 nodes: Vec<GraphNode>,
267 edges: Vec<GraphEdge>,
268 },
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case")]
274pub enum CalloutLevel {
275 Info,
277 Success,
279 Warn,
281 Error,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct KvPair {
288 pub key: String,
290 pub value: String,
292 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
294 pub mono: bool,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct Column {
300 pub key: String,
302 pub label: String,
304 #[serde(default = "default_column_type")]
306 pub r#type: String,
307}
308
309fn default_column_type() -> String {
310 "string".to_string()
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct TreeNode {
316 pub label: String,
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
320 pub children: Vec<TreeNode>,
321 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
323 pub meta: serde_json::Value,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct TimelineEvent {
329 pub ts: String,
331 pub label: String,
333 #[serde(skip_serializing_if = "Option::is_none")]
335 pub severity: Option<String>,
336 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
338 pub meta: serde_json::Value,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct GraphNode {
344 pub id: String,
346 pub label: String,
348 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
350 pub meta: serde_json::Value,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct GraphEdge {
356 pub from: String,
358 pub to: String,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub label: Option<String>,
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use serde_json::json;
369
370 fn minimal_report() -> Report {
371 Report {
372 schema_version: SCHEMA_VERSION,
373 plugin: PluginInfo {
374 id: "yara".into(),
375 version: "1.0.0".into(),
376 display_name: None,
377 },
378 verdict: None,
379 indicators: vec![],
380 ttps: vec![],
381 artifacts: vec![],
382 summary: None,
383 sections: vec![],
384 raw: None,
385 }
386 }
387
388 #[test]
389 fn minimal_report_omits_optional_fields() {
390 let v = serde_json::to_value(minimal_report()).unwrap();
391 assert_eq!(v["schema_version"], 1);
392 assert_eq!(v["plugin"]["id"], "yara");
393 assert!(v.get("verdict").is_none());
395 assert!(v.get("summary").is_none());
396 assert!(v.get("indicators").is_none());
397 assert!(v.get("ttps").is_none());
398 assert!(v.get("sections").is_none());
399 assert!(v.get("raw").is_none());
400 assert!(v["plugin"].get("display_name").is_none());
402 }
403
404 #[test]
405 fn classification_serializes_snake_case() {
406 let v = serde_json::to_value(Classification::Malicious).unwrap();
407 assert_eq!(v, json!("malicious"));
408 }
409
410 #[test]
411 fn classification_severity_orders_correctly() {
412 assert!(Classification::Malicious.severity() > Classification::Suspicious.severity());
413 assert!(Classification::Suspicious.severity() > Classification::Unknown.severity());
414 assert!(Classification::Unknown.severity() > Classification::Clean.severity());
415 }
416
417 #[test]
418 fn block_is_tagged_with_type() {
419 let b = Block::Heading {
420 level: 2,
421 text: "Matches".into(),
422 };
423 let v = serde_json::to_value(&b).unwrap();
424 assert_eq!(v["type"], "heading");
425 assert_eq!(v["level"], 2);
426 assert_eq!(v["text"], "Matches");
427 }
428
429 #[test]
430 fn divider_block_has_no_extra_fields() {
431 let v = serde_json::to_value(Block::Divider).unwrap();
432 assert_eq!(v, json!({"type": "divider"}));
433 }
434
435 #[test]
436 fn full_report_round_trips() {
437 let r = Report {
438 schema_version: SCHEMA_VERSION,
439 plugin: PluginInfo {
440 id: "yara".into(),
441 version: "1.0.0".into(),
442 display_name: Some("YARA Scanner".into()),
443 },
444 verdict: Some(Verdict {
445 classification: Classification::Malicious,
446 score: Some(87),
447 confidence: Some(Confidence::High),
448 labels: vec!["trojan".into()],
449 }),
450 indicators: vec![Indicator {
451 kind: "sha256".into(),
452 value: "deadbeef".into(),
453 context: Some("sample".into()),
454 first_seen: None,
455 }],
456 ttps: vec![Ttp {
457 id: "T1055".into(),
458 name: "Process Injection".into(),
459 evidence: None,
460 }],
461 artifacts: vec![ArtifactRef {
462 result_name: "details.json".into(),
463 kind: "other".into(),
464 description: None,
465 }],
466 summary: Some("Matched 1 rule".into()),
467 sections: vec![Section {
468 id: "matches".into(),
469 title: "Matches".into(),
470 blocks: vec![
471 Block::Markdown {
472 text: "Details".into(),
473 },
474 Block::Table {
475 columns: vec![Column {
476 key: "rule".into(),
477 label: "Rule".into(),
478 r#type: "string".into(),
479 }],
480 rows: vec![json!({"rule": "r1"})],
481 sortable: true,
482 searchable: false,
483 },
484 ],
485 }],
486 raw: Some(json!({"native": 1})),
487 };
488
489 let s = serde_json::to_string(&r).unwrap();
490 let parsed: Report = serde_json::from_str(&s).unwrap();
491 assert_eq!(parsed.schema_version, SCHEMA_VERSION);
492 assert_eq!(
493 parsed.verdict.as_ref().unwrap().classification,
494 Classification::Malicious
495 );
496 assert_eq!(parsed.indicators.len(), 1);
497 assert_eq!(parsed.ttps[0].id, "T1055");
498 assert_eq!(parsed.sections[0].blocks.len(), 2);
499 }
500
501 const PARITY_GOLDEN: &str = concat!(
506 "{",
507 "\"schema_version\":1,",
508 "\"plugin\":{\"id\":\"yara\",\"version\":\"1.0.0\",\"display_name\":\"YARA Scanner\"},",
509 "\"verdict\":{\"classification\":\"malicious\",\"score\":87,\"confidence\":\"high\",\"labels\":[\"trojan\"]},",
510 "\"indicators\":[{\"kind\":\"sha256\",\"value\":\"abc\",\"context\":\"sample\"}],",
511 "\"ttps\":[{\"id\":\"T1055\",\"name\":\"Process Injection\"}],",
512 "\"artifacts\":[{\"result_name\":\"details.json\",\"kind\":\"other\"}],",
513 "\"summary\":\"Matched 1 rule\",",
514 "\"sections\":[{\"id\":\"overview\",\"title\":\"Overview\",\"blocks\":[",
515 "{\"type\":\"heading\",\"level\":2,\"text\":\"Rules\"},",
516 "{\"type\":\"markdown\",\"text\":\"1 match\"},",
517 "{\"type\":\"divider\"}",
518 "]}]",
519 "}"
520 );
521
522 #[test]
523 fn parity_golden_matches_rust_serialization() {
524 let r = ReportBuilder::new("yara", "1.0.0")
526 .display_name("YARA Scanner")
527 .summary("Matched 1 rule")
528 .verdict(Classification::Malicious, Some(87), Some(Confidence::High))
529 .labels(["trojan"])
530 .indicator(Indicator::new("sha256", "abc").context("sample"))
531 .ttp(Ttp::new("T1055", "Process Injection"))
532 .artifact(ArtifactRef::new("details.json", "other"))
533 .section("overview", "Overview", |s| {
534 s.heading(2, "Rules").markdown("1 match").divider()
535 })
536 .build();
537
538 let got = serde_json::to_string(&r).unwrap();
539 assert_eq!(
540 got, PARITY_GOLDEN,
541 "Rust Report serialization drifted from parity golden.\n\
542 If this is intentional, update BOTH:\n\
543 - `PARITY_GOLDEN` in this file\n\
544 - `GOLDEN_JSON` in malbox-plugin-sdk-cpp/tests/cpp/test_report.cpp"
545 );
546 }
547
548 #[test]
549 fn into_plugin_result_uses_well_known_name() {
550 let r = minimal_report();
551 let pr = r.into_plugin_result().expect("serialize");
552 match pr {
553 PluginResult::Json { name, data } => {
554 assert_eq!(name, REPORT_RESULT_NAME);
555 let parsed: serde_json::Value = serde_json::from_slice(&data).unwrap();
556 assert_eq!(parsed["schema_version"], 1);
557 }
558 _ => panic!("expected Json variant"),
559 }
560 }
561}