Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/report/cbom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ fn deterministic_uuid(data: &str) -> String {
///
/// Pure function: returns a `serde_json::Value`. Separated from [`print_cbom`]
/// so tests can inspect the structured output without capturing stdout.
fn build_cbom(findings: &[Finding]) -> (serde_json::Value, bool) {
pub fn build_cbom(findings: &[Finding]) -> (serde_json::Value, bool) {
// Group findings by crypto_algorithm
let mut groups: BTreeMap<String, Vec<&Finding>> = BTreeMap::new();
for f in findings {
Expand Down
8 changes: 6 additions & 2 deletions src/report/sarif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ fn path_to_uri(path: &str) -> String {
format!("file://{encoded}")
}

pub fn print_sarif(findings: &[Finding]) {
pub fn build_sarif(findings: &[Finding]) -> serde_json::Value {
let results: Vec<_> = findings
.iter()
.map(|f| {
Expand Down Expand Up @@ -107,8 +107,12 @@ pub fn print_sarif(findings: &[Finding]) {
}]
});

sarif
}

pub fn print_sarif(findings: &[Finding]) {
println!(
"{}",
serde_json::to_string_pretty(&sarif).expect("Failed to serialize SARIF")
serde_json::to_string_pretty(&build_sarif(findings)).expect("Failed to serialize SARIF")
);
}
259 changes: 258 additions & 1 deletion src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ struct TuiApp {
source_context_cache: Option<SourceContextCache>,
open_focus: OpenFocus,
action_menu: Option<ActionMenu>,
export_menu: Option<ExportMenu>,
severity_picker: Option<SeverityPicker>,
review_states: HashMap<String, ReviewState>,
}
Expand Down Expand Up @@ -181,6 +182,7 @@ impl TuiApp {
source_context_cache: None,
open_focus: OpenFocus::Finding,
action_menu: None,
export_menu: None,
severity_picker: None,
review_states: HashMap::new(),
}
Expand Down Expand Up @@ -284,6 +286,10 @@ impl TuiApp {
return self.handle_action_menu_key(key.code);
}

if self.export_menu.is_some() {
return self.handle_export_menu_key(key.code);
}

if self.search_mode {
return self.handle_search_key(key.code);
}
Expand Down Expand Up @@ -335,6 +341,7 @@ impl TuiApp {
self.show_compliance_panel = !self.show_compliance_panel;
ControlFlow::Continue
}
KeyCode::Char('e') => self.open_export_menu(),
KeyCode::Char('i') => self.open_action_menu(),
KeyCode::PageDown => {
self.scroll_detail(8);
Expand Down Expand Up @@ -472,6 +479,93 @@ impl TuiApp {
}
}

fn open_export_menu(&mut self) -> ControlFlow {
if self.result.is_none() {
self.push_runtime_notice("no results to export".to_string());
return ControlFlow::Continue;
}
self.export_menu = Some(ExportMenu {
formats: vec![ExportFormat::Cbom, ExportFormat::Json, ExportFormat::Sarif],
selected: 0,
});
ControlFlow::Continue
}

fn handle_export_menu_key(&mut self, key: KeyCode) -> ControlFlow {
let Some(menu) = self.export_menu.as_mut() else {
return ControlFlow::Continue;
};

match key {
KeyCode::Esc | KeyCode::Char('q') => {
self.export_menu = None;
ControlFlow::Continue
}
KeyCode::Char('j') | KeyCode::Down => {
menu.selected = (menu.selected + 1).min(menu.formats.len().saturating_sub(1));
ControlFlow::Continue
}
KeyCode::Char('k') | KeyCode::Up => {
menu.selected = menu.selected.saturating_sub(1);
ControlFlow::Continue
}
KeyCode::Enter => {
let format = menu.formats[menu.selected];
self.export_menu = None;
self.export_findings(format);
ControlFlow::Continue
}
_ => ControlFlow::Continue,
}
}

fn export_findings(&mut self, format: ExportFormat) {
self.export_findings_to(format, format.filename().as_ref());
}

fn export_findings_to(&mut self, format: ExportFormat, path: &std::path::Path) {
let findings = match self.result.as_ref() {
Some(r) => &r.findings,
None => return,
};

let finding_count = findings.len();
let mut empty_cbom = false;
let content = match format {
ExportFormat::Cbom => {
let (cbom, empty_but_findings_present) = crate::report::cbom::build_cbom(findings);
empty_cbom = empty_but_findings_present;
serde_json::to_string_pretty(&cbom).expect("Failed to serialize CBOM")
}
Comment on lines +535 to +539
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Surface the empty-CBOM case instead of silently exporting it

build_cbom returns the empty_but_findings_present flag for exactly the "findings exist, but none are cryptographic" case. Dropping it here means Scan/Secrets/Diff exports can write an empty CBOM and still report success, which looks like a broken export flow. src/report/cbom.rs:294-300 already turns this same signal into a warning; the TUI path should do the same or hide the CBOM option when it cannot produce meaningful output.

Possible fix
     fn export_findings(&mut self, format: ExportFormat) {
         let findings = match self.result.as_ref() {
             Some(r) => &r.findings,
             None => return,
         };
 
         let filename = format.filename();
-        let content = match format {
+        let finding_count = findings.len();
+        let mut export_notice = None;
+        let content = match format {
             ExportFormat::Cbom => {
-                let (cbom, _) = crate::report::cbom::build_cbom(findings);
+                let (cbom, empty_but_findings_present) = crate::report::cbom::build_cbom(findings);
+                if empty_but_findings_present {
+                    export_notice = Some(
+                        "CBOM export is empty: no cryptographic findings detected".to_string(),
+                    );
+                }
                 serde_json::to_string_pretty(&cbom).expect("Failed to serialize CBOM")
             }
             ExportFormat::Json => {
                 serde_json::to_string_pretty(findings).expect("Failed to serialize findings")
             }
             ExportFormat::Sarif => {
                 let sarif = crate::report::sarif::build_sarif(findings);
                 serde_json::to_string_pretty(&sarif).expect("Failed to serialize SARIF")
             }
         };
+
+        if let Some(notice) = export_notice {
+            self.push_runtime_notice(notice);
+        }
 
         match std::fs::write(filename, &content) {
             Ok(()) => {
                 self.push_runtime_notice(format!(
                     "exported {} findings to {}",
-                    findings.len(),
+                    finding_count,
                     filename
                 ));
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ExportFormat::Cbom => {
let (cbom, _) = crate::report::cbom::build_cbom(findings);
serde_json::to_string_pretty(&cbom).expect("Failed to serialize CBOM")
}
fn export_findings(&mut self, format: ExportFormat) {
let findings = match self.result.as_ref() {
Some(r) => &r.findings,
None => return,
};
let filename = format.filename();
let finding_count = findings.len();
let mut export_notice = None;
let content = match format {
ExportFormat::Cbom => {
let (cbom, empty_but_findings_present) = crate::report::cbom::build_cbom(findings);
if empty_but_findings_present {
export_notice = Some(
"CBOM export is empty: no cryptographic findings detected".to_string(),
);
}
serde_json::to_string_pretty(&cbom).expect("Failed to serialize CBOM")
}
ExportFormat::Json => {
serde_json::to_string_pretty(findings).expect("Failed to serialize findings")
}
ExportFormat::Sarif => {
let sarif = crate::report::sarif::build_sarif(findings);
serde_json::to_string_pretty(&sarif).expect("Failed to serialize SARIF")
}
};
if let Some(notice) = export_notice {
self.push_runtime_notice(notice);
}
match std::fs::write(filename, &content) {
Ok(()) => {
self.push_runtime_notice(format!(
"exported {} findings to {}",
finding_count,
filename
));
}
Err(e) => {
self.push_runtime_error(format!("Failed to export findings: {}", e));
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tui.rs` around lines 530 - 533, In the ExportFormat::Cbom branch, capture
the second return value from crate::report::cbom::build_cbom (the
empty_but_findings_present flag) instead of discarding it; if
empty_but_findings_present is true, surface the condition (e.g., log a warning
or return an error/result that indicates CBOM cannot be produced) rather than
serializing an empty CBOM, otherwise proceed to
serde_json::to_string_pretty(&cbom); reference the ExportFormat::Cbom match arm,
the build_cbom function and the empty_but_findings_present flag when making the
change so the TUI mirrors the warning behavior in the cbom report code.

ExportFormat::Json => {
serde_json::to_string_pretty(findings).expect("Failed to serialize findings")
}
Comment on lines +522 to +542
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent overwrite of existing export files

std::fs::write(filename, &content) unconditionally replaces any pre-existing file. A user who has manually edited or version-controlled their findings.cbom.json / findings.json / findings.sarif.json will lose that data without any warning.

Consider checking for existence first and either (a) surfacing a confirmation notice, or (b) appending a timestamp to make each export unique (e.g. findings.cbom.1714000000.json).

ExportFormat::Sarif => {
let sarif = crate::report::sarif::build_sarif(findings);
serde_json::to_string_pretty(&sarif).expect("Failed to serialize SARIF")
}
};

if empty_cbom {
self.push_runtime_notice(
"CBOM export is empty: no cryptographic findings detected".to_string(),
);
}

match std::fs::write(path, &content) {
Ok(()) => {
self.push_runtime_notice(format!(
"exported {} findings to {}",
finding_count,
path.display()
));
}
Err(err) => {
self.push_runtime_notice(format!("export failed: {}", err));
}
}
}

fn handle_severity_picker_key(&mut self, key: KeyCode) -> ControlFlow {
let Some(picker) = self.severity_picker.as_mut() else {
return ControlFlow::Continue;
Expand Down Expand Up @@ -812,6 +906,10 @@ impl TuiApp {
self.draw_action_menu(frame);
}

if self.export_menu.is_some() {
self.draw_export_menu(frame);
}

if self.severity_picker.is_some() {
self.draw_severity_picker(frame);
}
Expand Down Expand Up @@ -1166,7 +1264,8 @@ impl TuiApp {
// The compliance strip sits *below* notices so notices never shrink
// when it is toggled on.
let show_notices = self.show_notices && self.notice_count() > 0;
let show_compliance = self.show_compliance_panel && self.result.is_some();
let show_compliance =
self.show_compliance_panel && self.result.is_some() && self.request.pq_mode;

let mut body_constraints: Vec<Constraint> = vec![Constraint::Min(8)];
if show_notices {
Expand Down Expand Up @@ -1585,6 +1684,7 @@ impl TuiApp {
Line::from("Enter open the current target in your editor"),
Line::from("w show or hide notices panel"),
Line::from("Shift+N toggle CNSA 2.0 compliance panel"),
Line::from("e export findings (CBOM / JSON / SARIF)"),
Line::from("PageUp/Down scroll detail pane"),
Line::from("[/] scroll notices pane"),
Line::from("r rescan"),
Expand Down Expand Up @@ -1703,6 +1803,69 @@ impl TuiApp {
);
}

fn draw_export_menu(&self, frame: &mut ratatui::Frame) {
let Some(menu) = self.export_menu.as_ref() else {
return;
};

let area = centered_rect(40, 40, frame.area());
let items = menu
.formats
.iter()
.map(|fmt| ListItem::new(Line::from(Span::styled(fmt.label(), Style::default()))))
.collect::<Vec<_>>();
let list = List::new(items)
.block(panel_block(None, PANEL_BG))
.highlight_style(
Style::default()
.fg(Color::White)
.bg(DETAIL_BG)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
let inner = area.inner(Margin {
vertical: 1,
horizontal: 1,
});
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(menu.formats.len() as u16 + 2),
Constraint::Length(1),
])
.split(inner);
Comment on lines +1811 to +1837
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The export modal still clips on small terminals.

Line 1811 still sizes the popup purely by percentage. This layout needs at least 9 rows end-to-end, but a 20-line terminal only gives the modal 8 rows at 40%, so the list/footer gets truncated. Please size the dialog from its content height and then clamp to the terminal instead of relying on a fixed percentage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tui.rs` around lines 1811 - 1837, The popup currently uses
centered_rect(40, 40, frame.area()) which can shrink below the required content
height; instead calculate the dialog's required rows from content (e.g. let
required_rows = 1 /*header*/ + menu.formats.len() as u16 + 2 /*footer/margins*/
+ 2 /*outer margins*/), clamp it to the terminal height (let term_h =
frame.area().height; let height_rows =
required_rows.min(term_h.saturating_sub(0)) ), convert that to a height
percentage (let height_pct = (height_rows.saturating_mul(100) / term_h).max(1)),
and call centered_rect(width_pct, height_pct, frame.area()); keep the
inner/layout code using Constraint::Length(menu.formats.len() as u16 + 2) so the
list gets its full required length when terminal is large enough and otherwise
is clamped by the computed height_pct.


frame.render_widget(Clear, area);
frame.render_widget(
Block::default()
.title("export")
.borders(Borders::ALL)
.style(Style::default().bg(PANEL_BG)),
area,
);
frame.render_widget(
Paragraph::new(Span::styled(
"export findings as",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(PANEL_BG)),
layout[0],
);

let mut state = ListState::default();
state.select(Some(menu.selected));
frame.render_stateful_widget(list, layout[1], &mut state);
frame.render_widget(
Paragraph::new("Enter export Esc cancel")
.style(Style::default().bg(PANEL_BG).fg(Color::Gray))
.alignment(Alignment::Left),
layout[2],
);
}

fn draw_severity_picker(&self, frame: &mut ratatui::Frame) {
let Some(picker) = self.severity_picker.as_ref() else {
return;
Expand Down Expand Up @@ -2357,6 +2520,36 @@ struct ActionMenu {
selected: usize,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ExportFormat {
Cbom,
Json,
Sarif,
}

impl ExportFormat {
fn label(self) -> &'static str {
match self {
ExportFormat::Cbom => "CBOM (CycloneDX 1.6)",
ExportFormat::Json => "JSON",
ExportFormat::Sarif => "SARIF",
}
}

fn filename(self) -> &'static str {
match self {
ExportFormat::Cbom => "findings.cbom.json",
ExportFormat::Json => "findings.json",
ExportFormat::Sarif => "findings.sarif.json",
}
}
}

struct ExportMenu {
formats: Vec<ExportFormat>,
selected: usize,
}

/// Modal sub-picker shown when the user chooses "Lower severity" from the
/// triage menu. Owns a highlight cursor over `SEVERITY_PICKER_CHOICES` and
/// remembers the rule's current override (if any) so the UI can show it.
Expand Down Expand Up @@ -4052,6 +4245,7 @@ mod tests {
// visible; emulate the post-scan state the user sees when pressing
// Shift+N.
app.show_launch = false;
app.request.pq_mode = true;
assert!(!app.show_compliance_panel);
let flow = app.handle_key(KeyEvent::from(KeyCode::Char('N')));
assert!(matches!(flow, ControlFlow::Continue));
Expand All @@ -4060,6 +4254,23 @@ mod tests {
assert!(!app.show_compliance_panel);
}

#[test]
fn compliance_panel_hidden_outside_pqc_mode() {
let mut app = tui_app_with_findings(vec![]);
app.show_launch = false;
// pq_mode defaults to false via tui_app_with_findings
assert!(!app.request.pq_mode);
// Shift+N still toggles the flag…
app.handle_key(KeyEvent::from(KeyCode::Char('N')));
assert!(app.show_compliance_panel);
// …but the draw_body gate requires pq_mode, so the panel won't render.
let would_show = app.show_compliance_panel && app.result.is_some() && app.request.pq_mode;
assert!(
!would_show,
"compliance panel should be hidden when pq_mode is false"
);
}

#[test]
fn compliance_panel_shows_badge_and_per_year_tallies() {
// Two findings at 2030, twelve at 2033 — report should render the
Expand Down Expand Up @@ -4173,6 +4384,52 @@ mod tests {
assert!(!span.style.add_modifier.contains(Modifier::BOLD));
}

#[test]
fn export_menu_opens_when_results_exist() {
let mut app = tui_app_with_findings(vec![cnsa_finding("pq/rsa", Some("2030"))]);
app.show_launch = false;
assert!(app.export_menu.is_none());
app.handle_key(KeyEvent::from(KeyCode::Char('e')));
assert!(app.export_menu.is_some());
let menu = app.export_menu.as_ref().unwrap();
assert_eq!(menu.formats.len(), 3);
assert_eq!(menu.selected, 0);
}

#[test]
fn export_menu_noop_without_results() {
let mut app = TuiApp::new(TuiArgs {
path: ".".to_string(),
config: None,
severity: None,
rules: None,
no_builtins: false,
changed: false,
exclude: Vec::new(),
baseline: None,
diff: None,
secrets: false,
explain: false,
max_file_size: 1_048_576,
pq_mode: false,
});
app.show_launch = false;
app.handle_key(KeyEvent::from(KeyCode::Char('e')));
assert!(app.export_menu.is_none());
}

#[test]
fn export_writes_cbom_file() {
let dir = tempfile::tempdir().expect("tempdir");
let mut app = tui_app_with_findings(vec![cnsa_finding("pq/rsa", Some("2030"))]);
app.show_launch = false;
let path = dir.path().join("findings.cbom.json");
app.export_findings_to(ExportFormat::Cbom, &path);
assert!(path.exists(), "CBOM file should exist");
let content = std::fs::read_to_string(&path).expect("read");
assert!(content.contains("CycloneDX"));
}

#[test]
fn crypto_algorithm_chip_renders_padded_name_with_magenta_background() {
let span = crypto_algorithm_chip_span("RSA");
Expand Down