execsurface_report/
lib.rs1use 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("&"),
144 '<' => escaped.push_str("<"),
145 '>' => escaped.push_str(">"),
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\\|<tag>"));
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}