TX and other UI/UX improvements
This commit is contained in:
@@ -27,7 +27,7 @@ A terminal-based RF signal analysis tool for capturing, decoding, and retransmit
|
|||||||
- **Research mode** — config option to show unknown (unidentified) signals in addition to successfully decoded ones
|
- **Research mode** — config option to show unknown (unidentified) signals in addition to successfully decoded ones
|
||||||
- **INI configuration** — `~/.config/KAT/config.ini` (auto-created with comments on first run): export path, max captures, research_mode, radio defaults, export format
|
- **INI configuration** — `~/.config/KAT/config.ini` (auto-created with comments on first run): export path, max captures, research_mode, radio defaults, export format
|
||||||
- **Embedded keystore** — manufacturer keys (Kia, VAG, etc.) built in for decoding
|
- **Embedded keystore** — manufacturer keys (Kia, VAG, etc.) built in for decoding
|
||||||
- **VIM-style command line** — `:freq`, `:lock`, `:unlock`, `:save`, `:load`, `:delete`, `:q` / `:quit`, and more
|
- **VIM-style command line** — `:freq`, `:lock`, `:unlock`, `:replay`, `:save`, `:load`, `:delete`, `:q` / `:quit`, and more
|
||||||
- **Interactive TUI** — captures list with detail panel (protocol, freq, mod, RF, encryption), signal action menu, radio settings, fob export form; header shows device (HackRF / RTL-SDR (RX only) / No device) and status (DISCONNECTED in red when no device)
|
- **Interactive TUI** — captures list with detail panel (protocol, freq, mod, RF, encryption), signal action menu, radio settings, fob export form; header shows device (HackRF / RTL-SDR (RX only) / No device) and status (DISCONNECTED in red when no device)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -172,10 +172,11 @@ Transmit commands (`:lock`, `:unlock`, `:trunk`, `:panic`) require HackRF; with
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `:freq <MHz>` | Set receive frequency (e.g. `:freq 433.92`) |
|
| `:freq <MHz>` | Set receive frequency (e.g. `:freq 433.92`) |
|
||||||
| `:lock <ID>` | Transmit lock signal for capture ID |
|
| `:lock <ID>` | Transmit lock signal (ID: single, comma list, or range; e.g. `1`, `1, 3, 5`, `1-5`) |
|
||||||
| `:unlock <ID>` | Transmit unlock signal for capture ID |
|
| `:unlock <ID>` | Transmit unlock signal (same ID formats) |
|
||||||
| `:trunk <ID>` | Transmit trunk release signal |
|
| `:trunk <ID>` | Transmit trunk release (same ID formats) |
|
||||||
| `:panic <ID>` | Transmit panic alarm signal |
|
| `:panic <ID>` | Transmit panic alarm (same ID formats) |
|
||||||
|
| `:replay <ID>` | Replay raw capture(s) by ID in order (same ID formats; HackRF only) |
|
||||||
| `:save <ID>` | Save capture to file |
|
| `:save <ID>` | Save capture to file |
|
||||||
| `:delete <ID>` | Delete capture from list |
|
| `:delete <ID>` | Delete capture from list |
|
||||||
| `:load <file>` | Import capture from `.fob` or `.sub` file |
|
| `:load <file>` | Import capture from `.fob` or `.sub` file |
|
||||||
|
|||||||
+123
-41
@@ -367,6 +367,12 @@ pub struct App {
|
|||||||
/// Which capture is being edited (when in CaptureMeta* modes)
|
/// Which capture is being edited (when in CaptureMeta* modes)
|
||||||
pub capture_meta_capture_id: Option<u32>,
|
pub capture_meta_capture_id: Option<u32>,
|
||||||
|
|
||||||
|
// -- Pending transmit (so UI can draw TX state before blocking) --
|
||||||
|
/// Queue of (signal, frequency) to transmit; main loop draws then runs one at a time.
|
||||||
|
pending_transmit_queue: Vec<(Vec<LevelDuration>, u32)>,
|
||||||
|
/// State to restore when queue becomes empty (set when first item is queued).
|
||||||
|
pending_transmit_restore: Option<RadioState>,
|
||||||
|
|
||||||
// -- :load file browser --
|
// -- :load file browser --
|
||||||
/// Current directory in the load file browser
|
/// Current directory in the load file browser
|
||||||
pub load_browser_cwd: PathBuf,
|
pub load_browser_cwd: PathBuf,
|
||||||
@@ -485,6 +491,8 @@ impl App {
|
|||||||
capture_meta_model: String::new(),
|
capture_meta_model: String::new(),
|
||||||
capture_meta_region: String::new(),
|
capture_meta_region: String::new(),
|
||||||
capture_meta_capture_id: None,
|
capture_meta_capture_id: None,
|
||||||
|
pending_transmit_queue: Vec::new(),
|
||||||
|
pending_transmit_restore: None,
|
||||||
load_browser_cwd: PathBuf::new(),
|
load_browser_cwd: PathBuf::new(),
|
||||||
load_browser_selected: 0,
|
load_browser_selected: 0,
|
||||||
load_browser_scroll: 0,
|
load_browser_scroll: 0,
|
||||||
@@ -578,6 +586,34 @@ impl App {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse an ID spec into a list of capture IDs in order.
|
||||||
|
/// Supports: single "1", comma-separated "1, 3, 5", range "1-5", and mixed "1, 3-5, 7".
|
||||||
|
fn parse_id_spec(s: &str) -> Result<Vec<u32>, String> {
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for part in s.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if part.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((low, high)) = part.split_once('-') {
|
||||||
|
let low = low.trim().parse::<u32>().map_err(|_| "Invalid ID in range".to_string())?;
|
||||||
|
let high = high.trim().parse::<u32>().map_err(|_| "Invalid ID in range".to_string())?;
|
||||||
|
if low <= high {
|
||||||
|
ids.extend(low..=high);
|
||||||
|
} else {
|
||||||
|
ids.extend((high..=low).rev());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let id = part.parse::<u32>().map_err(|_| "Invalid capture ID".to_string())?;
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Err("No valid IDs".to_string());
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute a command
|
/// Execute a command
|
||||||
pub fn execute_command(&mut self, command: &str) -> Result<()> {
|
pub fn execute_command(&mut self, command: &str) -> Result<()> {
|
||||||
let parts: Vec<&str> = command.trim().split_whitespace().collect();
|
let parts: Vec<&str> = command.trim().split_whitespace().collect();
|
||||||
@@ -607,10 +643,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"unlock" => self.transmit_command(parts.get(1), ButtonCommand::Unlock)?,
|
"unlock" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Unlock)?,
|
||||||
"lock" => self.transmit_command(parts.get(1), ButtonCommand::Lock)?,
|
"lock" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Lock)?,
|
||||||
"trunk" => self.transmit_command(parts.get(1), ButtonCommand::Trunk)?,
|
"trunk" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Trunk)?,
|
||||||
"panic" => self.transmit_command(parts.get(1), ButtonCommand::Panic)?,
|
"panic" => self.transmit_command(parts.get(1).map(|_| parts[1..].join(" ")), ButtonCommand::Panic)?,
|
||||||
"license" | "licence" => {
|
"license" | "licence" => {
|
||||||
self.input_mode = InputMode::License;
|
self.input_mode = InputMode::License;
|
||||||
self.overlay_scroll = 0;
|
self.overlay_scroll = 0;
|
||||||
@@ -633,6 +669,24 @@ impl App {
|
|||||||
self.delete_capture(parts[1])?;
|
self.delete_capture(parts[1])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"replay" => {
|
||||||
|
let id_spec = parts.get(1).map(|_| parts[1..].join(" "));
|
||||||
|
let id_spec = match id_spec.as_deref() {
|
||||||
|
Some(s) if !s.is_empty() => s,
|
||||||
|
_ => {
|
||||||
|
self.last_error = Some("Usage: :replay <ID> (e.g. 1, 1,3,5, 1-5)".to_string());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match Self::parse_id_spec(id_spec) {
|
||||||
|
Ok(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
self.replay_capture(id)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.last_error = Some(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
"lna" => {
|
"lna" => {
|
||||||
if parts.len() < 2 {
|
if parts.len() < 2 {
|
||||||
self.last_error = Some("Usage: :lna <0-40>".to_string());
|
self.last_error = Some("Usage: :lna <0-40>".to_string());
|
||||||
@@ -760,8 +814,30 @@ impl App {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transmit a command for a capture
|
/// Transmit a command for one or more captures. ID spec: "1", "1, 3, 5", "1-5", or mixed.
|
||||||
fn transmit_command(&mut self, id_str: Option<&&str>, command: ButtonCommand) -> Result<()> {
|
fn transmit_command(&mut self, id_spec: Option<String>, command: ButtonCommand) -> Result<()> {
|
||||||
|
let id_spec = match id_spec.as_deref() {
|
||||||
|
Some(s) if !s.is_empty() => s,
|
||||||
|
_ => {
|
||||||
|
self.last_error = Some(format!("Usage: :{:?} <ID> (e.g. 1, 1,3,5, 1-5)", command).to_lowercase());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let ids = match Self::parse_id_spec(id_spec) {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(e) => {
|
||||||
|
self.last_error = Some(e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for id in ids {
|
||||||
|
self.transmit_one_command(id, command)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transmit a command for a single capture by ID.
|
||||||
|
fn transmit_one_command(&mut self, id: u32, command: ButtonCommand) -> Result<()> {
|
||||||
use crate::protocols::DecodedSignal;
|
use crate::protocols::DecodedSignal;
|
||||||
|
|
||||||
if let Some(ref radio) = self.radio {
|
if let Some(ref radio) = self.radio {
|
||||||
@@ -774,22 +850,6 @@ impl App {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let id_str = match id_str {
|
|
||||||
Some(s) => s,
|
|
||||||
None => {
|
|
||||||
self.last_error = Some(format!("Usage: :{:?} <ID>", command).to_lowercase());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let id: u32 = match id_str.parse() {
|
|
||||||
Ok(i) => i,
|
|
||||||
Err(_) => {
|
|
||||||
self.last_error = Some("Invalid capture ID".to_string());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let capture = match self.captures.iter().find(|c| c.id == id) {
|
let capture = match self.captures.iter().find(|c| c.id == id) {
|
||||||
Some(c) => c.clone(),
|
Some(c) => c.clone(),
|
||||||
None => {
|
None => {
|
||||||
@@ -840,11 +900,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(ref mut radio) = self.radio {
|
self.pending_transmit_queue.push((signal, capture.frequency));
|
||||||
radio.transmit(&signal, capture.frequency)?;
|
if self.pending_transmit_restore.is_none() {
|
||||||
self.status_message = Some(format!("Transmitted {:?} for capture {}", command, id));
|
self.pending_transmit_restore = Some(self.radio_state);
|
||||||
|
self.radio_state = RadioState::Transmitting;
|
||||||
}
|
}
|
||||||
|
self.status_message = Some(format!("Transmitted {:?} for capture {}", command, id));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,12 +939,14 @@ impl App {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|p| LevelDuration::new(p.level, p.duration_us))
|
.map(|p| LevelDuration::new(p.level, p.duration_us))
|
||||||
.collect();
|
.collect();
|
||||||
|
let pair_count = signal.len();
|
||||||
|
|
||||||
if let Some(ref mut radio) = self.radio {
|
self.pending_transmit_queue.push((signal, capture.frequency));
|
||||||
radio.transmit(&signal, capture.frequency)?;
|
if self.pending_transmit_restore.is_none() {
|
||||||
self.status_message = Some(format!("Replayed capture {} ({} pairs)", id, signal.len()));
|
self.pending_transmit_restore = Some(self.radio_state);
|
||||||
|
self.radio_state = RadioState::Transmitting;
|
||||||
}
|
}
|
||||||
|
self.status_message = Some(format!("Replayed capture {} ({} pairs)", id, pair_count));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,11 +1012,34 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(ref mut radio) = self.radio {
|
self.pending_transmit_queue.push((signal, capture.frequency));
|
||||||
radio.transmit(&signal, capture.frequency)?;
|
if self.pending_transmit_restore.is_none() {
|
||||||
self.status_message = Some(format!("Sent next code for capture {} (button {})", id, button));
|
self.pending_transmit_restore = Some(self.radio_state);
|
||||||
|
self.radio_state = RadioState::Transmitting;
|
||||||
}
|
}
|
||||||
|
self.status_message = Some(format!("Sent next code for capture {} (button {})", id, button));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if there are queued transmits (UI should draw then call run_one_pending_transmit).
|
||||||
|
pub fn has_pending_transmit(&self) -> bool {
|
||||||
|
!self.pending_transmit_queue.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one queued transmit; restores radio_state when queue is empty. Call after drawing.
|
||||||
|
pub fn run_one_pending_transmit(&mut self) -> Result<()> {
|
||||||
|
let (signal, frequency) = match self.pending_transmit_queue.pop() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
if let Some(ref mut radio) = self.radio {
|
||||||
|
radio.transmit(&signal, frequency)?;
|
||||||
|
}
|
||||||
|
if self.pending_transmit_queue.is_empty() {
|
||||||
|
if let Some(prev) = self.pending_transmit_restore.take() {
|
||||||
|
self.radio_state = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,20 +1265,16 @@ impl App {
|
|||||||
self.transmit_next_code(capture_id)?;
|
self.transmit_next_code(capture_id)?;
|
||||||
}
|
}
|
||||||
SignalAction::Lock => {
|
SignalAction::Lock => {
|
||||||
let id_str = capture_id.to_string();
|
self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Lock)?;
|
||||||
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Lock)?;
|
|
||||||
}
|
}
|
||||||
SignalAction::Unlock => {
|
SignalAction::Unlock => {
|
||||||
let id_str = capture_id.to_string();
|
self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Unlock)?;
|
||||||
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Unlock)?;
|
|
||||||
}
|
}
|
||||||
SignalAction::Trunk => {
|
SignalAction::Trunk => {
|
||||||
let id_str = capture_id.to_string();
|
self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Trunk)?;
|
||||||
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Trunk)?;
|
|
||||||
}
|
}
|
||||||
SignalAction::Panic => {
|
SignalAction::Panic => {
|
||||||
let id_str = capture_id.to_string();
|
self.transmit_command(Some(capture_id.to_string()), ButtonCommand::Panic)?;
|
||||||
self.transmit_command(Some(&&*id_str.as_str()), ButtonCommand::Panic)?;
|
|
||||||
}
|
}
|
||||||
SignalAction::ExportFob => {
|
SignalAction::ExportFob => {
|
||||||
self.export_fob(capture_id)?;
|
self.export_fob(capture_id)?;
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
|
|||||||
if app.input_mode == InputMode::Command {
|
if app.input_mode == InputMode::Command {
|
||||||
app.input_mode = InputMode::Normal;
|
app.input_mode = InputMode::Normal;
|
||||||
}
|
}
|
||||||
|
while app.has_pending_transmit() {
|
||||||
|
terminal.draw(|f| draw_ui(f, app))?;
|
||||||
|
app.run_one_pending_transmit()?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
app.command_input.push(c);
|
app.command_input.push(c);
|
||||||
@@ -209,6 +213,10 @@ fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut A
|
|||||||
if app.input_mode == InputMode::SignalMenu {
|
if app.input_mode == InputMode::SignalMenu {
|
||||||
app.input_mode = InputMode::Normal;
|
app.input_mode = InputMode::Normal;
|
||||||
}
|
}
|
||||||
|
while app.has_pending_transmit() {
|
||||||
|
terminal.draw(|f| draw_ui(f, app))?;
|
||||||
|
app.run_one_pending_transmit()?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
app.input_mode = InputMode::Normal;
|
app.input_mode = InputMode::Normal;
|
||||||
|
|||||||
+21
-12
@@ -138,11 +138,19 @@ pub fn draw_ui(frame: &mut Frame, app: &App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the RSSI bar on the right (vertical bar, bottom = strong).
|
/// Render the RSSI bar on the right (vertical bar, bottom = strong). Shows " TX " in red when transmitting.
|
||||||
fn render_rssi_bar(frame: &mut Frame, area: Rect, app: &App) {
|
fn render_rssi_bar(frame: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let is_tx = app.radio_state == RadioState::Transmitting;
|
||||||
|
let (title, filled_style, empty_style) = if is_tx {
|
||||||
|
(" TX ", Style::default().fg(Color::Red), Style::default().fg(Color::DarkGray))
|
||||||
|
} else {
|
||||||
|
(" RX ", Style::default().fg(Color::Green), Style::default().fg(Color::DarkGray))
|
||||||
|
};
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(" RX ");
|
.border_style(if is_tx { Style::default().fg(Color::Red) } else { Style::default() })
|
||||||
|
.title(title);
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
@@ -150,23 +158,24 @@ fn render_rssi_bar(frame: &mut Frame, area: Rect, app: &App) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize RSSI (0..~1) to fill ratio; scale so ~0.5 magnitude ≈ full bar
|
// When TX: show full red bar. When RX: scale RSSI to fill ratio
|
||||||
let fill_ratio = (app.rssi / 0.6).min(1.0);
|
let (fill_ratio, style) = if is_tx {
|
||||||
|
(1.0_f32, filled_style)
|
||||||
|
} else {
|
||||||
|
let fill_ratio = (app.rssi / 0.6).min(1.0);
|
||||||
|
(fill_ratio, filled_style)
|
||||||
|
};
|
||||||
let filled_rows = (inner.height as f32 * fill_ratio).round() as u16;
|
let filled_rows = (inner.height as f32 * fill_ratio).round() as u16;
|
||||||
|
|
||||||
let filled_style = Style::default().fg(Color::Green);
|
|
||||||
let empty_style = Style::default().fg(Color::DarkGray);
|
|
||||||
|
|
||||||
let mut lines = Vec::with_capacity(inner.height as usize);
|
let mut lines = Vec::with_capacity(inner.height as usize);
|
||||||
for r in 0..inner.height {
|
for r in 0..inner.height {
|
||||||
let fill = r >= inner.height.saturating_sub(filled_rows);
|
let fill = r >= inner.height.saturating_sub(filled_rows);
|
||||||
let (style, ch) = if fill {
|
let (s, line_style) = if fill {
|
||||||
(filled_style, "█")
|
("█".repeat(inner.width as usize), style)
|
||||||
} else {
|
} else {
|
||||||
(empty_style, " ")
|
(" ".repeat(inner.width as usize), empty_style)
|
||||||
};
|
};
|
||||||
let s = ch.repeat(inner.width as usize);
|
lines.push(Line::from(Span::styled(s, line_style)));
|
||||||
lines.push(Line::from(Span::styled(s, style)));
|
|
||||||
}
|
}
|
||||||
let paragraph = Paragraph::new(Text::from(lines));
|
let paragraph = Paragraph::new(Text::from(lines));
|
||||||
frame.render_widget(paragraph, inner);
|
frame.render_widget(paragraph, inner);
|
||||||
|
|||||||
+67
-16
@@ -1,13 +1,19 @@
|
|||||||
//! Vulnerability database: CVE entries matched by Year, Make, Model, Region.
|
//! Vulnerability database: CVE entries matched by year range, makes, models, region.
|
||||||
//! Used for "Vuln Found" column and the Vulnerability detail panel.
|
//! Used for "Vuln Found" column and the Vulnerability detail panel.
|
||||||
|
|
||||||
/// One CVE entry. Year/Make/Model/Region use "ALL" to match any value.
|
/// One CVE entry. Year range is inclusive; "ALL" for start/end means no bound.
|
||||||
|
/// Makes and models are arrays; any match counts. Use ["ALL"] to match any make/model.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct VulnEntry {
|
pub struct VulnEntry {
|
||||||
pub cve: &'static str,
|
pub cve: &'static str,
|
||||||
pub year: &'static str,
|
/// Inclusive start year (e.g. "2018"). "ALL" = no lower bound.
|
||||||
pub make: &'static str,
|
pub year_start: &'static str,
|
||||||
pub model: &'static str,
|
/// Inclusive end year (e.g. "2021"). "ALL" = no upper bound.
|
||||||
|
pub year_end: &'static str,
|
||||||
|
/// Makes that are affected (e.g. ["Renault"], ["Honda", "Acura"]). ["ALL"] = any make.
|
||||||
|
pub makes: &'static [&'static str],
|
||||||
|
/// Models that are affected (e.g. ["ZOE"], ["Civic", "Accord"]). ["ALL"] = any model.
|
||||||
|
pub models: &'static [&'static str],
|
||||||
pub region: &'static str,
|
pub region: &'static str,
|
||||||
pub description: &'static str,
|
pub description: &'static str,
|
||||||
}
|
}
|
||||||
@@ -15,24 +21,28 @@ pub struct VulnEntry {
|
|||||||
pub const VULN_DB: [VulnEntry; 2] = [
|
pub const VULN_DB: [VulnEntry; 2] = [
|
||||||
VulnEntry {
|
VulnEntry {
|
||||||
cve: "CVE-2022-38766",
|
cve: "CVE-2022-38766",
|
||||||
year: "2021",
|
year_start: "2020",
|
||||||
make: "Renault",
|
year_end: "2022",
|
||||||
model: "ALL",
|
makes: &["Renault"],
|
||||||
|
models: &["ZOE"],
|
||||||
region: "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.",
|
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 {
|
VulnEntry {
|
||||||
cve: "CVE-2022-27254",
|
cve: "CVE-2022-27254",
|
||||||
year: "2018",
|
year_start: "2016",
|
||||||
make: "Honda",
|
year_end: "2019",
|
||||||
model: "Civic",
|
makes: &["Honda"],
|
||||||
|
models: &["Civic"],
|
||||||
region: "ALL",
|
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.",
|
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.
|
/// Match a capture's year/make/model/region against the DB.
|
||||||
/// Empty/None from capture is treated as empty string (so "ALL" matches it).
|
/// Year: parsed as number; must be in [year_start, year_end] when entry has bounds.
|
||||||
|
/// Make/Model: capture value must match one of the entry's list, or entry list contains "ALL".
|
||||||
|
/// Region: "ALL" in entry matches any; otherwise case-insensitive match.
|
||||||
pub fn match_vulns(
|
pub fn match_vulns(
|
||||||
year: Option<&str>,
|
year: Option<&str>,
|
||||||
make: Option<&str>,
|
make: Option<&str>,
|
||||||
@@ -44,17 +54,58 @@ pub fn match_vulns(
|
|||||||
let mod_ = model.unwrap_or("");
|
let mod_ = model.unwrap_or("");
|
||||||
let r = region.unwrap_or("");
|
let r = region.unwrap_or("");
|
||||||
|
|
||||||
|
let year_num: Option<u32> = y.trim().parse().ok();
|
||||||
|
|
||||||
VULN_DB
|
VULN_DB
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
(e.year == "ALL" || eq_ignore_case(e.year, y))
|
year_in_range(e.year_start, e.year_end, year_num)
|
||||||
&& (e.make == "ALL" || eq_ignore_case(e.make, m))
|
&& list_matches(e.makes, m)
|
||||||
&& (e.model == "ALL" || eq_ignore_case(e.model, mod_))
|
&& list_matches(e.models, mod_)
|
||||||
&& (e.region == "ALL" || eq_ignore_case(e.region, r))
|
&& (e.region == "ALL" || eq_ignore_case(e.region, r))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn year_in_range(start: &str, end: &str, capture_year: Option<u32>) -> bool {
|
||||||
|
let has_start = start != "ALL" && !start.trim().is_empty();
|
||||||
|
let has_end = end != "ALL" && !end.trim().is_empty();
|
||||||
|
if !has_start && !has_end {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Some(yr) = capture_year else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if has_start {
|
||||||
|
let Ok(s) = start.trim().parse::<u32>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if yr < s {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_end {
|
||||||
|
let Ok(e) = end.trim().parse::<u32>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if yr > e {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_matches(list: &[&'static str], capture_value: &str) -> bool {
|
||||||
|
if list.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if list.iter().any(|s| *s == "ALL") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
list.iter()
|
||||||
|
.any(|s| eq_ignore_case(s, capture_value))
|
||||||
|
}
|
||||||
|
|
||||||
fn eq_ignore_case(a: &str, b: &str) -> bool {
|
fn eq_ignore_case(a: &str, b: &str) -> bool {
|
||||||
a.eq_ignore_ascii_case(b)
|
a.eq_ignore_ascii_case(b)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user