Skip to main content

ssg/audit/output/
sarif.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! SARIF v2.1.0 formatter for audit reports (issue #562).
5//!
6//! [SARIF](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html)
7//! (Static Analysis Results Interchange Format) is the OASIS-standard
8//! schema for static-analysis findings. It is the native ingestion
9//! format for GitHub Advanced Security (Code Scanning), GitLab Ultra,
10//! Sonatype Lifecycle, and most other enterprise security platforms.
11//!
12//! The emitter maps `ssg audit`'s domain model 1:1 to SARIF:
13//!
14//! | ssg field                       | SARIF field                                            |
15//! | ------------------------------- | ------------------------------------------------------ |
16//! | [`crate::audit::AuditReport`]   | `runs[0]`                                              |
17//! | [`crate::audit::GateResult`]    | `runs[0].tool.driver.rules[]` (one per gate)           |
18//! | [`crate::audit::Finding`]       | `runs[0].results[]`                                    |
19//! | [`crate::audit::Finding::gate`] | `result.ruleId` (or `<gate>.<code>` when code present) |
20//! | [`crate::audit::Severity`]      | `result.level` (`error` / `warning` / `note`)          |
21//! | [`crate::audit::Finding::path`] | `result.locations[0].physicalLocation.artifactLocation.uri` |
22//! | [`crate::audit::Finding::message`] | `result.message.text`                               |
23//!
24//! Site-wide findings (no `path`) omit the `locations` array per
25//! the SARIF spec.
26//!
27//! ## Acceptance criteria (issue #562)
28//!
29//! 1. `ssg audit --format=sarif > out.sarif` produces a file that
30//!    passes the [SARIF v2.1.0 validator](https://sarifweb.azurewebsites.net/Validation).
31//! 2. New CI step uploads the SARIF artefact via
32//!    `github/codeql-action/upload-sarif@v3`.
33//! 3. Findings appear in the GitHub Security tab on a deliberately-
34//!    broken fixture.
35
36use crate::audit::{AuditReport, Finding, GateResult, Severity};
37
38/// Serialises `report` to a pretty-printed SARIF v2.1.0 JSON string.
39///
40/// # Examples
41///
42/// ```
43/// use ssg::audit::AuditReport;
44/// use ssg::audit::output::sarif::format;
45///
46/// let s = format(&AuditReport { gates: vec![] });
47/// // Top-level shape: $schema, version, runs[].
48/// assert!(s.contains("\"version\": \"2.1.0\""));
49/// assert!(s.contains("\"runs\""));
50/// ```
51#[must_use]
52pub fn format(report: &AuditReport) -> String {
53    let runs = serde_json::json!([build_run(report)]);
54    let doc = serde_json::json!({
55        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
56        "version": "2.1.0",
57        "runs": runs,
58    });
59    // Infallible: every value is a String / number / Map / Vec /
60    // serde_json::Value. The `Result<String>` path is impossible to
61    // exercise in safe Rust, which is why we don't surface one to
62    // callers.
63    stringify(serde_json::to_string_pretty(&doc))
64}
65
66/// Unwraps the serialised document, degrading to a minimal (but
67/// schema-valid) empty SARIF document on the impossible-in-practice
68/// serialisation failure.
69fn stringify(res: Result<String, serde_json::Error>) -> String {
70    res.unwrap_or_else(|_| String::from("{\"version\":\"2.1.0\",\"runs\":[]}"))
71}
72
73/// Builds the per-run SARIF object: tool descriptor + rules + results.
74fn build_run(report: &AuditReport) -> serde_json::Value {
75    serde_json::json!({
76        "tool": {
77            "driver": {
78                "name":            "ssg",
79                "informationUri":  "https://github.com/sebastienrousseau/static-site-generator",
80                "semanticVersion": env!("CARGO_PKG_VERSION"),
81                "rules":           rules_for(report),
82            }
83        },
84        "results": results_for(report),
85    })
86}
87
88/// One SARIF rule descriptor per gate that appears in the report.
89fn rules_for(report: &AuditReport) -> Vec<serde_json::Value> {
90    report.gates.iter().map(rule_for_gate).collect()
91}
92
93/// Synthesises a `reportingDescriptor` for a single gate.
94fn rule_for_gate(g: &GateResult) -> serde_json::Value {
95    serde_json::json!({
96        "id":               g.name,
97        "name":             g.name,
98        "shortDescription": { "text": format!("ssg audit gate `{}`", g.name) },
99        "fullDescription":  { "text": describe_gate(&g.name) },
100        "helpUri":          format!(
101            "https://github.com/sebastienrousseau/static-site-generator#audit-gate-{}",
102            g.name
103        ),
104    })
105}
106
107/// Returns a short human-readable description for known gates.
108/// Falls back to a generic phrasing for unknown gate names so the
109/// emitter never crashes on a custom gate.
110fn describe_gate(name: &str) -> String {
111    match name {
112        "wcag"          => "WCAG 2.2 Level AA conformance checks against the built HTML.".to_string(),
113        "jsonld"        => "Schema.org JSON-LD blocks: schema validity, required fields, type coherence.".to_string(),
114        "hreflang"      => "Multilingual hreflang link-rel completeness and reciprocity.".to_string(),
115        "csp_sri"       => "Content Security Policy + Subresource Integrity hashes.".to_string(),
116        "pqc_tls"       => "Post-quantum-aware TLS and HSTS edge headers.".to_string(),
117        "html5"         => "HTML5 structural validity (doctype, charset, landmarks).".to_string(),
118        "links"         => "Internal link resolution; optional external HEAD probing.".to_string(),
119        "metadata"      => "Open Graph + Twitter Card + canonical chain completeness.".to_string(),
120        "markdownlint"  => "Markdown formatting and frontmatter sanity.".to_string(),
121        "performance"   => "Lighthouse-aligned performance budgets (Speed Index, LCP, CLS).".to_string(),
122        "ai_discovery"  => "agents.txt + .well-known/{ai-plugin.json,mcp.json} discovery files.".to_string(),
123        "feeds"         => "RSS / Atom / JSON Feed schema validity.".to_string(),
124        "images"        => "Responsive <picture>, alt text presence, modern format coverage.".to_string(),
125        "search_index"  => "ssg-search artefacts: embeddings.bin, model.bin, tokenizer.bin, manifest.json.".to_string(),
126        "lang_consistency" => "JSON-LD inLanguage vs <html lang> base-language consistency.".to_string(),
127        other            => format!("ssg audit gate `{other}` (third-party).") ,
128    }
129}
130
131/// SARIF `result` array for every finding across every gate.
132fn results_for(report: &AuditReport) -> Vec<serde_json::Value> {
133    let mut out = Vec::new();
134    for gate in &report.gates {
135        for finding in &gate.findings {
136            out.push(result_for_finding(finding));
137        }
138    }
139    out
140}
141
142/// Maps one [`Finding`] to one SARIF `result` object.
143fn result_for_finding(f: &Finding) -> serde_json::Value {
144    let mut obj = serde_json::Map::new();
145
146    // ruleId: prefer `<gate>.<code>` when code is present so two
147    // different findings under the same gate stay distinguishable;
148    // otherwise fall back to the bare gate name.
149    let rule_id = match &f.code {
150        Some(code) => format!("{}.{}", f.gate, code),
151        None => f.gate.clone(),
152    };
153    let _ = obj.insert("ruleId".into(), serde_json::Value::String(rule_id));
154
155    // level: SARIF's three canonical levels.
156    let level = match f.severity {
157        Severity::Error => "error",
158        Severity::Warn => "warning",
159        Severity::Info => "note",
160    };
161    let _ = obj.insert("level".into(), serde_json::Value::String(level.into()));
162
163    let _ =
164        obj.insert("message".into(), serde_json::json!({ "text": f.message }));
165
166    // SARIF spec allows omitting `locations` for site-wide findings,
167    // but GitHub Code Scanning requires every result to carry at
168    // least one location. For findings without a specific path we
169    // emit a synthetic site-wide location pointing at the audit
170    // surface itself; downstream tooling can de-prioritise the
171    // "<site-wide>" URI if it wants per-file grouping only.
172    let uri = f.path.as_deref().unwrap_or("<site-wide>");
173    let _ = obj.insert(
174        "locations".into(),
175        serde_json::json!([{
176            "physicalLocation": {
177                "artifactLocation": {
178                    "uri": uri,
179                    "uriBaseId": "%SRCROOT%",
180                }
181            }
182        }]),
183    );
184
185    // Custom properties block carrying gate context.
186    let _ = obj.insert(
187        "properties".into(),
188        serde_json::json!({
189            "gate":     f.gate,
190            "severity": severity_str(f.severity),
191        }),
192    );
193
194    serde_json::Value::Object(obj)
195}
196
197/// Returns the lowercase canonical string for a severity.
198const fn severity_str(sev: Severity) -> &'static str {
199    match sev {
200        Severity::Error => "error",
201        Severity::Warn => "warn",
202        Severity::Info => "info",
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::audit::{Finding, GateResult, SeverityCounts};
210
211    fn empty_report() -> AuditReport {
212        AuditReport { gates: vec![] }
213    }
214
215    fn report_with_two_findings() -> AuditReport {
216        AuditReport {
217            gates: vec![
218                GateResult {
219                    name: "wcag".to_string(),
220                    skipped: false,
221                    skip_reason: None,
222                    severity_counts: SeverityCounts {
223                        info: 0,
224                        warn: 0,
225                        error: 1,
226                    },
227                    findings: vec![Finding::new(
228                        "wcag",
229                        Severity::Error,
230                        "<img> missing alt",
231                    )
232                    .with_code("WCAG-1.1.1")
233                    .with_path("blog/post.html")],
234                },
235                GateResult {
236                    name: "hreflang".to_string(),
237                    skipped: false,
238                    skip_reason: None,
239                    severity_counts: SeverityCounts {
240                        info: 1,
241                        warn: 0,
242                        error: 0,
243                    },
244                    findings: vec![Finding::new(
245                        "hreflang",
246                        Severity::Info,
247                        "no-op site-wide finding",
248                    )],
249                },
250            ],
251        }
252    }
253
254    #[test]
255    fn emits_top_level_sarif_shape() {
256        let s = format(&empty_report());
257        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
258        assert_eq!(v["version"], "2.1.0");
259        assert!(v["$schema"].as_str().unwrap().contains("sarif-spec"));
260        assert!(v["runs"].is_array());
261        assert_eq!(v["runs"].as_array().unwrap().len(), 1);
262    }
263
264    #[test]
265    fn driver_carries_name_and_semantic_version() {
266        let s = format(&empty_report());
267        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
268        let driver = &v["runs"][0]["tool"]["driver"];
269        assert_eq!(driver["name"], "ssg");
270        assert_eq!(driver["semanticVersion"], env!("CARGO_PKG_VERSION"));
271        assert!(driver["informationUri"]
272            .as_str()
273            .unwrap()
274            .starts_with("https://"));
275    }
276
277    #[test]
278    fn one_rule_per_gate() {
279        let s = format(&report_with_two_findings());
280        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
281        let rules = v["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
282        assert_eq!(rules.len(), 2);
283        let ids: Vec<&str> =
284            rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
285        assert!(ids.contains(&"wcag"));
286        assert!(ids.contains(&"hreflang"));
287    }
288
289    #[test]
290    fn known_gate_has_description() {
291        // Spot-check that the describe_gate helper is wired.
292        let s = format(&report_with_two_findings());
293        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
294        let wcag = v["runs"][0]["tool"]["driver"]["rules"][0]
295            ["fullDescription"]["text"]
296            .as_str()
297            .unwrap()
298            .to_string();
299        // wcag is the first gate in our fixture.
300        assert!(wcag.contains("WCAG"));
301    }
302
303    #[test]
304    fn unknown_gate_uses_generic_description() {
305        let report = AuditReport {
306            gates: vec![GateResult {
307                name: "thirdparty".to_string(),
308                skipped: false,
309                skip_reason: None,
310                severity_counts: SeverityCounts::default(),
311                findings: vec![],
312            }],
313        };
314        let s = format(&report);
315        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
316        let txt = v["runs"][0]["tool"]["driver"]["rules"][0]["fullDescription"]
317            ["text"]
318            .as_str()
319            .unwrap()
320            .to_string();
321        assert!(txt.contains("third-party"));
322    }
323
324    #[test]
325    fn severity_to_sarif_level_mapping() {
326        let report = report_with_two_findings();
327        let s = format(&report);
328        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
329        let results = v["runs"][0]["results"].as_array().unwrap();
330        assert_eq!(results[0]["level"], "error");
331        assert_eq!(results[1]["level"], "note");
332    }
333
334    #[test]
335    fn warn_severity_maps_to_warning_level_and_warn_str() {
336        // Covers the `Severity::Warn` arm of both `level` (line 151)
337        // and `severity_str` (line 194) which are otherwise unhit by
338        // the Error+Info fixture above.
339        let report = AuditReport {
340            gates: vec![GateResult {
341                name: "links".to_string(),
342                skipped: false,
343                skip_reason: None,
344                severity_counts: SeverityCounts {
345                    info: 0,
346                    warn: 1,
347                    error: 0,
348                },
349                findings: vec![Finding::new(
350                    "links",
351                    Severity::Warn,
352                    "soft warning",
353                )
354                .with_path("blog/x.html")],
355            }],
356        };
357        let s = format(&report);
358        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
359        let r0 = &v["runs"][0]["results"][0];
360        assert_eq!(r0["level"], "warning");
361        assert_eq!(r0["properties"]["severity"], "warn");
362    }
363
364    #[test]
365    fn rule_id_includes_code_when_present() {
366        let s = format(&report_with_two_findings());
367        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
368        let r0 = &v["runs"][0]["results"][0];
369        assert_eq!(r0["ruleId"], "wcag.WCAG-1.1.1");
370    }
371
372    #[test]
373    fn rule_id_omits_code_when_absent() {
374        let s = format(&report_with_two_findings());
375        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
376        let r1 = &v["runs"][0]["results"][1];
377        assert_eq!(r1["ruleId"], "hreflang");
378    }
379
380    #[test]
381    fn path_emits_physical_location_block() {
382        let s = format(&report_with_two_findings());
383        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
384        let r0 = &v["runs"][0]["results"][0];
385        assert_eq!(
386            r0["locations"][0]["physicalLocation"]["artifactLocation"]["uri"],
387            "blog/post.html"
388        );
389        assert_eq!(
390            r0["locations"][0]["physicalLocation"]["artifactLocation"]
391                ["uriBaseId"],
392            "%SRCROOT%"
393        );
394    }
395
396    #[test]
397    fn site_wide_finding_emits_synthetic_location() {
398        // GitHub Code Scanning rejects results without locations[],
399        // so site-wide findings now carry a synthetic `<site-wide>`
400        // URI rather than omitting the field.
401        let s = format(&report_with_two_findings());
402        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
403        let r1 = &v["runs"][0]["results"][1];
404        let loc = &r1["locations"][0]["physicalLocation"]["artifactLocation"];
405        assert_eq!(loc["uri"], "<site-wide>");
406        assert_eq!(loc["uriBaseId"], "%SRCROOT%");
407    }
408
409    #[test]
410    fn properties_carry_gate_and_severity() {
411        let s = format(&report_with_two_findings());
412        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
413        let r0 = &v["runs"][0]["results"][0];
414        assert_eq!(r0["properties"]["gate"], "wcag");
415        assert_eq!(r0["properties"]["severity"], "error");
416    }
417
418    #[test]
419    fn message_text_carries_human_readable() {
420        let s = format(&report_with_two_findings());
421        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
422        assert_eq!(
423            v["runs"][0]["results"][0]["message"]["text"],
424            "<img> missing alt"
425        );
426    }
427
428    #[test]
429    fn stringify_falls_back_to_minimal_document_on_error() {
430        let err = serde_json::from_str::<serde_json::Value>("{")
431            .expect_err("truncated JSON must fail");
432        let s = stringify(Err(err));
433        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
434        assert_eq!(v["version"], "2.1.0");
435        assert!(v["runs"].as_array().unwrap().is_empty());
436    }
437
438    #[test]
439    fn empty_report_emits_empty_results_array() {
440        let s = format(&empty_report());
441        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
442        let results = v["runs"][0]["results"].as_array().unwrap();
443        assert!(results.is_empty());
444    }
445}