Skip to main content

execsurface_report/
lib.rs

1//! Deterministic human-readable reporting for ExecSurface verdict evidence.
2//!
3//! M6 deliberately does not synthesize SARIF source locations. Runtime effects
4//! are rendered to a job summary and a JSON evidence artifact instead.
5
6use execsurface_policy::{ChangeKind, EffectKind, FindingAction, Verdict, VerdictReport};
7
8pub const SARIF_STATUS: &str = "not-generated:no-source-provenance";
9pub const SARIF_REASON: &str =
10    "SARIF not generated: current runtime evidence has no causal source-code location.";
11
12pub fn render_markdown(report: &VerdictReport) -> String {
13    let mut output = String::new();
14    output.push_str("# ExecSurface — ");
15    output.push_str(verdict_name(report.verdict));
16    output.push_str("\n\n");
17
18    if let Some(digest) = &report.baseline_digest {
19        output.push_str("**Baseline:** ");
20        output.push_str(&escape_markdown(digest));
21        output.push_str("\n\n");
22    }
23
24    if let Some(target) = &report.target {
25        output.push_str(&format!(
26            "**Target:** exit_code={:?}, signal={:?}\n\n",
27            target.exit_code, target.signal
28        ));
29    }
30
31    let allow = report
32        .findings
33        .iter()
34        .filter(|finding| finding.action == FindingAction::Allow)
35        .count();
36    let review = report
37        .findings
38        .iter()
39        .filter(|finding| finding.action == FindingAction::Review)
40        .count();
41    let block = report
42        .findings
43        .iter()
44        .filter(|finding| finding.action == FindingAction::Block)
45        .count();
46
47    output.push_str(&format!(
48        "**Findings:** {} total — {} allow / {} review / {} block\n\n",
49        report.findings.len(),
50        allow,
51        review,
52        block
53    ));
54
55    if let Some(policy) = &report.policy {
56        output.push_str("**Policy:** ");
57        output.push_str(&escape_markdown(&policy.source));
58        output.push_str("\n\n");
59    }
60
61    if !report.findings.is_empty() {
62        output.push_str("| Action | Change | Effect | Matched rules |\n");
63        output.push_str("| --- | --- | --- | --- |\n");
64        for finding in &report.findings {
65            let rules = if finding.matched_rules.is_empty() {
66                "<default>".to_owned()
67            } else {
68                finding.matched_rules.join(", ")
69            };
70            output.push_str("| ");
71            output.push_str(action_name(finding.action));
72            output.push_str(" | ");
73            output.push_str(change_name(finding.change));
74            output.push_str(" | ");
75            output.push_str(effect_name(finding.effect_kind));
76            output.push_str(" | ");
77            output.push_str(&escape_markdown(&rules));
78            output.push_str(" |\n");
79        }
80        output.push('\n');
81    }
82
83    if let Some(error) = &report.error {
84        output.push_str("## Error\n\n");
85        output.push_str(&escape_markdown(error));
86        output.push_str("\n\n");
87    }
88
89    output.push_str("## SARIF\n\n");
90    output.push_str(SARIF_REASON);
91    output.push_str("\n\n");
92    output.push_str(
93        "ExecSurface will not invent a repository file or source line solely to create a code-scanning annotation.\n",
94    );
95
96    output
97}
98
99pub fn verdict_name(verdict: Verdict) -> &'static str {
100    match verdict {
101        Verdict::Pass => "PASS",
102        Verdict::Review => "REVIEW",
103        Verdict::Block => "BLOCK",
104        Verdict::Error => "ERROR",
105    }
106}
107
108fn action_name(action: FindingAction) -> &'static str {
109    match action {
110        FindingAction::Allow => "ALLOW",
111        FindingAction::Review => "REVIEW",
112        FindingAction::Block => "BLOCK",
113    }
114}
115
116fn change_name(change: ChangeKind) -> &'static str {
117    match change {
118        ChangeKind::Added => "ADDED",
119        ChangeKind::Removed => "REMOVED",
120        ChangeKind::Changed => "CHANGED",
121    }
122}
123
124fn effect_name(effect: EffectKind) -> &'static str {
125    match effect {
126        EffectKind::ProcessSpawn => "PROCESS_SPAWN",
127        EffectKind::ProcessExec => "PROCESS_EXEC",
128        EffectKind::FileOpen => "FILE_OPEN",
129        EffectKind::FileCreate => "FILE_CREATE",
130        EffectKind::FileDelete => "FILE_DELETE",
131        EffectKind::FileRead => "FILE_READ",
132        EffectKind::FileWrite => "FILE_WRITE",
133        EffectKind::FileRename => "FILE_RENAME",
134        EffectKind::NetworkConnect => "NETWORK_CONNECT",
135    }
136}
137
138fn escape_markdown(value: &str) -> String {
139    let mut escaped = String::with_capacity(value.len());
140    for ch in value.chars() {
141        match ch {
142            '\n' | '\r' => escaped.push(' '),
143            '&' => escaped.push_str("&amp;"),
144            '<' => escaped.push_str("&lt;"),
145            '>' => escaped.push_str("&gt;"),
146            '\\' | '|' | '*' | '_' | '[' | ']' | '`' => {
147                escaped.push('\\');
148                escaped.push(ch);
149            }
150            _ => escaped.push(ch),
151        }
152    }
153    escaped
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use execsurface_policy::{error_report, PolicySummary, VerdictReport};
160
161    #[test]
162    fn error_summary_is_deterministic_and_declares_no_sarif() {
163        let report = error_report("observer failed");
164        let first = render_markdown(&report);
165        let second = render_markdown(&report);
166        assert_eq!(first, second);
167        assert!(first.starts_with("# ExecSurface — ERROR"));
168        assert!(first.contains(SARIF_REASON));
169    }
170
171    #[test]
172    fn markdown_escaping_prevents_table_and_html_injection() {
173        let mut report = error_report("safe");
174        report.verdict = Verdict::Pass;
175        report.error = None;
176        report.policy = Some(PolicySummary {
177            schema_version: execsurface_policy::VERDICT_SCHEMA_VERSION,
178            source: "evil|<tag>\n**bold**".to_owned(),
179            default_action: FindingAction::Review,
180        });
181
182        let markdown = render_markdown(&report);
183        assert!(!markdown.contains("<tag>"));
184        assert!(markdown.contains("evil\\|&lt;tag&gt;"));
185        assert!(!markdown.contains("\n**bold**"));
186    }
187
188    #[test]
189    fn sarif_disposition_is_explicit() {
190        assert_eq!(SARIF_STATUS, "not-generated:no-source-provenance");
191    }
192
193    #[test]
194    fn empty_pass_report_has_no_findings_table() {
195        let report = VerdictReport {
196            schema_version: 1,
197            baseline_digest: Some("sha256:test".to_owned()),
198            target: None,
199            verdict: Verdict::Pass,
200            policy: None,
201            findings: vec![],
202            error: None,
203        };
204        let markdown = render_markdown(&report);
205        assert!(markdown.contains("0 total"));
206        assert!(!markdown.contains("| Action |"));
207    }
208}