go_brrr/security/
sarif.rs

1//! SARIF (Static Analysis Results Interchange Format) output support.
2//!
3//! SARIF is a standard format for static analysis tool output, supported by
4//! GitHub, GitLab, Azure DevOps, and many other CI/CD platforms.
5//!
6//! Specification: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
7
8use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use super::types::{Confidence, SecurityCategory, SecurityFinding, SecurityReport, Severity};
13
14// =============================================================================
15// SARIF Types (v2.1.0)
16// =============================================================================
17
18/// The top-level SARIF log object.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct SarifLog {
22    /// The SARIF specification version (always "2.1.0")
23    #[serde(rename = "$schema")]
24    pub schema: String,
25    /// SARIF version
26    pub version: String,
27    /// Array of run objects
28    pub runs: Vec<SarifRun>,
29}
30
31impl SarifLog {
32    /// Create a new SARIF log with a single run.
33    #[must_use]
34    pub fn new(run: SarifRun) -> Self {
35        Self {
36            schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
37            version: "2.1.0".to_string(),
38            runs: vec![run],
39        }
40    }
41}
42
43/// A single analysis run.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct SarifRun {
47    /// The analysis tool that produced the results
48    pub tool: SarifTool,
49    /// The results of the analysis
50    pub results: Vec<SarifResult>,
51    /// Artifacts (files) analyzed
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    pub artifacts: Vec<SarifArtifact>,
54    /// Invocation details
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub invocations: Option<Vec<SarifInvocation>>,
57}
58
59/// Tool information.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct SarifTool {
63    /// The tool driver (main component)
64    pub driver: SarifToolComponent,
65}
66
67/// Tool component (driver or extension).
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct SarifToolComponent {
71    /// Tool name
72    pub name: String,
73    /// Tool version
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub version: Option<String>,
76    /// Semantic version
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub semantic_version: Option<String>,
79    /// Tool information URI
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub information_uri: Option<String>,
82    /// Rules defined by this tool
83    #[serde(skip_serializing_if = "Vec::is_empty")]
84    pub rules: Vec<SarifReportingDescriptor>,
85}
86
87/// A rule/vulnerability descriptor.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct SarifReportingDescriptor {
91    /// Rule ID (e.g., "SQLI-001")
92    pub id: String,
93    /// Short description
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub short_description: Option<SarifMessage>,
96    /// Full description
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub full_description: Option<SarifMessage>,
99    /// Help text with remediation guidance
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub help: Option<SarifMessage>,
102    /// Help URI
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub help_uri: Option<String>,
105    /// Default severity configuration
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub default_configuration: Option<SarifReportingConfiguration>,
108    /// Properties bag (for CWE, tags, etc.)
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub properties: Option<SarifPropertyBag>,
111}
112
113/// Reporting configuration (severity level).
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct SarifReportingConfiguration {
117    /// Severity level
118    pub level: SarifLevel,
119    /// Whether this rule is enabled
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub enabled: Option<bool>,
122}
123
124/// SARIF severity level.
125#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum SarifLevel {
128    /// Serious problem
129    Error,
130    /// Less serious problem
131    Warning,
132    /// Informational message
133    Note,
134    /// No level (used for suppressions)
135    None,
136}
137
138impl From<Severity> for SarifLevel {
139    fn from(sev: Severity) -> Self {
140        match sev {
141            Severity::Critical | Severity::High => Self::Error,
142            Severity::Medium => Self::Warning,
143            Severity::Low | Severity::Info => Self::Note,
144        }
145    }
146}
147
148/// A message (text or markdown).
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct SarifMessage {
152    /// Plain text message
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub text: Option<String>,
155    /// Markdown message
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub markdown: Option<String>,
158}
159
160impl SarifMessage {
161    /// Create a plain text message.
162    #[must_use]
163    pub fn text(s: impl Into<String>) -> Self {
164        Self {
165            text: Some(s.into()),
166            markdown: None,
167        }
168    }
169
170    /// Create a markdown message.
171    #[must_use]
172    pub fn markdown(s: impl Into<String>) -> Self {
173        Self {
174            text: None,
175            markdown: Some(s.into()),
176        }
177    }
178}
179
180/// A result (finding).
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct SarifResult {
184    /// Rule ID
185    pub rule_id: String,
186    /// Rule index in the rules array
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub rule_index: Option<usize>,
189    /// Severity level
190    pub level: SarifLevel,
191    /// Message
192    pub message: SarifMessage,
193    /// Locations where the issue was found
194    pub locations: Vec<SarifLocation>,
195    /// Code flows (taint tracking paths)
196    #[serde(skip_serializing_if = "Vec::is_empty")]
197    pub code_flows: Vec<SarifCodeFlow>,
198    /// Related locations
199    #[serde(skip_serializing_if = "Vec::is_empty")]
200    pub related_locations: Vec<SarifLocation>,
201    /// Fix suggestions
202    #[serde(skip_serializing_if = "Vec::is_empty")]
203    pub fixes: Vec<SarifFix>,
204    /// Fingerprints for tracking across runs
205    #[serde(skip_serializing_if = "HashMap::is_empty")]
206    pub fingerprints: HashMap<String, String>,
207    /// Properties bag
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub properties: Option<SarifPropertyBag>,
210    /// Suppression info
211    #[serde(skip_serializing_if = "Vec::is_empty")]
212    pub suppressions: Vec<SarifSuppression>,
213}
214
215/// A location.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct SarifLocation {
219    /// Physical location in a file
220    pub physical_location: SarifPhysicalLocation,
221    /// Logical location (function name, etc.)
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub logical_locations: Option<Vec<SarifLogicalLocation>>,
224}
225
226/// Physical location (file, region).
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase")]
229pub struct SarifPhysicalLocation {
230    /// Artifact (file) location
231    pub artifact_location: SarifArtifactLocation,
232    /// Region within the file
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub region: Option<SarifRegion>,
235    /// Context region (surrounding code)
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub context_region: Option<SarifRegion>,
238}
239
240/// File reference.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct SarifArtifactLocation {
244    /// URI (file path)
245    pub uri: String,
246    /// URI base ID (for relative paths)
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub uri_base_id: Option<String>,
249    /// Index in the artifacts array
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub index: Option<usize>,
252}
253
254/// A region within a file.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct SarifRegion {
258    /// Start line (1-indexed)
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub start_line: Option<usize>,
261    /// Start column (1-indexed)
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub start_column: Option<usize>,
264    /// End line (1-indexed)
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub end_line: Option<usize>,
267    /// End column (1-indexed)
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub end_column: Option<usize>,
270    /// Code snippet
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub snippet: Option<SarifArtifactContent>,
273}
274
275/// Artifact content (code snippet).
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct SarifArtifactContent {
279    /// Plain text content
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub text: Option<String>,
282    /// Binary content (base64)
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub binary: Option<String>,
285}
286
287/// Logical location (function, class, etc.).
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct SarifLogicalLocation {
291    /// Name (e.g., function name)
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub name: Option<String>,
294    /// Fully qualified name
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub fully_qualified_name: Option<String>,
297    /// Kind (function, method, class, etc.)
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub kind: Option<String>,
300}
301
302/// Code flow for taint tracking.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct SarifCodeFlow {
306    /// Thread flows
307    pub thread_flows: Vec<SarifThreadFlow>,
308}
309
310/// Thread flow (sequence of locations).
311#[derive(Debug, Clone, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct SarifThreadFlow {
314    /// Locations in order
315    pub locations: Vec<SarifThreadFlowLocation>,
316}
317
318/// A location in a thread flow.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct SarifThreadFlowLocation {
322    /// Location
323    pub location: SarifLocation,
324    /// Importance
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub importance: Option<String>,
327}
328
329/// Fix suggestion.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct SarifFix {
333    /// Description of the fix
334    pub description: SarifMessage,
335    /// Artifact changes
336    pub artifact_changes: Vec<SarifArtifactChange>,
337}
338
339/// Artifact change (file modification).
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct SarifArtifactChange {
343    /// File to modify
344    pub artifact_location: SarifArtifactLocation,
345    /// Replacements to make
346    pub replacements: Vec<SarifReplacement>,
347}
348
349/// Text replacement.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase")]
352pub struct SarifReplacement {
353    /// Region to delete
354    pub deleted_region: SarifRegion,
355    /// Content to insert
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub inserted_content: Option<SarifArtifactContent>,
358}
359
360/// An artifact (file).
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct SarifArtifact {
364    /// File location
365    pub location: SarifArtifactLocation,
366    /// MIME type
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub mime_type: Option<String>,
369    /// Length in bytes
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub length: Option<usize>,
372}
373
374/// Invocation details.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376#[serde(rename_all = "camelCase")]
377pub struct SarifInvocation {
378    /// Whether the run succeeded
379    pub execution_successful: bool,
380    /// Start time
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub start_time_utc: Option<String>,
383    /// End time
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub end_time_utc: Option<String>,
386    /// Working directory
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub working_directory: Option<SarifArtifactLocation>,
389}
390
391/// Property bag for custom properties.
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct SarifPropertyBag {
395    /// CWE IDs
396    #[serde(skip_serializing_if = "Vec::is_empty")]
397    pub cwe: Vec<String>,
398    /// Tags
399    #[serde(skip_serializing_if = "Vec::is_empty")]
400    pub tags: Vec<String>,
401    /// Security severity score (0.0-10.0 CVSS-like)
402    #[serde(skip_serializing_if = "Option::is_none", rename = "security-severity")]
403    pub security_severity: Option<String>,
404    /// Confidence
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub confidence: Option<String>,
407    /// Additional properties
408    #[serde(flatten)]
409    pub extra: HashMap<String, serde_json::Value>,
410}
411
412/// Suppression info.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414#[serde(rename_all = "camelCase")]
415pub struct SarifSuppression {
416    /// Suppression kind
417    pub kind: String,
418    /// Justification
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub justification: Option<String>,
421}
422
423// =============================================================================
424// Conversion from SecurityReport to SARIF
425// =============================================================================
426
427impl SecurityReport {
428    /// Convert the report to SARIF format.
429    #[must_use]
430    pub fn to_sarif(&self) -> SarifLog {
431        // Build rules from unique finding IDs
432        let mut rules: Vec<SarifReportingDescriptor> = Vec::new();
433        let mut rule_index_map: HashMap<String, usize> = HashMap::new();
434
435        for finding in &self.findings {
436            if !rule_index_map.contains_key(&finding.id) {
437                rule_index_map.insert(finding.id.clone(), rules.len());
438                rules.push(finding_to_rule(finding));
439            }
440        }
441
442        // Build results
443        let results: Vec<SarifResult> = self
444            .findings
445            .iter()
446            .map(|f| finding_to_result(f, rule_index_map.get(&f.id).copied()))
447            .collect();
448
449        // Build artifacts from unique files
450        let mut artifacts: Vec<SarifArtifact> = Vec::new();
451        let mut seen_files: HashMap<String, usize> = HashMap::new();
452
453        for finding in &self.findings {
454            if !seen_files.contains_key(&finding.location.file) {
455                seen_files.insert(finding.location.file.clone(), artifacts.len());
456                artifacts.push(SarifArtifact {
457                    location: SarifArtifactLocation {
458                        uri: finding.location.file.clone(),
459                        uri_base_id: Some("%SRCROOT%".to_string()),
460                        index: Some(artifacts.len()),
461                    },
462                    mime_type: guess_mime_type(&finding.location.file),
463                    length: None,
464                });
465            }
466        }
467
468        let tool = SarifTool {
469            driver: SarifToolComponent {
470                name: "brrr-security".to_string(),
471                version: Some(self.scanner_version.clone()),
472                semantic_version: Some(self.scanner_version.clone()),
473                information_uri: Some(
474                    "https://github.com/GrigoryEvko/go-brrr".to_string(),
475                ),
476                rules,
477            },
478        };
479
480        let run = SarifRun {
481            tool,
482            results,
483            artifacts,
484            invocations: Some(vec![SarifInvocation {
485                execution_successful: true,
486                start_time_utc: Some(self.timestamp.clone()),
487                end_time_utc: Some(self.timestamp.clone()),
488                working_directory: None,
489            }]),
490        };
491
492        SarifLog::new(run)
493    }
494
495    /// Serialize to SARIF JSON string.
496    ///
497    /// # Errors
498    /// Returns an error if JSON serialization fails.
499    pub fn to_sarif_json(&self) -> Result<String, serde_json::Error> {
500        let sarif = self.to_sarif();
501        serde_json::to_string_pretty(&sarif)
502    }
503}
504
505/// Convert a finding to a SARIF rule descriptor.
506fn finding_to_rule(finding: &SecurityFinding) -> SarifReportingDescriptor {
507    let mut properties = SarifPropertyBag::default();
508
509    if let Some(cwe) = finding.cwe_id {
510        properties.cwe.push(format!("CWE-{cwe}"));
511    }
512
513    properties.security_severity = Some(format!("{:.1}", finding.severity.cvss_score()));
514
515    // Add category tag
516    let tag = match &finding.category {
517        SecurityCategory::Injection(t) => format!("injection/{t:?}").to_lowercase(),
518        SecurityCategory::SecretsExposure => "secrets".to_string(),
519        SecurityCategory::WeakCrypto => "crypto".to_string(),
520        SecurityCategory::UnsafeDeserialization => "deserialization".to_string(),
521        SecurityCategory::ReDoS => "redos".to_string(),
522        SecurityCategory::InsecureConfig => "config".to_string(),
523        SecurityCategory::AuthIssue => "auth".to_string(),
524        SecurityCategory::InfoDisclosure => "disclosure".to_string(),
525        SecurityCategory::Other(s) => s.clone(),
526    };
527    properties.tags.push(tag);
528
529    // Add OWASP tag if applicable
530    if let Some(owasp) = finding.category.owasp_category() {
531        properties.tags.push(owasp.to_string());
532    }
533
534    let help_uri = finding
535        .cwe_id
536        .map(|cwe| format!("https://cwe.mitre.org/data/definitions/{cwe}.html"));
537
538    SarifReportingDescriptor {
539        id: finding.id.clone(),
540        short_description: Some(SarifMessage::text(&finding.title)),
541        full_description: Some(SarifMessage::text(&finding.description)),
542        help: if finding.remediation.is_empty() {
543            None
544        } else {
545            Some(SarifMessage::markdown(&finding.remediation))
546        },
547        help_uri,
548        default_configuration: Some(SarifReportingConfiguration {
549            level: SarifLevel::from(finding.severity),
550            enabled: Some(true),
551        }),
552        properties: Some(properties),
553    }
554}
555
556/// Convert a finding to a SARIF result.
557fn finding_to_result(finding: &SecurityFinding, rule_index: Option<usize>) -> SarifResult {
558    let location = SarifLocation {
559        physical_location: SarifPhysicalLocation {
560            artifact_location: SarifArtifactLocation {
561                uri: finding.location.file.clone(),
562                uri_base_id: Some("%SRCROOT%".to_string()),
563                index: None,
564            },
565            region: Some(SarifRegion {
566                start_line: Some(finding.location.start_line),
567                start_column: Some(finding.location.start_column),
568                end_line: Some(finding.location.end_line),
569                end_column: Some(finding.location.end_column),
570                snippet: if finding.code_snippet.is_empty() {
571                    None
572                } else {
573                    Some(SarifArtifactContent {
574                        text: Some(finding.code_snippet.clone()),
575                        binary: None,
576                    })
577                },
578            }),
579            context_region: None,
580        },
581        logical_locations: None,
582    };
583
584    let mut fingerprints = HashMap::new();
585    fingerprints.insert(
586        "primaryLocationLineHash".to_string(),
587        finding.fingerprint(),
588    );
589
590    let mut properties = SarifPropertyBag::default();
591    properties.confidence = Some(finding.confidence.to_string());
592
593    // Add metadata as properties
594    for (k, v) in &finding.metadata {
595        properties
596            .extra
597            .insert(k.clone(), serde_json::Value::String(v.clone()));
598    }
599
600    let suppressions = if finding.suppressed {
601        vec![SarifSuppression {
602            kind: "inSource".to_string(),
603            justification: Some("Suppressed via inline comment".to_string()),
604        }]
605    } else {
606        Vec::new()
607    };
608
609    SarifResult {
610        rule_id: finding.id.clone(),
611        rule_index,
612        level: SarifLevel::from(finding.severity),
613        message: SarifMessage::text(&finding.description),
614        locations: vec![location],
615        code_flows: Vec::new(),
616        related_locations: Vec::new(),
617        fixes: Vec::new(),
618        fingerprints,
619        properties: Some(properties),
620        suppressions,
621    }
622}
623
624/// Guess MIME type from file extension.
625fn guess_mime_type(path: &str) -> Option<String> {
626    let ext = path.rsplit('.').next()?;
627    let mime = match ext {
628        "py" => "text/x-python",
629        "js" => "text/javascript",
630        "ts" => "text/typescript",
631        "tsx" | "jsx" => "text/jsx",
632        "rs" => "text/x-rust",
633        "go" => "text/x-go",
634        "java" => "text/x-java",
635        "c" | "h" => "text/x-c",
636        "cpp" | "cc" | "cxx" | "hpp" => "text/x-c++src",
637        "rb" => "text/x-ruby",
638        "php" => "text/x-php",
639        _ => return None,
640    };
641    Some(mime.to_string())
642}
643
644// =============================================================================
645// Tests
646// =============================================================================
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use crate::security::types::{InjectionType, Location};
652
653    #[test]
654    fn test_sarif_generation() {
655        let finding = SecurityFinding::new(
656            "SQLI-001",
657            SecurityCategory::Injection(InjectionType::Sql),
658            Severity::High,
659            Confidence::High,
660            Location::new("src/api.py", 42, 5, 42, 50),
661            "SQL Injection in query",
662            "User input is concatenated into SQL query without sanitization",
663        )
664        .with_remediation("Use parameterized queries")
665        .with_code_snippet("cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")");
666
667        let report = SecurityReport::new(vec![finding], 10);
668        let sarif = report.to_sarif();
669
670        assert_eq!(sarif.version, "2.1.0");
671        assert_eq!(sarif.runs.len(), 1);
672
673        let run = &sarif.runs[0];
674        assert_eq!(run.tool.driver.name, "brrr-security");
675        assert_eq!(run.results.len(), 1);
676        assert_eq!(run.tool.driver.rules.len(), 1);
677
678        let result = &run.results[0];
679        assert_eq!(result.rule_id, "SQLI-001");
680        assert!(matches!(result.level, SarifLevel::Error));
681    }
682
683    #[test]
684    fn test_sarif_json_output() {
685        let finding = SecurityFinding::new(
686            "CMD-001",
687            SecurityCategory::Injection(InjectionType::Command),
688            Severity::Critical,
689            Confidence::High,
690            Location::new("app.py", 10, 1, 10, 30),
691            "Command Injection",
692            "os.system called with user input",
693        );
694
695        let report = SecurityReport::new(vec![finding], 1);
696        let json = report.to_sarif_json().expect("SARIF JSON serialization");
697
698        assert!(json.contains("\"version\": \"2.1.0\""));
699        assert!(json.contains("CMD-001"));
700        assert!(json.contains("error")); // level
701    }
702}