Inital Vuln DB
This commit is contained in:
+97
-8
@@ -126,6 +126,11 @@ pub enum InputMode {
|
|||||||
FobMetaRegion,
|
FobMetaRegion,
|
||||||
/// Fob export metadata: editing notes field
|
/// Fob export metadata: editing notes field
|
||||||
FobMetaNotes,
|
FobMetaNotes,
|
||||||
|
/// Capture metadata (Year/Make/Model/Region) for vuln lookup — press i on a capture
|
||||||
|
CaptureMetaYear,
|
||||||
|
CaptureMetaMake,
|
||||||
|
CaptureMetaModel,
|
||||||
|
CaptureMetaRegion,
|
||||||
/// License overlay (centered box)
|
/// License overlay (centered box)
|
||||||
License,
|
License,
|
||||||
/// Credits overlay (centered box)
|
/// Credits overlay (centered box)
|
||||||
@@ -347,6 +352,14 @@ pub struct App {
|
|||||||
pub fob_meta_region: String,
|
pub fob_meta_region: String,
|
||||||
/// Notes input buffer
|
/// Notes input buffer
|
||||||
pub fob_meta_notes: String,
|
pub fob_meta_notes: String,
|
||||||
|
|
||||||
|
// -- Capture metadata (Year/Make/Model/Region for vuln lookup, set via 'i') --
|
||||||
|
pub capture_meta_year: String,
|
||||||
|
pub capture_meta_make: String,
|
||||||
|
pub capture_meta_model: String,
|
||||||
|
pub capture_meta_region: String,
|
||||||
|
/// Which capture is being edited (when in CaptureMeta* modes)
|
||||||
|
pub capture_meta_capture_id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -451,6 +464,11 @@ impl App {
|
|||||||
fob_meta_model: String::new(),
|
fob_meta_model: String::new(),
|
||||||
fob_meta_region: String::new(),
|
fob_meta_region: String::new(),
|
||||||
fob_meta_notes: String::new(),
|
fob_meta_notes: String::new(),
|
||||||
|
capture_meta_year: String::new(),
|
||||||
|
capture_meta_make: String::new(),
|
||||||
|
capture_meta_model: String::new(),
|
||||||
|
capture_meta_region: String::new(),
|
||||||
|
capture_meta_capture_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,7 +925,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// True if a capture with the same protocol, data, serial, and button already exists.
|
/// True if a capture with the same protocol, data, serial, and button already exists.
|
||||||
|
/// Unknown signals (no protocol) are never treated as duplicates so they can be kept for research.
|
||||||
fn capture_duplicate_of_existing(&self, capture: &Capture) -> bool {
|
fn capture_duplicate_of_existing(&self, capture: &Capture) -> bool {
|
||||||
|
if capture.protocol.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
self.captures.iter().any(|c| {
|
self.captures.iter().any(|c| {
|
||||||
c.protocol == capture.protocol
|
c.protocol == capture.protocol
|
||||||
&& c.data == capture.data
|
&& c.data == capture.data
|
||||||
@@ -1072,18 +1094,32 @@ impl App {
|
|||||||
.map(|c| Self::default_export_filename(c))
|
.map(|c| Self::default_export_filename(c))
|
||||||
.unwrap_or_else(|| format!("capture_{}", id));
|
.unwrap_or_else(|| format!("capture_{}", id));
|
||||||
|
|
||||||
// Pre-fill make from protocol
|
// Pre-fill metadata from capture if set, otherwise make from protocol
|
||||||
let make = self.captures.iter().find(|c| c.id == id).map(|c| {
|
let capture = self.captures.iter().find(|c| c.id == id);
|
||||||
Self::get_make_for_protocol(c.protocol_name()).to_string()
|
let make = capture
|
||||||
}).unwrap_or_default();
|
.and_then(|c| c.make.as_ref().map(String::clone))
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
capture
|
||||||
|
.map(|c| Self::get_make_for_protocol(c.protocol_name()).to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
});
|
||||||
self.export_capture_id = Some(id);
|
self.export_capture_id = Some(id);
|
||||||
self.export_filename = default_name;
|
self.export_filename = default_name;
|
||||||
self.export_format = Some(ExportFormat::Fob);
|
self.export_format = Some(ExportFormat::Fob);
|
||||||
self.fob_meta_year = String::new();
|
self.fob_meta_year = capture
|
||||||
|
.and_then(|c| c.year.as_ref())
|
||||||
|
.map(String::clone)
|
||||||
|
.unwrap_or_default();
|
||||||
self.fob_meta_make = make;
|
self.fob_meta_make = make;
|
||||||
self.fob_meta_model = String::new();
|
self.fob_meta_model = capture
|
||||||
self.fob_meta_region = String::new();
|
.and_then(|c| c.model.as_ref())
|
||||||
|
.map(String::clone)
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.fob_meta_region = capture
|
||||||
|
.and_then(|c| c.region.as_ref())
|
||||||
|
.map(String::clone)
|
||||||
|
.unwrap_or_default();
|
||||||
self.fob_meta_notes = String::new();
|
self.fob_meta_notes = String::new();
|
||||||
self.input_mode = InputMode::ExportFilename;
|
self.input_mode = InputMode::ExportFilename;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1136,6 +1172,55 @@ impl App {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the capture metadata form for the given capture (Year/Make/Model/Region). Called when user presses 'i'.
|
||||||
|
pub fn open_capture_meta_form(&mut self, capture_id: u32) {
|
||||||
|
let capture = self.captures.iter().find(|c| c.id == capture_id);
|
||||||
|
self.capture_meta_year = capture
|
||||||
|
.and_then(|c| c.year.as_ref())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.capture_meta_make = capture
|
||||||
|
.and_then(|c| c.make.as_ref())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.capture_meta_model = capture
|
||||||
|
.and_then(|c| c.model.as_ref())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.capture_meta_region = capture
|
||||||
|
.and_then(|c| c.region.as_ref())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.capture_meta_capture_id = Some(capture_id);
|
||||||
|
self.input_mode = InputMode::CaptureMetaYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save capture metadata from the form into the selected capture and return to Normal.
|
||||||
|
pub fn save_capture_meta(&mut self) {
|
||||||
|
let id = match self.capture_meta_capture_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.capture_meta_capture_id = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(capture) = self.captures.iter_mut().find(|c| c.id == id) {
|
||||||
|
capture.year = Some(self.capture_meta_year.clone()).filter(|s| !s.is_empty());
|
||||||
|
capture.make = Some(self.capture_meta_make.clone()).filter(|s| !s.is_empty());
|
||||||
|
capture.model = Some(self.capture_meta_model.clone()).filter(|s| !s.is_empty());
|
||||||
|
capture.region = Some(self.capture_meta_region.clone()).filter(|s| !s.is_empty());
|
||||||
|
}
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.capture_meta_capture_id = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel capture metadata form without saving.
|
||||||
|
pub fn cancel_capture_meta(&mut self) {
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
self.capture_meta_capture_id = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// Import pending .fob and .sub files into captures list.
|
/// Import pending .fob and .sub files into captures list.
|
||||||
/// .sub files are decoded with registered protocols after load (no metadata in file).
|
/// .sub files are decoded with registered protocols after load (no metadata in file).
|
||||||
/// When research_mode is off, only decoded captures are added (same as live capture).
|
/// When research_mode is off, only decoded captures are added (same as live capture).
|
||||||
@@ -1406,6 +1491,10 @@ impl App {
|
|||||||
raw_pairs: vec![],
|
raw_pairs: vec![],
|
||||||
status: crate::capture::CaptureStatus::EncoderCapable,
|
status: crate::capture::CaptureStatus::EncoderCapable,
|
||||||
received_rf: None,
|
received_rf: None,
|
||||||
|
year: None,
|
||||||
|
make: None,
|
||||||
|
model: None,
|
||||||
|
region: None,
|
||||||
};
|
};
|
||||||
self.next_capture_id += 1;
|
self.next_capture_id += 1;
|
||||||
self.captures.push(capture);
|
self.captures.push(capture);
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ pub struct Capture {
|
|||||||
/// Which demodulator path produced this capture (AM or FM). None if unknown/imported.
|
/// Which demodulator path produced this capture (AM or FM). None if unknown/imported.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub received_rf: Option<RfModulation>,
|
pub received_rf: Option<RfModulation>,
|
||||||
|
/// Vehicle/year for vulnerability lookup and .fob export (set via 'i' in UI).
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<String>,
|
||||||
|
/// Make for vulnerability lookup and .fob export.
|
||||||
|
#[serde(default)]
|
||||||
|
pub make: Option<String>,
|
||||||
|
/// Model for vulnerability lookup and .fob export.
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
/// Region (e.g. NA, EU) for vulnerability lookup and .fob export.
|
||||||
|
#[serde(default)]
|
||||||
|
pub region: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modulation type used by protocol (encoding: PWM vs Manchester)
|
/// Modulation type used by protocol (encoding: PWM vs Manchester)
|
||||||
@@ -138,6 +150,10 @@ impl Capture {
|
|||||||
raw_pairs: pairs,
|
raw_pairs: pairs,
|
||||||
status: CaptureStatus::Unknown,
|
status: CaptureStatus::Unknown,
|
||||||
received_rf,
|
received_rf,
|
||||||
|
year: None,
|
||||||
|
make: None,
|
||||||
|
model: None,
|
||||||
|
region: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,10 @@ fn import_fob_v2(fob: &FobFile, next_id: u32) -> Result<Capture> {
|
|||||||
raw_pairs,
|
raw_pairs,
|
||||||
status,
|
status,
|
||||||
received_rf: None,
|
received_rf: None,
|
||||||
|
year: None,
|
||||||
|
make: None,
|
||||||
|
model: None,
|
||||||
|
region: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +372,10 @@ fn import_fob_v1(fob: &FobFileV1, next_id: u32) -> Result<Capture> {
|
|||||||
raw_pairs,
|
raw_pairs,
|
||||||
status,
|
status,
|
||||||
received_rf: None,
|
received_rf: None,
|
||||||
|
year: None,
|
||||||
|
make: None,
|
||||||
|
model: None,
|
||||||
|
region: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
@@ -11,6 +11,7 @@ mod protocols;
|
|||||||
mod radio;
|
mod radio;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod vuln_db;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
@@ -148,6 +149,14 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
|
|||||||
app.signal_menu_index = 0;
|
app.signal_menu_index = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('i') => {
|
||||||
|
// Edit Year/Make/Model/Region for vuln lookup
|
||||||
|
if let Some(idx) = app.selected_capture {
|
||||||
|
if let Some(capture) = app.captures.get(idx) {
|
||||||
|
app.open_capture_meta_form(capture.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
// Open settings selector
|
// Open settings selector
|
||||||
app.input_mode = InputMode::SettingsSelect;
|
app.input_mode = InputMode::SettingsSelect;
|
||||||
@@ -408,6 +417,70 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Capture metadata (Year/Make/Model/Region for vuln lookup)
|
||||||
|
InputMode::CaptureMetaYear => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.input_mode = InputMode::CaptureMetaMake;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if c.is_ascii_digit() => {
|
||||||
|
if app.capture_meta_year.len() < 4 {
|
||||||
|
app.capture_meta_year.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.capture_meta_year.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.cancel_capture_meta();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
InputMode::CaptureMetaMake => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.input_mode = InputMode::CaptureMetaModel;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.capture_meta_make.push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.capture_meta_make.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.cancel_capture_meta();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
InputMode::CaptureMetaModel => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.input_mode = InputMode::CaptureMetaRegion;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.capture_meta_model.push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.capture_meta_model.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.cancel_capture_meta();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
InputMode::CaptureMetaRegion => match key.code {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.save_capture_meta();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.capture_meta_region.push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.capture_meta_region.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.cancel_capture_meta();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
|
||||||
InputMode::License | InputMode::Credits => match key.code {
|
InputMode::License | InputMode::Credits => match key.code {
|
||||||
KeyCode::Esc | KeyCode::Enter => {
|
KeyCode::Esc | KeyCode::Enter => {
|
||||||
app.input_mode = InputMode::Normal;
|
app.input_mode = InputMode::Normal;
|
||||||
|
|||||||
+110
-12
@@ -10,6 +10,7 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::capture::CaptureStatus;
|
use crate::capture::CaptureStatus;
|
||||||
|
use crate::vuln_db;
|
||||||
|
|
||||||
/// Render the captures area: table + detail panel
|
/// Render the captures area: table + detail panel
|
||||||
pub fn render_captures_list(frame: &mut Frame, area: Rect, app: &App) {
|
pub fn render_captures_list(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
@@ -45,6 +46,7 @@ pub fn render_captures_list(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
fn render_table(frame: &mut Frame, area: Rect, app: &App) {
|
fn render_table(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let header_cells = [
|
let header_cells = [
|
||||||
"ID", "Time", "Protocol", "Freq", "Serial", "Btn", "Cnt", "Modulation", "CRC", "Status",
|
"ID", "Time", "Protocol", "Freq", "Serial", "Btn", "Cnt", "Modulation", "CRC", "Status",
|
||||||
|
"Vuln Found",
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD)));
|
.map(|h| Cell::from(*h).style(Style::default().add_modifier(Modifier::BOLD)));
|
||||||
@@ -81,6 +83,21 @@ fn render_table(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
CaptureStatus::Unknown => "Unknown",
|
CaptureStatus::Unknown => "Unknown",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let vuln_found = capture.status == CaptureStatus::EncoderCapable
|
||||||
|
|| !vuln_db::match_vulns(
|
||||||
|
capture.year.as_deref(),
|
||||||
|
capture.make.as_deref(),
|
||||||
|
capture.model.as_deref(),
|
||||||
|
capture.region.as_deref(),
|
||||||
|
)
|
||||||
|
.is_empty();
|
||||||
|
let vuln_text = if vuln_found { "Yes" } else { "No" };
|
||||||
|
let vuln_style = if vuln_found {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
Row::new(vec![
|
Row::new(vec![
|
||||||
Cell::from(format!("{:02}", capture.id)),
|
Cell::from(format!("{:02}", capture.id)),
|
||||||
Cell::from(capture.timestamp_short()),
|
Cell::from(capture.timestamp_short()),
|
||||||
@@ -92,6 +109,7 @@ fn render_table(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
Cell::from(capture.modulation().to_string()).style(mod_style),
|
Cell::from(capture.modulation().to_string()).style(mod_style),
|
||||||
Cell::from(capture.crc_status()).style(crc_style),
|
Cell::from(capture.crc_status()).style(crc_style),
|
||||||
Cell::from(status_text).style(status_style),
|
Cell::from(status_text).style(status_style),
|
||||||
|
Cell::from(vuln_text).style(vuln_style),
|
||||||
])
|
])
|
||||||
.height(1)
|
.height(1)
|
||||||
});
|
});
|
||||||
@@ -107,6 +125,7 @@ fn render_table(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
Constraint::Length(12), // Modulation
|
Constraint::Length(12), // Modulation
|
||||||
Constraint::Length(5), // CRC
|
Constraint::Length(5), // CRC
|
||||||
Constraint::Length(10), // Status
|
Constraint::Length(10), // Status
|
||||||
|
Constraint::Length(10), // Vuln Found
|
||||||
];
|
];
|
||||||
|
|
||||||
let table = Table::new(rows, widths)
|
let table = Table::new(rows, widths)
|
||||||
@@ -129,13 +148,24 @@ fn render_table(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
frame.render_stateful_widget(table, area, &mut state);
|
frame.render_stateful_widget(table, area, &mut state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the detail panel for the selected signal
|
/// Render the detail panel for the selected signal (left = signal info, right = vulnerability)
|
||||||
fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
let capture = match app.selected_capture {
|
let capture = match app.selected_capture {
|
||||||
Some(idx) if idx < app.captures.len() => &app.captures[idx],
|
Some(idx) if idx < app.captures.len() => &app.captures[idx],
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let halves = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_signal_detail(frame, halves[0], capture);
|
||||||
|
render_vulnerability_panel(frame, halves[1], capture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Left half: signal information
|
||||||
|
fn render_signal_detail(frame: &mut Frame, area: Rect, capture: &crate::capture::Capture) {
|
||||||
let label_style = Style::default()
|
let label_style = Style::default()
|
||||||
.fg(Color::DarkGray)
|
.fg(Color::DarkGray)
|
||||||
.add_modifier(Modifier::BOLD);
|
.add_modifier(Modifier::BOLD);
|
||||||
@@ -144,10 +174,8 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
let good_style = Style::default().fg(Color::Green);
|
let good_style = Style::default().fg(Color::Green);
|
||||||
let bad_style = Style::default().fg(Color::Red);
|
let bad_style = Style::default().fg(Color::Red);
|
||||||
|
|
||||||
// Build detail content in two columns
|
|
||||||
let make = App::get_make_for_protocol(capture.protocol_name());
|
let make = App::get_make_for_protocol(capture.protocol_name());
|
||||||
|
|
||||||
// --- Left column lines ---
|
|
||||||
let mut left_lines = Vec::new();
|
let mut left_lines = Vec::new();
|
||||||
|
|
||||||
// Row 1: Protocol + Make
|
// Row 1: Protocol + Make
|
||||||
@@ -158,24 +186,28 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
Span::styled(make, value_style),
|
Span::styled(make, value_style),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Row 2: Freq + Mod + RF (protocol) + Enc + Rx (demodulator path when known)
|
// Row 2: Freq + Mod + RF
|
||||||
let mut row2 = vec![
|
left_lines.push(Line::from(vec![
|
||||||
Span::styled(" Freq: ", label_style),
|
Span::styled(" Freq: ", label_style),
|
||||||
Span::styled(capture.frequency_mhz(), value_style),
|
Span::styled(capture.frequency_mhz(), value_style),
|
||||||
Span::styled(" Mod: ", label_style),
|
Span::styled(" Mod: ", label_style),
|
||||||
Span::styled(capture.modulation().to_string(), value_style),
|
Span::styled(capture.modulation().to_string(), value_style),
|
||||||
Span::styled(" RF: ", label_style),
|
Span::styled(" RF: ", label_style),
|
||||||
Span::styled(capture.rf_modulation().to_string(), value_style),
|
Span::styled(capture.rf_modulation().to_string(), value_style),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Row 3: Enc + Rx (demodulator path when known)
|
||||||
|
let mut row3 = vec![
|
||||||
Span::styled(" Enc: ", label_style),
|
Span::styled(" Enc: ", label_style),
|
||||||
Span::styled(capture.encryption_type(), value_style),
|
Span::styled(capture.encryption_type(), value_style),
|
||||||
];
|
];
|
||||||
if let Some(rf) = capture.received_rf {
|
if let Some(rf) = capture.received_rf {
|
||||||
row2.push(Span::styled(" Rx: ", label_style));
|
row3.push(Span::styled(" Rx: ", label_style));
|
||||||
row2.push(Span::styled(rf.to_string(), value_style));
|
row3.push(Span::styled(rf.to_string(), value_style));
|
||||||
}
|
}
|
||||||
left_lines.push(Line::from(row2));
|
left_lines.push(Line::from(row3));
|
||||||
|
|
||||||
// Row 3: Full Serial + Button
|
// Row 4: Full Serial + Button
|
||||||
left_lines.push(Line::from(vec![
|
left_lines.push(Line::from(vec![
|
||||||
Span::styled(" Serial: ", label_style),
|
Span::styled(" Serial: ", label_style),
|
||||||
Span::styled(format!("0x{}", capture.serial_hex()), accent_style),
|
Span::styled(format!("0x{}", capture.serial_hex()), accent_style),
|
||||||
@@ -186,7 +218,7 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Row 4: Counter + CRC
|
// Row 5: Counter + CRC
|
||||||
let crc_span = if capture.protocol.is_none() {
|
let crc_span = if capture.protocol.is_none() {
|
||||||
Span::styled("-", Style::default().fg(Color::DarkGray))
|
Span::styled("-", Style::default().fg(Color::DarkGray))
|
||||||
} else if capture.crc_valid {
|
} else if capture.crc_valid {
|
||||||
@@ -204,7 +236,7 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
Span::styled(capture.status.to_string(), value_style),
|
Span::styled(capture.status.to_string(), value_style),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Row 5: Full data/key hex
|
// Row 6: Full data/key hex
|
||||||
left_lines.push(Line::from(vec![
|
left_lines.push(Line::from(vec![
|
||||||
Span::styled(" Key/Data: ", label_style),
|
Span::styled(" Key/Data: ", label_style),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
@@ -217,7 +249,7 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
),
|
),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
// Row 6: Timestamp + Raw data info
|
// Row 7: Timestamp + Raw data info
|
||||||
let raw_info = if capture.has_raw_data() {
|
let raw_info = if capture.has_raw_data() {
|
||||||
format!("✓ {} transitions", capture.raw_pair_count())
|
format!("✓ {} transitions", capture.raw_pair_count())
|
||||||
} else {
|
} else {
|
||||||
@@ -263,3 +295,69 @@ fn render_detail_panel(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
frame.render_widget(detail, area);
|
frame.render_widget(detail, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Right half: vulnerability panel (matching CVEs or prompt to set Year/Make/Model)
|
||||||
|
fn render_vulnerability_panel(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
capture: &crate::capture::Capture,
|
||||||
|
) {
|
||||||
|
let label_style = Style::default()
|
||||||
|
.fg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let value_style = Style::default().fg(Color::White);
|
||||||
|
let accent_style = Style::default().fg(Color::Cyan);
|
||||||
|
|
||||||
|
let vulns = vuln_db::match_vulns(
|
||||||
|
capture.year.as_deref(),
|
||||||
|
capture.make.as_deref(),
|
||||||
|
capture.model.as_deref(),
|
||||||
|
capture.region.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
if vulns.is_empty() {
|
||||||
|
let has_meta = capture.year.is_some()
|
||||||
|
|| capture.make.is_some()
|
||||||
|
|| capture.model.is_some()
|
||||||
|
|| capture.region.is_some();
|
||||||
|
if has_meta {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" No matching CVE in database.",
|
||||||
|
value_style,
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" Set Year, Make, Model, Region",
|
||||||
|
value_style,
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" (press i) to check vulnerabilities.",
|
||||||
|
value_style,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for v in vulns {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" CVE: ", label_style),
|
||||||
|
Span::styled(v.cve, accent_style),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Description: ", label_style),
|
||||||
|
Span::styled(v.description, value_style),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(Span::raw("")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow))
|
||||||
|
.title(" Vulnerability ");
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
if inner.width > 0 && inner.height > 0 {
|
||||||
|
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(para, inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ pub fn render_command_line(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
"EXPORT",
|
"EXPORT",
|
||||||
Style::default().fg(Color::Green),
|
Style::default().fg(Color::Green),
|
||||||
),
|
),
|
||||||
|
InputMode::CaptureMetaYear
|
||||||
|
| InputMode::CaptureMetaMake
|
||||||
|
| InputMode::CaptureMetaModel
|
||||||
|
| InputMode::CaptureMetaRegion => (
|
||||||
|
String::new(),
|
||||||
|
"META",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
InputMode::License => (
|
InputMode::License => (
|
||||||
String::new(),
|
String::new(),
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ pub fn draw_ui(frame: &mut Frame, app: &App) {
|
|||||||
render_export_form(frame, app);
|
render_export_form(frame, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
app.input_mode,
|
||||||
|
InputMode::CaptureMetaYear
|
||||||
|
| InputMode::CaptureMetaMake
|
||||||
|
| InputMode::CaptureMetaModel
|
||||||
|
| InputMode::CaptureMetaRegion
|
||||||
|
) {
|
||||||
|
render_capture_meta_form(frame, app);
|
||||||
|
}
|
||||||
|
|
||||||
if app.input_mode == InputMode::License {
|
if app.input_mode == InputMode::License {
|
||||||
render_text_overlay(frame, app, "License", LICENSE_TEXT, Alignment::Left);
|
render_text_overlay(frame, app, "License", LICENSE_TEXT, Alignment::Left);
|
||||||
}
|
}
|
||||||
@@ -229,6 +239,10 @@ fn render_help_bar(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
| InputMode::FobMetaModel
|
| InputMode::FobMetaModel
|
||||||
| InputMode::FobMetaRegion => "Enter: Next Field | Esc: Cancel Export",
|
| InputMode::FobMetaRegion => "Enter: Next Field | Esc: Cancel Export",
|
||||||
InputMode::FobMetaNotes => "Enter: Save & Export | Esc: Cancel Export",
|
InputMode::FobMetaNotes => "Enter: Save & Export | Esc: Cancel Export",
|
||||||
|
InputMode::CaptureMetaYear
|
||||||
|
| InputMode::CaptureMetaMake
|
||||||
|
| InputMode::CaptureMetaModel => "Enter: Next Field | Esc: Cancel",
|
||||||
|
InputMode::CaptureMetaRegion => "Enter: Save | Esc: Cancel",
|
||||||
InputMode::License | InputMode::Credits => "Esc/Enter: Close | Up/Down: Scroll",
|
InputMode::License | InputMode::Credits => "Esc/Enter: Close | Up/Down: Scroll",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -546,3 +560,151 @@ fn render_export_form(frame: &mut Frame, app: &App) {
|
|||||||
|
|
||||||
frame.render_widget(paragraph, popup);
|
frame.render_widget(paragraph, popup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Render the capture metadata form (Year/Make/Model/Region for vuln lookup). Shown when user presses 'i' on a capture.
|
||||||
|
fn render_capture_meta_form(frame: &mut Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let popup = centered_rect(62, 18, area);
|
||||||
|
|
||||||
|
frame.render_widget(Clear, popup);
|
||||||
|
|
||||||
|
let active_style = Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let inactive_style = Style::default().fg(Color::DarkGray);
|
||||||
|
let done_style = Style::default().fg(Color::Green);
|
||||||
|
let value_style = Style::default().fg(Color::White);
|
||||||
|
let dim_style = Style::default().fg(Color::DarkGray);
|
||||||
|
let accent_style = Style::default().fg(Color::Yellow);
|
||||||
|
let cursor = Span::styled(
|
||||||
|
"_",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::RAPID_BLINK),
|
||||||
|
);
|
||||||
|
|
||||||
|
let field_modes = [
|
||||||
|
InputMode::CaptureMetaYear,
|
||||||
|
InputMode::CaptureMetaMake,
|
||||||
|
InputMode::CaptureMetaModel,
|
||||||
|
InputMode::CaptureMetaRegion,
|
||||||
|
];
|
||||||
|
let current_idx = field_modes
|
||||||
|
.iter()
|
||||||
|
.position(|m| *m == app.input_mode)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let style_for = |idx: usize| -> Style {
|
||||||
|
if idx == current_idx {
|
||||||
|
active_style
|
||||||
|
} else if idx < current_idx {
|
||||||
|
done_style
|
||||||
|
} else {
|
||||||
|
inactive_style
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
if let Some(capture) = app
|
||||||
|
.capture_meta_capture_id
|
||||||
|
.and_then(|id| app.captures.iter().find(|c| c.id == id))
|
||||||
|
{
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Signal: ", dim_style),
|
||||||
|
Span::styled(
|
||||||
|
format!(
|
||||||
|
"#{:02} {} | {} | {} | 0x{}",
|
||||||
|
capture.id,
|
||||||
|
capture.protocol_name(),
|
||||||
|
capture.frequency_mhz(),
|
||||||
|
capture.modulation(),
|
||||||
|
capture.serial_hex(),
|
||||||
|
),
|
||||||
|
accent_style,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Key: ", dim_style),
|
||||||
|
Span::styled(
|
||||||
|
format!("0x{} ({})", capture.data_hex(), capture.encryption_type()),
|
||||||
|
accent_style,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" ──────────────────────────────────────────────────────",
|
||||||
|
dim_style,
|
||||||
|
)));
|
||||||
|
|
||||||
|
struct FormField<'a> {
|
||||||
|
label: &'a str,
|
||||||
|
value: &'a str,
|
||||||
|
placeholder: &'a str,
|
||||||
|
idx: usize,
|
||||||
|
}
|
||||||
|
let fields: [FormField; 4] = [
|
||||||
|
FormField {
|
||||||
|
label: " Year: ",
|
||||||
|
value: &app.capture_meta_year,
|
||||||
|
placeholder: "(e.g. 2021)",
|
||||||
|
idx: 0,
|
||||||
|
},
|
||||||
|
FormField {
|
||||||
|
label: " Make: ",
|
||||||
|
value: &app.capture_meta_make,
|
||||||
|
placeholder: "(e.g. Renault, Honda)",
|
||||||
|
idx: 1,
|
||||||
|
},
|
||||||
|
FormField {
|
||||||
|
label: " Model: ",
|
||||||
|
value: &app.capture_meta_model,
|
||||||
|
placeholder: "(e.g. ZOE, Civic — or ALL)",
|
||||||
|
idx: 2,
|
||||||
|
},
|
||||||
|
FormField {
|
||||||
|
label: " Region: ",
|
||||||
|
value: &app.capture_meta_region,
|
||||||
|
placeholder: "(e.g. NA, EU, or ALL)",
|
||||||
|
idx: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for field in &fields {
|
||||||
|
let label_s = style_for(field.idx);
|
||||||
|
let display_val = if field.value.is_empty() {
|
||||||
|
field.placeholder
|
||||||
|
} else {
|
||||||
|
field.value
|
||||||
|
};
|
||||||
|
let val_s = if field.value.is_empty() && field.idx != current_idx {
|
||||||
|
dim_style
|
||||||
|
} else {
|
||||||
|
value_style
|
||||||
|
};
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(field.label, label_s),
|
||||||
|
Span::styled(display_val.to_string(), val_s),
|
||||||
|
];
|
||||||
|
if field.idx == current_idx {
|
||||||
|
spans.push(cursor.clone());
|
||||||
|
}
|
||||||
|
if field.idx < current_idx && !field.value.is_empty() {
|
||||||
|
spans.push(Span::styled(" ✓", done_style));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
let progress = format!(" Step {}/4 — Used for vuln lookup and .fob export", current_idx + 1);
|
||||||
|
lines.push(Line::from(Span::styled(progress, dim_style)));
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.title(" Capture Metadata — Year / Make / Model / Region ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
frame.render_widget(paragraph, popup);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! Vulnerability database: CVE entries matched by Year, Make, Model, Region.
|
||||||
|
//! Used for "Vuln Found" column and the Vulnerability detail panel.
|
||||||
|
|
||||||
|
/// One CVE entry. Year/Make/Model/Region use "ALL" to match any value.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VulnEntry {
|
||||||
|
pub cve: &'static str,
|
||||||
|
pub year: &'static str,
|
||||||
|
pub make: &'static str,
|
||||||
|
pub model: &'static str,
|
||||||
|
pub region: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const VULN_DB: [VulnEntry; 2] = [
|
||||||
|
VulnEntry {
|
||||||
|
cve: "CVE-2022-38766",
|
||||||
|
year: "2021",
|
||||||
|
make: "Renault",
|
||||||
|
model: "ALL",
|
||||||
|
region: "ALL",
|
||||||
|
description: "The remote keyless system on Renault ZOE 2021 vehicles sends 433.92 MHz RF signals from the same Rolling Codes set for each door-open request, which allows for a replay attack.",
|
||||||
|
},
|
||||||
|
VulnEntry {
|
||||||
|
cve: "CVE-2022-27254",
|
||||||
|
year: "2018",
|
||||||
|
make: "Honda",
|
||||||
|
model: "Civic",
|
||||||
|
region: "ALL",
|
||||||
|
description: "The remote keyless system on Honda Civic 2018 vehicles sends the same RF signal for each door-open request, which allows for a replay attack, a related issue to CVE-2019-20626.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Match a capture's year/make/model/region against the DB. "ALL" in an entry matches any value.
|
||||||
|
/// Empty/None from capture is treated as empty string (so "ALL" matches it).
|
||||||
|
pub fn match_vulns(
|
||||||
|
year: Option<&str>,
|
||||||
|
make: Option<&str>,
|
||||||
|
model: Option<&str>,
|
||||||
|
region: Option<&str>,
|
||||||
|
) -> Vec<&'static VulnEntry> {
|
||||||
|
let y = year.unwrap_or("");
|
||||||
|
let m = make.unwrap_or("");
|
||||||
|
let mod_ = model.unwrap_or("");
|
||||||
|
let r = region.unwrap_or("");
|
||||||
|
|
||||||
|
VULN_DB
|
||||||
|
.iter()
|
||||||
|
.filter(|e| {
|
||||||
|
(e.year == "ALL" || eq_ignore_case(e.year, y))
|
||||||
|
&& (e.make == "ALL" || eq_ignore_case(e.make, m))
|
||||||
|
&& (e.model == "ALL" || eq_ignore_case(e.model, mod_))
|
||||||
|
&& (e.region == "ALL" || eq_ignore_case(e.region, r))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eq_ignore_case(a: &str, b: &str) -> bool {
|
||||||
|
a.eq_ignore_ascii_case(b)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user