Skip to main content

rustinel_core/
report.rs

1use crate::diff::LockfileDiff;
2use crate::lockfile::LockfileModel;
3use crate::markdown;
4use crate::policy::{Decision, PolicyDecision};
5use crate::risk::ProjectRisk;
6use crate::sarif;
7use crate::signals::{RiskSignal, Severity};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum OutputFormat {
12    Human,
13    Json,
14    Markdown,
15    Sarif,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ToolInfo {
20    pub name: String,
21    pub version: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct AnalysisInfo {
26    pub mode: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub generated_at: Option<String>,
29    pub offline: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DiffInfo {
34    pub base_score: u8,
35    pub head_score: u8,
36    pub delta: i32,
37    pub added: Vec<String>,
38    pub removed: Vec<String>,
39    pub changed: Vec<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct RustinelReport {
44    pub schema_version: String,
45    pub tool: ToolInfo,
46    pub analysis: AnalysisInfo,
47    pub project: ProjectRisk,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub diff: Option<DiffInfo>,
50    pub policy: PolicyDecision,
51    pub packages_count: usize,
52    pub findings: Vec<RiskSignal>,
53}
54
55pub const TOOL_NAME: &str = "rustinel";
56pub const SCHEMA_VERSION: &str = "1.0";
57
58fn tool_info() -> ToolInfo {
59    ToolInfo {
60        name: TOOL_NAME.into(),
61        version: env!("CARGO_PKG_VERSION").into(),
62    }
63}
64
65#[allow(clippy::too_many_arguments)]
66pub fn build_check_report(
67    lock: LockfileModel,
68    findings: Vec<RiskSignal>,
69    risk: ProjectRisk,
70    policy: PolicyDecision,
71    offline: bool,
72    generated_at: Option<String>,
73) -> RustinelReport {
74    RustinelReport {
75        schema_version: SCHEMA_VERSION.into(),
76        tool: tool_info(),
77        analysis: AnalysisInfo {
78            mode: "check".into(),
79            generated_at,
80            offline,
81        },
82        project: risk,
83        diff: None,
84        policy,
85        packages_count: lock.packages.len(),
86        findings,
87    }
88}
89
90#[allow(clippy::too_many_arguments)]
91pub fn build_diff_report(
92    head_lock: LockfileModel,
93    findings: Vec<RiskSignal>,
94    head_risk: ProjectRisk,
95    base_score: u8,
96    diff: LockfileDiff,
97    policy: PolicyDecision,
98    offline: bool,
99    generated_at: Option<String>,
100) -> RustinelReport {
101    let head_score = head_risk.score;
102    let diff_info = DiffInfo {
103        base_score,
104        head_score,
105        delta: head_score as i32 - base_score as i32,
106        added: diff.added,
107        removed: diff.removed,
108        changed: diff.changed,
109    };
110    RustinelReport {
111        schema_version: SCHEMA_VERSION.into(),
112        tool: tool_info(),
113        analysis: AnalysisInfo {
114            mode: "diff".into(),
115            generated_at,
116            offline,
117        },
118        project: head_risk,
119        diff: Some(diff_info),
120        policy,
121        packages_count: head_lock.packages.len(),
122        findings,
123    }
124}
125
126fn severity_tag(sev: Severity) -> &'static str {
127    match sev {
128        Severity::Critical => "CRIT",
129        Severity::High => "HIGH",
130        Severity::Medium => "MED ",
131        Severity::Low => "LOW ",
132        Severity::Info => "INFO",
133    }
134}
135
136/// A 20-cell unicode gauge for a 0–100 score, e.g. `[█████████████░░░░░░░]`.
137pub fn score_bar(score: u8) -> String {
138    let filled = ((score as usize) * 20 / 100).min(20);
139    format!("[{}{}]", "█".repeat(filled), "░".repeat(20 - filled))
140}
141
142pub fn to_human(report: &RustinelReport) -> String {
143    let mut out = String::new();
144    out.push_str(&format!("{} {}\n\n", report.tool.name, report.tool.version));
145    if let Some(diff) = &report.diff {
146        out.push_str(&format!(
147            "Supply-chain risk: {} -> {} ({:+}, {})\n",
148            diff.base_score,
149            diff.head_score,
150            diff.delta,
151            report.project.level.as_str().to_uppercase()
152        ));
153    } else {
154        out.push_str(&format!(
155            "Project risk: {}/100 {}\n",
156            report.project.score,
157            report.project.level.as_str().to_uppercase()
158        ));
159    }
160    out.push_str(&format!("  {}\n", score_bar(report.project.score)));
161    out.push_str(&format!("Policy: {}\n", report.policy.profile));
162    out.push_str(&format!(
163        "Decision: {}\n",
164        report.policy.decision.as_str().to_uppercase()
165    ));
166    out.push_str(&format!("Packages: {}\n", report.packages_count));
167
168    let shown: Vec<&RiskSignal> = report
169        .findings
170        .iter()
171        .filter(|f| f.severity > Severity::Info)
172        .take(10)
173        .collect();
174    if !shown.is_empty() {
175        out.push_str("\nTop findings:\n");
176        for f in shown {
177            let detail = f
178                .evidence
179                .first()
180                .map(|e| e.summary.as_str())
181                .unwrap_or(&f.id);
182            out.push_str(&format!(
183                "  [{}] {}: {}\n",
184                severity_tag(f.severity),
185                f.package,
186                sanitize_terminal(first_line(detail))
187            ));
188            if let Some(path) = path_evidence(f) {
189                out.push_str(&format!("        ↳ {}\n", sanitize_terminal(path)));
190            }
191        }
192    }
193
194    if !report.policy.violations.is_empty() {
195        out.push_str("\nPolicy violations:\n");
196        for v in &report.policy.violations {
197            out.push_str(&format!("  - {}\n", sanitize_terminal(v)));
198        }
199    }
200    if !report.policy.review_items.is_empty() {
201        out.push_str("\nReview required:\n");
202        for v in &report.policy.review_items {
203            out.push_str(&format!("  - {}\n", sanitize_terminal(v)));
204        }
205    }
206    out
207}
208
209pub fn to_json(report: &RustinelReport) -> Result<String, serde_json::Error> {
210    serde_json::to_string_pretty(report)
211}
212
213pub fn to_sarif(report: &RustinelReport) -> Result<String, serde_json::Error> {
214    serde_json::to_string_pretty(&sarif::build(report))
215}
216
217// Plain-text, monochrome markers — readable in any terminal, diff, or code
218// review, and free of emoji. The level marker is a rising block glyph that
219// matches the `score_bar` visual language; status/severity use bracketed,
220// fixed-width tags that read like linter/compiler output.
221fn level_marker(level: crate::risk::RiskLevel) -> &'static str {
222    use crate::risk::RiskLevel::*;
223    match level {
224        Low => "▁",
225        Medium => "▃",
226        High => "▅",
227        Critical => "▇",
228    }
229}
230
231fn decision_marker(d: Decision) -> &'static str {
232    match d {
233        Decision::Pass => "[ok]",
234        Decision::Warn => "[warn]",
235        Decision::ReviewRequired => "[review]",
236        Decision::Fail => "[fail]",
237    }
238}
239
240fn severity_marker(sev: Severity) -> &'static str {
241    // Fixed six-column width so list items stay aligned in a monospace view.
242    match sev {
243        Severity::Critical => "[crit]",
244        Severity::High => "[high]",
245        Severity::Medium => "[med] ",
246        Severity::Low => "[low] ",
247        Severity::Info => "[info]",
248    }
249}
250
251/// Render a GitHub-PR-ready Markdown comment. All untrusted strings (package
252/// names, finding details) are escaped — see [`crate::markdown`].
253pub fn to_markdown(report: &RustinelReport) -> String {
254    let mut out = String::new();
255    let level = report.project.level;
256    out.push_str("## rustinel — supply-chain risk\n\n");
257
258    if let Some(diff) = &report.diff {
259        out.push_str(&format!(
260            "{} **{} → {} ({:+})** · {} · Decision: {} **{}**\n\n",
261            level_marker(level),
262            diff.base_score,
263            diff.head_score,
264            diff.delta,
265            level.as_str().to_uppercase(),
266            decision_marker(report.policy.decision),
267            report.policy.decision.as_str().replace('_', " ")
268        ));
269    } else {
270        out.push_str(&format!(
271            "{} **{}/100 {}** · Decision: {} **{}**\n\n",
272            level_marker(level),
273            report.project.score,
274            level.as_str().to_uppercase(),
275            decision_marker(report.policy.decision),
276            report.policy.decision.as_str().replace('_', " ")
277        ));
278    }
279    out.push_str(&format!(
280        "`{}`  ·  policy: **{}**  ·  {} packages\n\n",
281        score_bar(report.project.score),
282        markdown::escape(&report.policy.profile),
283        report.packages_count
284    ));
285
286    // Split contributors into known advisories (parity with cargo-audit) and
287    // proactive signals (the pre-advisory risk an advisory-only scanner cannot
288    // see). Surfacing the second group on every PR is rustinel's reason to exist.
289    let advisories: Vec<&RiskSignal> = report
290        .findings
291        .iter()
292        .filter(|f| f.severity > Severity::Info && is_advisory(&f.id))
293        .collect();
294    let signals: Vec<&RiskSignal> = report
295        .findings
296        .iter()
297        .filter(|f| f.severity > Severity::Info && !is_advisory(&f.id))
298        .collect();
299
300    if !advisories.is_empty() {
301        out.push_str("### Known advisories\n");
302        out.push_str("<sub>matched against the RustSec database — the same set `cargo audit` reports</sub>\n\n");
303        for f in advisories.iter().take(10) {
304            render_contributor(&mut out, f);
305        }
306        out.push('\n');
307    }
308    if !signals.is_empty() {
309        out.push_str("### Proactive signals\n");
310        out.push_str(
311            "<sub>structural risk an advisory-only scanner reports none of — \
312             [why](https://github.com/kosiorkosa47/rustinel/blob/main/docs/PROACTIVE-DETECTION.md)</sub>\n\n",
313        );
314        for f in signals.iter().take(10) {
315            render_contributor(&mut out, f);
316        }
317        out.push('\n');
318    }
319
320    if !report.policy.violations.is_empty() {
321        out.push_str("### Blocking items\n\n");
322        for v in &report.policy.violations {
323            out.push_str(&format!("- {}\n", markdown::escape(v)));
324        }
325        out.push('\n');
326    }
327    if !report.policy.review_items.is_empty() {
328        out.push_str("### Review required\n\n");
329        for v in &report.policy.review_items {
330            out.push_str(&format!("- {}\n", markdown::escape(v)));
331        }
332        out.push('\n');
333    }
334
335    // Suggested actions: unique recommendations from the findings shown above —
336    // the same `take(10)` advisory and signal sets. Deriving from the shown sets
337    // (not all findings) guarantees an action never references a finding the
338    // comment never displayed, in the rare case a group is truncated past 10.
339    let mut actions: Vec<String> = Vec::new();
340    for f in advisories.iter().take(10).chain(signals.iter().take(10)) {
341        let rec = f.recommendation.trim();
342        if !rec.is_empty() && !actions.iter().any(|a| a == rec) {
343            actions.push(rec.to_string());
344        }
345    }
346    if !actions.is_empty() {
347        out.push_str("### Suggested actions\n\n");
348        for a in actions.iter().take(6) {
349            out.push_str(&format!("- {}\n", markdown::escape(a)));
350        }
351        out.push('\n');
352    }
353
354    if let Some(diff) = &report.diff {
355        out.push_str("<details>\n<summary>Dependency changes</summary>\n\n");
356        render_list(&mut out, "Added", &diff.added);
357        render_list(&mut out, "Changed", &diff.changed);
358        render_list(&mut out, "Removed", &diff.removed);
359        out.push_str("</details>\n");
360    }
361
362    out.push_str(
363        "\n<sub>rustinel · static, offline supply-chain risk diff for Cargo · \
364         matches `cargo audit` on advisories, adds the pre-advisory signals it can't see</sub>\n",
365    );
366    out
367}
368
369fn render_list(out: &mut String, title: &str, items: &[String]) {
370    out.push_str(&format!("{title}:\n\n"));
371    if items.is_empty() {
372        out.push_str("- none\n\n");
373    } else {
374        for item in items {
375            out.push_str(&format!("- `{}`\n", markdown::escape_code(item)));
376        }
377        out.push('\n');
378    }
379}
380
381/// A finding produced by matching the RustSec advisory database (id prefixed
382/// `advisory_`), as opposed to a proactive static/metadata signal.
383fn is_advisory(id: &str) -> bool {
384    id.starts_with("advisory_")
385}
386
387/// Render one finding as a Markdown bullet: severity, package, the first line of
388/// its evidence, and the dependency path when known. Shared by both the advisory
389/// and proactive-signal sections of the PR comment.
390fn render_contributor(out: &mut String, f: &RiskSignal) {
391    let detail = f
392        .evidence
393        .first()
394        .map(|e| e.summary.as_str())
395        .unwrap_or(&f.id);
396    out.push_str(&format!(
397        "- {} `{}` — {}\n",
398        severity_marker(f.severity),
399        markdown::escape_code(&f.package),
400        markdown::escape(first_line(detail))
401    ));
402    if let Some(path) = path_evidence(f) {
403        out.push_str(&format!("  - {}\n", markdown::escape(path)));
404    }
405}
406
407pub fn render(report: &RustinelReport, format: OutputFormat) -> Result<String, serde_json::Error> {
408    Ok(match format {
409        OutputFormat::Human => to_human(report),
410        OutputFormat::Json => to_json(report)?,
411        OutputFormat::Markdown => to_markdown(report),
412        OutputFormat::Sarif => to_sarif(report)?,
413    })
414}
415
416/// True when the policy decision should cause a non-zero exit code.
417pub fn is_failing(report: &RustinelReport, fail_on_review_required: bool) -> bool {
418    match report.policy.decision {
419        Decision::Fail => true,
420        Decision::ReviewRequired => fail_on_review_required,
421        Decision::Warn | Decision::Pass => false,
422    }
423}
424
425fn first_line(s: &str) -> &str {
426    s.lines().next().unwrap_or(s)
427}
428
429/// Neutralize characters that could corrupt or spoof the plain-text terminal
430/// report: C0/C1 control codes (newlines forging fake report lines, ANSI `ESC`
431/// sequences recoloring/erasing output) and the bidirectional-override format
432/// characters behind "Trojan Source" visual spoofing. Untrusted text — a
433/// dependency's manifest `license` reflected into a policy message, or a
434/// filesystem path used as evidence — reaches the human renderer; the markdown
435/// renderer escapes the same fields via `markdown::escape`, and JSON/SARIF go
436/// through serde (which `\u`-escapes controls), so this is the terminal-output
437/// equivalent. Each offending character becomes a single space, keeping the
438/// field on one line and the attack inert without dropping legible content.
439fn sanitize_terminal(s: &str) -> String {
440    s.chars()
441        .map(|c| {
442            let bidi_override =
443                ('\u{202A}'..='\u{202E}').contains(&c) || ('\u{2066}'..='\u{2069}').contains(&c);
444            if c.is_control() || bidi_override {
445                ' '
446            } else {
447                c
448            }
449        })
450        .collect()
451}
452
453/// A human-readable "how was this score computed" breakdown, appended to the
454/// `check`/`diff` report when `--explain` is set.
455pub fn score_explanation(report: &RustinelReport) -> String {
456    let ex = crate::risk::explain(&report.findings);
457    let mut out = String::from("\nScore breakdown:\n");
458    if ex.critical_pin {
459        out.push_str("  pinned to 100 by a critical advisory\n");
460    }
461    if ex.contributions.is_empty() {
462        out.push_str("  (no risk-contributing signals)\n");
463    }
464    for (label, points) in &ex.contributions {
465        out.push_str(&format!("  {:>5.1}  {}\n", points, label));
466    }
467    out.push_str(&format!(
468        "  -----\n  {:>5}  total ({}/100)\n",
469        "=", ex.total
470    ));
471    out
472}
473
474/// The dependency-path evidence summary, if present.
475fn path_evidence(finding: &RiskSignal) -> Option<&str> {
476    finding
477        .evidence
478        .iter()
479        .find(|e| e.kind == "path")
480        .map(|e| e.summary.as_str())
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::risk::{level_for_score, PackageRisk, RiskLevel};
487    use crate::signals::Evidence;
488
489    fn sample_report() -> RustinelReport {
490        RustinelReport {
491            schema_version: SCHEMA_VERSION.into(),
492            tool: ToolInfo {
493                name: TOOL_NAME.into(),
494                version: "0.1.0".into(),
495            },
496            analysis: AnalysisInfo {
497                mode: "check".into(),
498                generated_at: None,
499                offline: true,
500            },
501            project: ProjectRisk {
502                score: 32,
503                level: level_for_score(32),
504                max_package_score: 32,
505                packages: vec![PackageRisk {
506                    package: "openssl-sys@0.9.99".into(),
507                    score: 32,
508                    level: RiskLevel::Medium,
509                }],
510            },
511            diff: None,
512            policy: PolicyDecision {
513                decision: Decision::ReviewRequired,
514                profile: "balanced".into(),
515                violations: vec![],
516                warnings: vec![],
517                review_items: vec!["`openssl-sys@0.9.99` is a native/FFI dependency".into()],
518                ignored_advisories: vec![],
519            },
520            packages_count: 4,
521            findings: vec![RiskSignal {
522                id: "native_ffi_detected".into(),
523                package: "openssl-sys@0.9.99".into(),
524                severity: Severity::High,
525                weight: 20,
526                confidence: 0.9,
527                evidence: vec![Evidence::new("heuristic", "native FFI dependency detected")],
528                recommendation: "Review the native dependency before merging.".into(),
529            }],
530        }
531    }
532
533    #[test]
534    fn json_has_schema_version() {
535        let json = to_json(&sample_report()).unwrap();
536        assert!(json.contains("\"schema_version\": \"1.0\""));
537        assert!(json.contains("\"name\": \"rustinel\""));
538    }
539
540    #[test]
541    fn sarif_is_valid_json_and_maps_levels() {
542        let sarif = to_sarif(&sample_report()).unwrap();
543        let v: serde_json::Value = serde_json::from_str(&sarif).unwrap();
544        assert_eq!(v["version"], "2.1.0");
545        assert_eq!(v["runs"][0]["results"][0]["level"], "error");
546    }
547
548    #[test]
549    fn markdown_escapes_injection() {
550        let mut report = sample_report();
551        report.findings[0].package = "<img src=x onerror=alert(1)>@1".into();
552        report.findings[0].evidence[0].summary = "</script><h1>pwn</h1>".into();
553        let md = to_markdown(&report);
554        assert!(!md.contains("<img src=x"));
555        assert!(!md.contains("<h1>pwn"));
556    }
557
558    #[test]
559    fn human_output_mentions_project_risk() {
560        let h = to_human(&sample_report());
561        assert!(h.contains("rustinel"));
562        assert!(h.contains("Decision: REVIEW_REQUIRED"));
563    }
564
565    #[test]
566    fn human_output_neutralizes_control_chars() {
567        // A denied-license string is attacker-controllable via a dependency's
568        // Cargo.toml. A newline must not forge a second report line and an ANSI
569        // ESC must not reach the terminal — the markdown renderer escapes the
570        // same field, so the human renderer must neutralize it too.
571        let mut report = sample_report();
572        report
573            .policy
574            .violations
575            .push("pkg uses denied license GPL-3.0\n  - all clear\u{1b}[2J".into());
576        let h = to_human(&report);
577        assert!(!h.contains('\u{1b}'), "ANSI ESC must be neutralized");
578        let line = h
579            .lines()
580            .find(|l| l.contains("GPL-3.0"))
581            .expect("the violation line is present");
582        assert!(
583            line.contains("all clear"),
584            "the newline must be neutralized so the injected text cannot form its own line"
585        );
586    }
587
588    #[test]
589    fn sanitize_terminal_replaces_controls_and_bidi() {
590        assert_eq!(sanitize_terminal("a\nb\tc"), "a b c");
591        assert_eq!(sanitize_terminal("x\u{1b}[31my"), "x [31my");
592        assert_eq!(sanitize_terminal("a\u{202E}b"), "a b");
593        assert_eq!(sanitize_terminal("normal text"), "normal text");
594    }
595}