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
136pub 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
217fn 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 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
251pub 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 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 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
381fn is_advisory(id: &str) -> bool {
384 id.starts_with("advisory_")
385}
386
387fn 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
416pub 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
429fn 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
453pub 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
474fn 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 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}