Skip to main content

malbox_plugin_sdk/
report.rs

1//! The `Report` envelope - a structured, frontend-renderable result.
2//!
3//! Plugins produce one `PluginResult::Json { name: "report", ... }` per task
4//! containing a [`Report`]. The scheduler tags that row with `role='report'`
5//! in the `task_results` table and the API aggregates it into the task view.
6//!
7//! The envelope has two layers:
8//!
9//! * A **semantic layer** (`verdict`, `indicators`, `ttps`, `artifacts`) the
10//!   platform understands and can query across tasks.
11//! * A **presentation layer** (`sections` of typed [`Block`]s) the frontend
12//!   renders generically. Unknown block types degrade to a JSON tree view on
13//!   the client, so adding new variants is never a breaking change.
14//!
15//! See [`builder`] for the ergonomic [`ReportBuilder`] API.
16
17use crate::error::Result;
18use crate::result::PluginResult;
19use serde::{Deserialize, Serialize};
20
21pub mod builder;
22
23pub use builder::{ReportBuilder, SectionBuilder};
24
25/// Current schema version of the report envelope.
26pub const SCHEMA_VERSION: u32 = 1;
27
28/// The well-known `result_name` the scheduler and API use to identify a
29/// report envelope among a task's outputs. Defined in `malbox-plugin-transport`
30/// so the SDK and scheduler share the same constant without a direct dep.
31pub use malbox_plugin_transport::REPORT_RESULT_NAME;
32
33/// A plugin's structured analysis result for a single task.
34///
35/// Reports are the primary way plugins communicate findings to the
36/// frontend. Build one with [`ReportBuilder`], then call
37/// [`Report::into_plugin_result`] to turn it into a [`PluginResult`]
38/// ready for [`ResultSink::push`](crate::context::ResultSink::push).
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Report {
41    /// Schema version for forward-compatible deserialization.
42    pub schema_version: u32,
43    /// Identity of the plugin that produced this report.
44    pub plugin: PluginInfo,
45
46    /// Overall verdict (classification, score, confidence, labels).
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub verdict: Option<Verdict>,
49
50    /// Indicators of compromise extracted during analysis.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub indicators: Vec<Indicator>,
53
54    /// MITRE ATT&CK techniques observed during analysis.
55    #[serde(default, skip_serializing_if = "Vec::is_empty")]
56    pub ttps: Vec<Ttp>,
57
58    /// References to sibling [`PluginResult`]s (files, captures, etc.).
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub artifacts: Vec<ArtifactRef>,
61
62    /// Short human-readable summary of the analysis findings.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub summary: Option<String>,
65
66    /// Presentation sections rendered by the frontend.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub sections: Vec<Section>,
69
70    /// Escape hatch for plugin-native JSON that doesn't fit the typed schema.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub raw: Option<serde_json::Value>,
73}
74
75impl Report {
76    /// Serialize this report into a [`PluginResult::Json`] with the
77    /// well-known [`REPORT_RESULT_NAME`] name, ready for `ctx.push_result`.
78    pub fn into_plugin_result(self) -> Result<PluginResult> {
79        PluginResult::json(REPORT_RESULT_NAME, &self)
80    }
81}
82
83/// Identity of the plugin that produced a report.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct PluginInfo {
86    /// Unique plugin identifier (usually the crate name).
87    pub id: String,
88    /// SemVer version of the plugin binary.
89    pub version: String,
90    /// Optional human-friendly name shown in the frontend.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub display_name: Option<String>,
93}
94
95/// The plugin's overall assessment of the analyzed sample.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Verdict {
98    /// Threat classification (clean, suspicious, malicious, unknown).
99    pub classification: Classification,
100    /// Optional numeric score (0-100). Not all plugins produce a score.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub score: Option<u8>,
103    /// How confident the plugin is in its classification.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub confidence: Option<Confidence>,
106    /// Free-form tags describing the threat (e.g. `"trojan"`, `"ransomware"`).
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub labels: Vec<String>,
109}
110
111/// Threat classification assigned by a plugin's verdict.
112///
113/// When multiple plugins produce verdicts, the daemon aggregates them
114/// using [`Classification::severity`] - worst wins.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum Classification {
118    /// The sample appears safe.
119    Clean,
120    /// The sample shows potentially harmful behavior but is not conclusive.
121    Suspicious,
122    /// The sample is confirmed malicious.
123    Malicious,
124    /// The plugin could not determine a classification.
125    Unknown,
126}
127
128impl Classification {
129    /// Precedence for aggregating plugin verdicts: worst wins.
130    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/// How confident a plugin is in its [`Classification`].
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum Confidence {
144    /// The classification is speculative or based on weak signals.
145    Low,
146    /// The classification is likely correct but not certain.
147    Medium,
148    /// The classification is highly reliable (strong signature match, etc.).
149    High,
150}
151
152/// An indicator of compromise. `kind` is an open vocabulary so plugins can
153/// emit kinds the SDK doesn't know about yet; the frontend renders any kind.
154/// Common values: `sha256`, `md5`, `sha1`, `ipv4`, `ipv6`, `domain`, `url`,
155/// `email`, `mutex`, `registry`, `filepath`, `yara_rule`.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct Indicator {
158    /// IOC type (e.g. `"sha256"`, `"ipv4"`, `"domain"`, `"mutex"`).
159    pub kind: String,
160    /// The indicator value itself (a hash, IP, URL, etc.).
161    pub value: String,
162    /// Optional context describing where or how this IOC was observed.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub context: Option<String>,
165    /// Optional ISO-8601 timestamp of when the IOC was first seen.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub first_seen: Option<String>,
168}
169
170/// A MITRE ATT&CK technique observation. `id` uses the canonical `T####`
171/// (or `T####.###` for sub-techniques) form.
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
173pub struct Ttp {
174    /// Technique ID in `T####` or `T####.###` form.
175    pub id: String,
176    /// Human-readable technique name (e.g. `"Process Injection"`).
177    pub name: String,
178    /// Optional free-text evidence supporting this observation.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub evidence: Option<String>,
181}
182
183/// A reference to a sibling `PluginResult` produced by the same plugin in
184/// the same task - used by `Block::Image` / `Block::Download` to resolve
185/// artifact URLs on the frontend.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ArtifactRef {
188    /// Name of the sibling [`PluginResult`] this references.
189    pub result_name: String,
190    /// Artifact type (e.g. `"pcap"`, `"screenshot"`, `"memdump"`).
191    pub kind: String,
192    /// Optional human-readable description of the artifact.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub description: Option<String>,
195}
196
197/// A named section in the report's presentation layer. Each section
198/// has a title and a list of renderable [`Block`]s.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Section {
201    /// Machine-readable section identifier (used for anchoring/linking).
202    pub id: String,
203    /// Human-readable section title shown in the frontend.
204    pub title: String,
205    /// Ordered list of content blocks rendered inside this section.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub blocks: Vec<Block>,
208}
209
210/// A renderable block. The frontend dispatches on `type`; unknown types
211/// are rendered as a JSON tree so additions are non-breaking.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type", rename_all = "snake_case")]
214pub enum Block {
215    /// Rendered markdown text.
216    Markdown { text: String },
217    /// Highlighted message box (info, success, warning, or error).
218    Callout { level: CalloutLevel, text: String },
219    /// Section heading. `level` maps to HTML heading levels (1-6).
220    Heading { level: u8, text: String },
221    /// Horizontal rule separating content.
222    Divider,
223    /// Key-value pairs displayed as a definition list.
224    Kv { pairs: Vec<KvPair> },
225    /// Tabular data with typed columns and JSON rows.
226    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    /// Syntax-highlighted source code.
235    Code { language: String, text: String },
236    /// Interactive JSON tree viewer.
237    Json {
238        data: serde_json::Value,
239        #[serde(default)]
240        collapsed: bool,
241    },
242    /// Hex dump of binary data. `bytes_b64` is base64-encoded.
243    Hex {
244        bytes_b64: String,
245        #[serde(default)]
246        offset: u64,
247    },
248    /// Inline image resolved from a sibling artifact result.
249    Image {
250        artifact: String,
251        #[serde(skip_serializing_if = "Option::is_none")]
252        caption: Option<String>,
253    },
254    /// Download link resolved from a sibling artifact result.
255    Download { artifact: String, label: String },
256    /// Formatted list of indicators of compromise.
257    Iocs { items: Vec<Indicator> },
258    /// Formatted list of MITRE ATT&CK techniques.
259    Ttps { items: Vec<Ttp> },
260    /// Collapsible tree structure (e.g. process trees, file hierarchies).
261    Tree { nodes: Vec<TreeNode> },
262    /// Chronological event timeline.
263    Timeline { events: Vec<TimelineEvent> },
264    /// Node-and-edge graph (e.g. network connections, call graphs).
265    Graph {
266        nodes: Vec<GraphNode>,
267        edges: Vec<GraphEdge>,
268    },
269}
270
271/// Severity level for a [`Block::Callout`], controlling its color and icon.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case")]
274pub enum CalloutLevel {
275    /// Neutral informational message.
276    Info,
277    /// Positive confirmation.
278    Success,
279    /// Something that deserves attention but is not an error.
280    Warn,
281    /// A problem that needs action.
282    Error,
283}
284
285/// A single key-value pair for [`Block::Kv`].
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct KvPair {
288    /// Label shown on the left.
289    pub key: String,
290    /// Content shown on the right.
291    pub value: String,
292    /// Render the value in a monospace font (useful for hashes, paths, etc.).
293    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
294    pub mono: bool,
295}
296
297/// Column definition for a [`Block::Table`].
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct Column {
300    /// JSON key used to look up this column's value in each row object.
301    pub key: String,
302    /// Human-readable column header.
303    pub label: String,
304    /// Rendering type hint: `"string"`, `"number"`, `"bool"`, `"datetime"`, etc.
305    #[serde(default = "default_column_type")]
306    pub r#type: String,
307}
308
309fn default_column_type() -> String {
310    "string".to_string()
311}
312
313/// A node in a [`Block::Tree`] (e.g. a process or directory entry).
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct TreeNode {
316    /// Display text for this node.
317    pub label: String,
318    /// Child nodes rendered nested beneath this one.
319    #[serde(default, skip_serializing_if = "Vec::is_empty")]
320    pub children: Vec<TreeNode>,
321    /// Arbitrary metadata shown in a tooltip or detail pane.
322    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
323    pub meta: serde_json::Value,
324}
325
326/// A single event on a [`Block::Timeline`].
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct TimelineEvent {
329    /// Timestamp string (ISO-8601 or relative offset).
330    pub ts: String,
331    /// Short description of the event.
332    pub label: String,
333    /// Optional severity hint for color-coding (e.g. `"high"`, `"low"`).
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub severity: Option<String>,
336    /// Arbitrary metadata shown on click or hover.
337    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
338    pub meta: serde_json::Value,
339}
340
341/// A node in a [`Block::Graph`].
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct GraphNode {
344    /// Unique identifier referenced by [`GraphEdge::from`] and [`GraphEdge::to`].
345    pub id: String,
346    /// Display label for this node.
347    pub label: String,
348    /// Arbitrary metadata shown on click or hover.
349    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
350    pub meta: serde_json::Value,
351}
352
353/// A directed edge in a [`Block::Graph`].
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct GraphEdge {
356    /// Source [`GraphNode::id`].
357    pub from: String,
358    /// Target [`GraphNode::id`].
359    pub to: String,
360    /// Optional label shown on the edge (e.g. `"connects to"`, `"spawns"`).
361    #[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        // Nones and empty vecs must not appear in the output.
394        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        // display_name also absent.
401        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    /// Parity golden: this string MUST remain byte-for-byte identical to
502    /// `GOLDEN_JSON` in
503    /// `crates/malbox-plugin-sdk-cpp/tests/cpp/test_report.cpp`. If either
504    /// SDK's serializer drifts, one of these two tests will fail.
505    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        // Build the equivalent report via the Rust builder and compare.
525        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}