Skip to main content

xarf/
generator.rs

1//! Report generator — `create_report` and `create_evidence`.
2
3use base64::Engine;
4use base64::engine::general_purpose::STANDARD as BASE64;
5use chrono::SecondsFormat;
6use md5::Md5;
7use serde_json::{Map, Value, json};
8use sha1::Sha1;
9use sha2::{Digest, Sha256, Sha512};
10use uuid::Uuid;
11
12use crate::error::{Result, ValidationError, XarfError};
13use crate::model::{Contact, Evidence};
14use crate::parser::{ParseOptions, ParseResult, parse_value};
15
16/// The XARF specification version this crate targets.
17pub const SPEC_VERSION: &str = "4.2.0";
18
19/// Hash algorithm choices for [`create_evidence`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum HashAlgorithm {
22    #[default]
23    Sha256,
24    Sha512,
25    Sha1,
26    Md5,
27}
28
29impl HashAlgorithm {
30    pub fn prefix(self) -> &'static str {
31        match self {
32            Self::Sha256 => "sha256",
33            Self::Sha512 => "sha512",
34            Self::Sha1 => "sha1",
35            Self::Md5 => "md5",
36        }
37    }
38}
39
40/// Build an [`Evidence`] item from raw bytes:
41/// computes the requested hash, base64-encodes the payload, and records the
42/// pre-encoding size.
43pub fn create_evidence(content_type: impl Into<String>, payload: &[u8]) -> Evidence {
44    create_evidence_with_options(content_type, payload, EvidenceOptions::default())
45}
46
47/// Options accepted by [`create_evidence_with_options`].
48#[derive(Debug, Clone, Default)]
49pub struct EvidenceOptions {
50    pub description: Option<String>,
51    pub hash_algorithm: HashAlgorithm,
52}
53
54/// Like [`create_evidence`], but lets the caller supply description and hash
55/// algorithm.
56pub fn create_evidence_with_options(
57    content_type: impl Into<String>,
58    payload: &[u8],
59    options: EvidenceOptions,
60) -> Evidence {
61    let hex_digest = match options.hash_algorithm {
62        HashAlgorithm::Sha256 => crate::hex::encode(Sha256::digest(payload).as_slice()),
63        HashAlgorithm::Sha512 => crate::hex::encode(Sha512::digest(payload).as_slice()),
64        HashAlgorithm::Sha1 => crate::hex::encode(Sha1::digest(payload).as_slice()),
65        HashAlgorithm::Md5 => crate::hex::encode(Md5::digest(payload).as_slice()),
66    };
67    let hash = format!("{}:{hex_digest}", options.hash_algorithm.prefix());
68    let encoded = BASE64.encode(payload);
69    Evidence {
70        content_type: content_type.into(),
71        payload: encoded,
72        description: options.description,
73        hash: Some(hash),
74        size: Some(payload.len() as u64),
75    }
76}
77
78/// Builder for [`crate::model::Report`] objects.
79///
80/// Pattern:
81///
82/// ```rust,ignore
83/// let report = xarf::ReportBuilder::new("messaging", "spam", "192.0.2.1")
84///     .reporter(xarf::Contact::new("Acme", "abuse@acme.example", "acme.example"))
85///     .sender(xarf::Contact::new("Acme", "abuse@acme.example", "acme.example"))
86///     .extra("protocol", json!("smtp"))
87///     .extra("smtp_from", json!("spam@bad.example"))
88///     .build()?;
89/// ```
90#[derive(Debug, Clone)]
91pub struct ReportBuilder {
92    category: String,
93    type_name: String,
94    source_identifier: String,
95    reporter: Option<Contact>,
96    sender: Option<Contact>,
97    timestamp: Option<String>,
98    report_id: Option<String>,
99    xarf_version: Option<String>,
100    evidence_source: Option<String>,
101    evidence: Vec<Evidence>,
102    tags: Vec<String>,
103    confidence: Option<f64>,
104    description: Option<String>,
105    source_port: Option<u16>,
106    internal: Option<Map<String, Value>>,
107    extras: Map<String, Value>,
108}
109
110impl ReportBuilder {
111    pub fn new(
112        category: impl Into<String>,
113        type_name: impl Into<String>,
114        source_identifier: impl Into<String>,
115    ) -> Self {
116        Self {
117            category: category.into(),
118            type_name: type_name.into(),
119            source_identifier: source_identifier.into(),
120            reporter: None,
121            sender: None,
122            timestamp: None,
123            report_id: None,
124            xarf_version: None,
125            evidence_source: None,
126            evidence: Vec::new(),
127            tags: Vec::new(),
128            confidence: None,
129            description: None,
130            source_port: None,
131            internal: None,
132            extras: Map::new(),
133        }
134    }
135
136    pub fn reporter(mut self, contact: Contact) -> Self {
137        self.reporter = Some(contact);
138        self
139    }
140
141    pub fn sender(mut self, contact: Contact) -> Self {
142        self.sender = Some(contact);
143        self
144    }
145
146    pub fn timestamp(mut self, ts: impl Into<String>) -> Self {
147        self.timestamp = Some(ts.into());
148        self
149    }
150
151    pub fn report_id(mut self, id: impl Into<String>) -> Self {
152        self.report_id = Some(id.into());
153        self
154    }
155
156    pub fn xarf_version(mut self, v: impl Into<String>) -> Self {
157        self.xarf_version = Some(v.into());
158        self
159    }
160
161    pub fn evidence_source(mut self, s: impl Into<String>) -> Self {
162        self.evidence_source = Some(s.into());
163        self
164    }
165
166    pub fn add_evidence(mut self, evidence: Evidence) -> Self {
167        self.evidence.push(evidence);
168        self
169    }
170
171    pub fn tags<I, S>(mut self, tags: I) -> Self
172    where
173        I: IntoIterator<Item = S>,
174        S: Into<String>,
175    {
176        self.tags = tags.into_iter().map(Into::into).collect();
177        self
178    }
179
180    pub fn confidence(mut self, c: f64) -> Self {
181        self.confidence = Some(c);
182        self
183    }
184
185    pub fn description(mut self, d: impl Into<String>) -> Self {
186        self.description = Some(d.into());
187        self
188    }
189
190    pub fn source_port(mut self, p: u16) -> Self {
191        self.source_port = Some(p);
192        self
193    }
194
195    pub fn extra(mut self, key: impl Into<String>, value: Value) -> Self {
196        self.extras.insert(key.into(), value);
197        self
198    }
199
200    pub fn internal(mut self, internal: Map<String, Value>) -> Self {
201        self.internal = Some(internal);
202        self
203    }
204
205    /// Build and validate the report. Returns the validation outcome
206    /// (mirrors [`crate::parse`]).
207    pub fn build(self) -> Result<ParseResult> {
208        self.build_with_options(ParseOptions::default())
209    }
210
211    /// Build and validate with explicit [`ParseOptions`]. In strict mode the
212    /// result mirrors strict-mode parsing.
213    pub fn build_with_options(self, options: ParseOptions) -> Result<ParseResult> {
214        let reporter = self.reporter.ok_or_else(|| {
215            XarfError::Validation(vec![ValidationError::new(
216                "reporter",
217                "reporter contact is required",
218            )])
219        })?;
220        let sender = self.sender.ok_or_else(|| {
221            XarfError::Validation(vec![ValidationError::new(
222                "sender",
223                "sender contact is required",
224            )])
225        })?;
226
227        let timestamp = self
228            .timestamp
229            .unwrap_or_else(|| chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true));
230        let report_id = self.report_id.unwrap_or_else(|| Uuid::new_v4().to_string());
231        let xarf_version = self
232            .xarf_version
233            .unwrap_or_else(|| SPEC_VERSION.to_string());
234
235        let mut data = Map::new();
236        data.insert("xarf_version".into(), Value::String(xarf_version));
237        data.insert("report_id".into(), Value::String(report_id));
238        data.insert("timestamp".into(), Value::String(timestamp));
239        data.insert(
240            "reporter".into(),
241            serde_json::to_value(&reporter).expect("Contact serialises"),
242        );
243        data.insert(
244            "sender".into(),
245            serde_json::to_value(&sender).expect("Contact serialises"),
246        );
247        data.insert(
248            "source_identifier".into(),
249            Value::String(self.source_identifier),
250        );
251        data.insert("category".into(), Value::String(self.category));
252        data.insert("type".into(), Value::String(self.type_name));
253
254        if let Some(p) = self.source_port {
255            data.insert("source_port".into(), json!(p));
256        }
257        if let Some(s) = self.evidence_source {
258            data.insert("evidence_source".into(), Value::String(s));
259        }
260        if !self.evidence.is_empty() {
261            data.insert(
262                "evidence".into(),
263                serde_json::to_value(self.evidence).expect("Vec<Evidence> serialises"),
264            );
265        }
266        if !self.tags.is_empty() {
267            data.insert(
268                "tags".into(),
269                Value::Array(self.tags.into_iter().map(Value::String).collect()),
270            );
271        }
272        if let Some(c) = self.confidence {
273            data.insert("confidence".into(), json!(c));
274        }
275        if let Some(d) = self.description {
276            data.insert("description".into(), Value::String(d));
277        }
278        if let Some(internal) = self.internal {
279            data.insert("_internal".into(), Value::Object(internal));
280        }
281        for (k, v) in self.extras {
282            data.insert(k, v);
283        }
284
285        parse_value(Value::Object(data), options)
286    }
287}
288
289/// Functional shorthand for [`ReportBuilder`]: matches the Python API
290/// signature for callers porting code 1:1.
291#[allow(clippy::too_many_arguments)]
292pub fn create_report(
293    category: &str,
294    type_name: &str,
295    source_identifier: &str,
296    reporter: Contact,
297    sender: Contact,
298    extras: Map<String, Value>,
299    options: CreateReportOptions,
300) -> Result<ParseResult> {
301    let mut builder = ReportBuilder::new(category, type_name, source_identifier)
302        .reporter(reporter)
303        .sender(sender);
304    for (k, v) in extras {
305        // Route well-known keys through dedicated setters so caller intent
306        // matches builder semantics (timestamp generation, etc.). Unknown
307        // keys flow through `.extra()` verbatim.
308        match k.as_str() {
309            "timestamp" => {
310                if let Some(s) = v.as_str() {
311                    builder = builder.timestamp(s);
312                }
313            }
314            "report_id" => {
315                if let Some(s) = v.as_str() {
316                    builder = builder.report_id(s);
317                }
318            }
319            "xarf_version" => {
320                if let Some(s) = v.as_str() {
321                    builder = builder.xarf_version(s);
322                }
323            }
324            "evidence_source" => {
325                if let Some(s) = v.as_str() {
326                    builder = builder.evidence_source(s);
327                }
328            }
329            "evidence" => {
330                if let Value::Array(arr) = &v {
331                    for item in arr {
332                        if let Ok(ev) = serde_json::from_value::<Evidence>(item.clone()) {
333                            builder = builder.add_evidence(ev);
334                        } else {
335                            builder = builder.extra(k.clone(), v.clone());
336                            break;
337                        }
338                    }
339                }
340            }
341            "tags" => {
342                if let Value::Array(arr) = &v {
343                    let tags: Vec<String> = arr
344                        .iter()
345                        .filter_map(|t| t.as_str().map(String::from))
346                        .collect();
347                    builder = builder.tags(tags);
348                }
349            }
350            "confidence" => {
351                if let Some(c) = v.as_f64() {
352                    builder = builder.confidence(c);
353                }
354            }
355            "description" => {
356                if let Some(s) = v.as_str() {
357                    builder = builder.description(s);
358                }
359            }
360            "source_port" => {
361                if let Some(n) = v.as_u64() {
362                    if n <= u16::MAX as u64 {
363                        builder = builder.source_port(n as u16);
364                    }
365                }
366            }
367            "_internal" => {
368                if let Value::Object(obj) = v {
369                    builder = builder.internal(obj);
370                }
371            }
372            _ => {
373                builder = builder.extra(k, v);
374            }
375        }
376    }
377    builder.build_with_options(options.into())
378}
379
380/// Options for the functional `create_report` shorthand.
381#[derive(Debug, Clone, Copy, Default)]
382pub struct CreateReportOptions {
383    pub strict: bool,
384    pub show_missing_optional: bool,
385}
386
387impl From<CreateReportOptions> for ParseOptions {
388    fn from(o: CreateReportOptions) -> Self {
389        Self {
390            strict: o.strict,
391            show_missing_optional: o.show_missing_optional,
392        }
393    }
394}