Feat: Vertrauensprüfung für Netzwerke vor nmap-Scan (Schritt 3)

Ein ARP-/Ping-Scan ganzer Subnetze ist auf Netzwerken, die man nicht selbst
administriert (Firmen-/Gast-WLAN etc.), potenziell heikel (IDS-Alarm, Verstoß
gegen Nutzungsordnungen). nmap wird deshalb künftig nur noch in explizit
vertrauten Netzwerken ausgeführt.

Das aktuelle Netzwerk wird über die MAC-Adresse seines Default-Gateways
identifiziert (stabil über IP-/Subnetzwechsel hinweg, unabhängig von SSID).
Neues Modul src/trust.rs prüft vor Schritt 3:
- statisch konfigurierte Gateway-MACs (AppConfig::trusted_networks, via
  --trusted-networks/MAC2IP_TRUSTED_NETWORKS oder Config-Datei),
- zuvor per Rückfrage bestätigte Netzwerke (persistiert in einer neuen
  trusted_networks-Tabelle im bestehenden globalen Cache),
- automatische Bestätigung via --auto-trust-networks (merkt sich das
  Netzwerk danach ebenfalls im Cache).

Ist nichts davon der Fall, wird im interaktiven Modus auf stderr
nachgefragt; im --json-Modus ist keine Rückfrage möglich, daher wird der
Scan sicherheitshalber abgelehnt (neuer Fehler Mac2IpError::UntrustedNetwork).

src/network.rs: neue Exec-/Parse-Funktionen zur Gateway-MAC-Ermittlung
(run_ip_route_show_default, parse_default_gateway_ip,
parse_ip_neigh_mac_for_ip, detect_default_gateway_mac).

Ergänzt Unit-/Integrationstests (tests/gateway_trust.rs,
tests/config_overrides.rs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnWfGGqHJGh2AhZ6uhotaD
This commit is contained in:
2026-09-13 20:10:51 +02:00
co-authored by Claude Sonnet 5
parent 4ebbbc93f7
commit 1141d18f66
11 changed files with 414 additions and 17 deletions
+61 -1
View File
@@ -38,9 +38,63 @@ impl Cache {
(),
)
.await?;
self.conn
.execute(
"CREATE TABLE IF NOT EXISTS trusted_networks (\
gateway_mac TEXT PRIMARY KEY, \
trusted_at INTEGER NOT NULL\
)",
(),
)
.await?;
Ok(())
}
/// Best-effort: gibt bei jedem Fehler `false` zurück (nur eine Warnung wird geloggt) -
/// ein Cache-Lesefehler darf ein zuvor bereits bestätigtes Netzwerk nicht plötzlich
/// als unbekannt erscheinen lassen, aber sicherheitshalber auch nicht automatisch
/// als vertrauenswürdig gelten lassen; der Aufrufer fragt in diesem Fall erneut nach.
pub async fn is_trusted_network(&self, gateway_mac: &MacAddress) -> bool {
let key = gateway_mac.to_lower_colon();
match self
.conn
.query(
"SELECT 1 FROM trusted_networks WHERE gateway_mac = ?1",
(key,),
)
.await
{
Ok(mut rows) => matches!(rows.next().await, Ok(Some(_))),
Err(e) => {
crate::log::warn(
"cache",
&format!("Cache-Lesefehler (trusted_networks): {e}"),
);
false
}
}
}
/// Best-effort Upsert: merkt sich ein Netzwerk (identifiziert über die Gateway-MAC)
/// dauerhaft als vertrauenswürdig für zukünftige nmap-Scans.
pub async fn trust_network(&self, gateway_mac: &MacAddress, now: i64) {
let key = gateway_mac.to_lower_colon();
if let Err(e) = self
.conn
.execute(
"INSERT INTO trusted_networks (gateway_mac, trusted_at) VALUES (?1, ?2) \
ON CONFLICT(gateway_mac) DO UPDATE SET trusted_at = excluded.trusted_at",
(key, now),
)
.await
{
crate::log::warn(
"cache",
&format!("Cache-Schreibfehler (trusted_networks, ignoriert): {e}"),
);
}
}
/// Best-effort: gibt bei jedem Fehler `None` zurück (nur eine Warnung wird geloggt).
pub async fn get(&self, mac: &MacAddress) -> Option<CacheEntry> {
let key = mac.to_lower_colon();
@@ -189,7 +243,13 @@ mod tests {
#[test]
fn test_ensure_cache_dir_creates_new_directory() {
let temp_dir = std::env::temp_dir().join(format!("mac2ip_test_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
let temp_dir = std::env::temp_dir().join(format!(
"mac2ip_test_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
assert!(!temp_dir.exists());
assert!(ensure_cache_dir(&temp_dir).is_ok());
assert!(temp_dir.exists());
+11
View File
@@ -45,6 +45,17 @@ pub struct Cli {
/// Kommagetrennte Liste von CIDR-Subnetzen für den nmap-Scan (überschreibt Auto-Erkennung)
#[arg(long, env = "MAC2IP_NETWORKS", value_delimiter = ',')]
pub networks: Option<Vec<String>>,
/// Kommagetrennte Liste von Gateway-MAC-Adressen, deren Netzwerke ohne Rückfrage für
/// nmap-Scans (Schritt 3) vertraut werden (überschreibt die Konfigurationsdatei vollständig)
#[arg(long, env = "MAC2IP_TRUSTED_NETWORKS", value_delimiter = ',')]
pub trusted_networks: Option<Vec<MacAddress>>,
/// Beantwortet die "nmap-Scan in diesem Netzwerk erlauben?"-Rückfrage vor Schritt 3
/// automatisch mit Ja (und merkt sich das Netzwerk dauerhaft im Cache), statt
/// interaktiv nachzufragen bzw. im --json-Modus den Scan abzulehnen
#[arg(long)]
pub auto_trust_networks: bool,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
+23
View File
@@ -4,6 +4,8 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::mac::MacAddress;
pub const DEFAULT_CACHE_TTL_SECONDS: u64 = 1800; // 30 Min: Ping-Check ist die primäre
// Absicherung gegen veraltete Einträge, die TTL ist nur eine zusätzliche Absicherung
// gegen den Fall, dass eine alte IP inzwischen an ein anderes, ebenfalls
@@ -40,6 +42,11 @@ pub struct AppConfig {
pub nmap_timeout_seconds: u64,
#[serde(default)]
pub networks: Vec<String>,
/// Gateway-MAC-Adressen von Netzwerken, in denen ein nmap-Scan (Schritt 3) ohne
/// interaktive Rückfrage erlaubt ist. Wird um per Rückfrage/`--auto-trust-networks`
/// bestätigte Netzwerke ergänzt, die im Cache gespeichert werden (siehe `src/trust.rs`).
#[serde(default)]
pub trusted_networks: Vec<String>,
}
impl Default for AppConfig {
@@ -50,10 +57,23 @@ impl Default for AppConfig {
log_level: DEFAULT_LOG_LEVEL.to_string(),
nmap_timeout_seconds: DEFAULT_NMAP_TIMEOUT_SECONDS,
networks: Vec::new(),
trusted_networks: Vec::new(),
}
}
}
impl AppConfig {
/// Reine, testbare Funktion: validiert die konfigurierten vertrauten Netzwerke.
/// Nicht als MAC-Adresse parsbare Einträge werden übersprungen (kein harter Fehler,
/// da eine fehlerhafte Config-Zeile den Lookup nicht insgesamt scheitern lassen soll).
pub fn trusted_network_macs(&self) -> Vec<MacAddress> {
self.trusted_networks
.iter()
.filter_map(|s| MacAddress::parse(s).ok())
.collect()
}
}
/// Initialisiert den Konfigurationspfad bei `config-ctdra` (Standardname "config",
/// optionaler expliziter Pfad via `--config`).
pub fn init_config_path(custom_path: Option<&Path>) {
@@ -90,4 +110,7 @@ pub fn apply_cli_overrides(config: &mut AppConfig, cli: &crate::cli::Cli) {
if let Some(networks) = &cli.networks {
config.networks = networks.clone();
}
if let Some(trusted) = &cli.trusted_networks {
config.trusted_networks = trusted.iter().map(MacAddress::to_lower_colon).collect();
}
}
+5
View File
@@ -6,6 +6,11 @@ use crate::mac::MacAddressError;
pub enum Mac2IpError {
#[error("keine IP-Adresse für MAC '{mac}' gefunden")]
NotFound { mac: String },
#[error(
"nmap-Scan abgelehnt: Netzwerk nicht vertrauenswürdig{}",
.gateway_mac.as_ref().map(|m| format!(" (Gateway-MAC {m})")).unwrap_or_default()
)]
UntrustedNetwork { gateway_mac: Option<String> },
#[error("MAC-Adresse ungültig: {0}")]
InvalidMac(#[from] MacAddressError),
#[error("E/A-Fehler: {0}")]
+1
View File
@@ -10,3 +10,4 @@ pub mod mac;
pub mod network;
pub mod output;
pub mod resolver;
pub mod trust;
+9 -1
View File
@@ -23,7 +23,15 @@ async fn main() -> ExitCode {
let cache = cache::try_open_cache(&app_config.cache_db_path).await;
match resolver::resolve(&cli.mac, &app_config, cache.as_ref(), cli.json).await {
match resolver::resolve(
&cli.mac,
&app_config,
cache.as_ref(),
cli.json,
cli.auto_trust_networks,
)
.await
{
Ok(result) => {
output::print_success(&result, cli.json);
ExitCode::SUCCESS
+69 -15
View File
@@ -30,6 +30,31 @@ pub async fn run_ip_route_show_scope_link() -> std::io::Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub async fn run_ip_route_show_default() -> std::io::Result<String> {
let out = tokio::process::Command::new("ip")
.args(["-4", "route", "show", "default"])
.output()
.await?;
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Ermittelt die MAC-Adresse des aktuellen Default-Gateways - dient als stabiler
/// Identifikator für "dieses Netzwerk" (bleibt bei DHCP-Subnetzwechsel gleich,
/// solange derselbe Router im Einsatz ist), unabhängig von SSID/CIDR.
/// Gibt `None` zurück, wenn kein Default-Gateway existiert oder dessen MAC nicht
/// ermittelt werden kann (z. B. kein Nachbar-Eintrag trotz Ping).
pub async fn detect_default_gateway_mac() -> Option<MacAddress> {
let route_output = run_ip_route_show_default().await.ok()?;
let gateway_ip = parse_default_gateway_ip(&route_output)?;
// Erzwingt best-effort einen frischen Nachbar-Tabelleneintrag; Ergebnis wird
// ignoriert, da `ip neigh` auch von einem veralteten/STALE-Eintrag profitiert.
let _ = ping_check(gateway_ip).await;
let neigh_output = run_ip_neigh_show().await.ok()?;
parse_ip_neigh_mac_for_ip(&neigh_output, gateway_ip)
}
pub async fn ping_check(ip: IpAddr) -> bool {
tokio::process::Command::new("ping")
.args([
@@ -106,22 +131,23 @@ pub async fn run_nmap_scan(
let child = cmd.spawn()?;
let pgid = child.id();
let out = match tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output())
.await
{
Ok(out) => out?,
Err(_) => {
if let Some(pgid) = pgid {
// Negative PID = Signal an die gesamte Prozessgruppe (sudo + nmap).
let _ = tokio::process::Command::new("kill")
.arg("-KILL")
.arg(format!("-{pgid}"))
.output()
.await;
let out =
match tokio::time::timeout(Duration::from_secs(timeout_secs), child.wait_with_output())
.await
{
Ok(out) => out?,
Err(_) => {
if let Some(pgid) = pgid {
// Negative PID = Signal an die gesamte Prozessgruppe (sudo + nmap).
let _ = tokio::process::Command::new("kill")
.arg("-KILL")
.arg(format!("-{pgid}"))
.output()
.await;
}
return Err(NmapRunError::Timeout);
}
return Err(NmapRunError::Timeout);
}
};
};
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
@@ -159,6 +185,34 @@ pub fn parse_ip_neigh_output(output: &str, mac: &MacAddress) -> Option<IpAddr> {
None
}
/// Parst `ip -4 route show default`, z. B. "default via 192.168.1.1 dev eth0 proto dhcp
/// metric 100". Gibt die Gateway-IP zurück (Token direkt nach "via").
pub fn parse_default_gateway_ip(output: &str) -> Option<IpAddr> {
output.lines().find_map(|line| {
let tokens: Vec<&str> = line.split_whitespace().collect();
let pos = tokens.iter().position(|&t| t == "via")?;
tokens.get(pos + 1)?.parse().ok()
})
}
/// Parst `ip neigh show`-Ausgabe und gibt die `lladdr`-MAC des Eintrags zurück, dessen
/// erste Spalte (IP) mit `target_ip` übereinstimmt. Kehrbild zu [`parse_ip_neigh_output`].
pub fn parse_ip_neigh_mac_for_ip(output: &str, target_ip: IpAddr) -> Option<MacAddress> {
for line in output.lines() {
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.first().and_then(|t| t.parse::<IpAddr>().ok()) != Some(target_ip) {
continue;
}
if let Some(pos) = tokens.iter().position(|&t| t == "lladdr")
&& let Some(&mac_str) = tokens.get(pos + 1)
&& let Ok(mac) = mac_str.parse()
{
return Some(mac);
}
}
None
}
/// Extrahiert lokale CIDR-Subnetze aus `ip -4 route show scope link`.
/// Die CIDR ist bereits das erste whitespace-getrennte Token pro passender Zeile.
/// Überspringt `default`-Zeilen und das Loopback-Interface.
+16
View File
@@ -8,6 +8,7 @@ use crate::config::AppConfig;
use crate::error::Mac2IpError;
use crate::mac::MacAddress;
use crate::network::{self, NmapRunError, SudoMode};
use crate::trust::{self, TrustDecision};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchSource {
@@ -44,6 +45,7 @@ pub async fn resolve(
config: &AppConfig,
cache: Option<&Cache>,
json_mode: bool,
auto_trust_networks: bool,
) -> Result<ResolveResult, Mac2IpError> {
let now = now_unix();
@@ -86,6 +88,20 @@ pub async fn resolve(
// Schritt 3: nmap
crate::log::debug("resolver", "Kein Treffer via ip neigh, starte nmap-Scan");
match trust::ensure_network_trusted(config, cache, auto_trust_networks, json_mode, now).await {
TrustDecision::Allowed => {}
TrustDecision::Denied { gateway_mac } => {
crate::log::warn(
"resolver",
"nmap-Scan übersprungen: Netzwerk nicht als vertrauenswürdig bestätigt",
);
return Err(Mac2IpError::UntrustedNetwork {
gateway_mac: gateway_mac.map(|m| m.to_string()),
});
}
}
let subnets = if !config.networks.is_empty() {
config.networks.clone()
} else {
+110
View File
@@ -0,0 +1,110 @@
//! Netzwerk-Vertrauensprüfung vor dem nmap-Scan (Schritt 3).
//!
//! Ein ARP-/Ping-Scan ganzer Subnetze ist auf Netzwerken, die man nicht selbst
//! administriert (Firmen-/Gast-WLAN etc.), potenziell heikel (IDS-Alarm, Verstoß gegen
//! Nutzungsordnungen). Das aktuelle Netzwerk wird daher über die MAC-Adresse seines
//! Default-Gateways identifiziert (stabil über IP-/Subnetzwechsel hinweg) und nmap darf
//! nur laufen, wenn dieses Netzwerk explizit vertraut ist:
//! - statisch über `AppConfig::trusted_networks` (Config-Datei/CLI/ENV), oder
//! - dynamisch über eine frühere Bestätigung, die im Cache gespeichert wurde, oder
//! - automatisch, wenn `--auto-trust-networks` gesetzt ist (merkt sich das Netzwerk
//! danach ebenfalls im Cache).
//!
//! Ist keins davon der Fall, wird im interaktiven Modus nachgefragt; im --json-Modus
//! ist keine Rückfrage möglich, daher wird sicherheitshalber abgelehnt.
use std::io::Write;
use crate::cache::Cache;
use crate::config::AppConfig;
use crate::mac::MacAddress;
use crate::network;
pub enum TrustDecision {
Allowed,
Denied { gateway_mac: Option<MacAddress> },
}
pub async fn ensure_network_trusted(
config: &AppConfig,
cache: Option<&Cache>,
auto_trust: bool,
json_mode: bool,
now: i64,
) -> TrustDecision {
let Some(gateway_mac) = network::detect_default_gateway_mac().await else {
crate::log::warn(
"trust",
"Gateway-MAC konnte nicht ermittelt werden, Netzwerk kann nicht identifiziert werden",
);
if auto_trust {
crate::log::warn(
"trust",
"--auto-trust-networks gesetzt: nmap-Scan wird trotz unbekannter Gateway-MAC ausgeführt",
);
return TrustDecision::Allowed;
}
return TrustDecision::Denied { gateway_mac: None };
};
if config.trusted_network_macs().contains(&gateway_mac) {
return TrustDecision::Allowed;
}
if let Some(cache) = cache
&& cache.is_trusted_network(&gateway_mac).await
{
return TrustDecision::Allowed;
}
if auto_trust {
crate::log::info(
"trust",
&format!(
"Netzwerk (Gateway-MAC {gateway_mac}) automatisch als vertrauenswürdig markiert"
),
);
if let Some(cache) = cache {
cache.trust_network(&gateway_mac, now).await;
}
return TrustDecision::Allowed;
}
if json_mode {
// Keine Rückfrage im --json-Modus möglich (stdin ggf. nicht an ein Terminal
// gebunden, Ausgabe darf nicht durch einen Prompt verunreinigt werden).
return TrustDecision::Denied {
gateway_mac: Some(gateway_mac),
};
}
if prompt_trust_confirmation(gateway_mac).await {
if let Some(cache) = cache {
cache.trust_network(&gateway_mac, now).await;
}
TrustDecision::Allowed
} else {
TrustDecision::Denied {
gateway_mac: Some(gateway_mac),
}
}
}
/// Blockierende stdin-Abfrage, daher in `spawn_blocking` ausgelagert, damit der
/// Tokio-Runtime-Thread währenddessen nicht blockiert.
async fn prompt_trust_confirmation(gateway_mac: MacAddress) -> bool {
tokio::task::spawn_blocking(move || {
eprint!(
"Unbekanntes Netzwerk (Gateway-MAC {gateway_mac}). nmap-Scan in diesem Netzwerk erlauben und dauerhaft merken? [y/N]: "
);
let _ = std::io::stderr().flush();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes" | "j" | "ja")
})
.await
.unwrap_or(false)
}
+15
View File
@@ -14,6 +14,8 @@ fn base_cli() -> Cli {
cache_db_path: None,
nmap_timeout_seconds: None,
networks: None,
trusted_networks: None,
auto_trust_networks: false,
}
}
@@ -61,6 +63,18 @@ fn overrides_networks() {
assert_eq!(config.networks, vec!["10.0.0.0/24".to_string()]);
}
#[test]
fn overrides_trusted_networks() {
let mut config = AppConfig::default();
let mut cli = base_cli();
cli.trusted_networks = Some(vec![MacAddress::parse("aa:bb:cc:dd:ee:ff").unwrap()]);
apply_cli_overrides(&mut config, &cli);
assert_eq!(
config.trusted_networks,
vec!["aa:bb:cc:dd:ee:ff".to_string()]
);
}
#[test]
fn partial_overrides_only_touch_provided_fields() {
let default_config = AppConfig::default();
@@ -77,4 +91,5 @@ fn partial_overrides_only_touch_provided_fields() {
default_config.nmap_timeout_seconds
);
assert_eq!(config.networks, default_config.networks);
assert_eq!(config.trusted_networks, default_config.trusted_networks);
}
+94
View File
@@ -0,0 +1,94 @@
use std::net::IpAddr;
use mac2ip::config::AppConfig;
use mac2ip::mac::MacAddress;
use mac2ip::network::{parse_default_gateway_ip, parse_ip_neigh_mac_for_ip};
fn mac(s: &str) -> MacAddress {
MacAddress::parse(s).unwrap()
}
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
// --- parse_default_gateway_ip ---
#[test]
fn parses_gateway_ip_from_default_route() {
let output = "default via 192.168.1.1 dev eth0 proto dhcp metric 100\n";
assert_eq!(parse_default_gateway_ip(output), Some(ip("192.168.1.1")));
}
#[test]
fn parses_gateway_ip_ignoring_extra_trailing_fields() {
let output = "default via 10.0.0.254 dev wlan0 proto static metric 600 onlink\n";
assert_eq!(parse_default_gateway_ip(output), Some(ip("10.0.0.254")));
}
#[test]
fn no_default_route_returns_none() {
assert!(parse_default_gateway_ip("").is_none());
}
#[test]
fn missing_via_token_returns_none() {
let output = "default dev eth0 proto dhcp metric 100\n";
assert!(parse_default_gateway_ip(output).is_none());
}
// --- parse_ip_neigh_mac_for_ip ---
#[test]
fn finds_mac_for_matching_ip() {
let output = "192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n";
let result = parse_ip_neigh_mac_for_ip(output, ip("192.168.1.1"));
assert_eq!(result, Some(mac("aa:bb:cc:dd:ee:ff")));
}
#[test]
fn ignores_entries_for_other_ips() {
let output = "\
192.168.1.2 dev eth0 lladdr 11:22:33:44:55:66 STALE
192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
";
let result = parse_ip_neigh_mac_for_ip(output, ip("192.168.1.1"));
assert_eq!(result, Some(mac("aa:bb:cc:dd:ee:ff")));
}
#[test]
fn returns_none_when_ip_not_present() {
let output = "192.168.1.2 dev eth0 lladdr 11:22:33:44:55:66 STALE\n";
assert!(parse_ip_neigh_mac_for_ip(output, ip("192.168.1.1")).is_none());
}
// --- AppConfig::trusted_network_macs ---
#[test]
fn trusted_network_macs_parses_valid_entries() {
let config = AppConfig {
trusted_networks: vec!["aa:bb:cc:dd:ee:ff".to_string()],
..Default::default()
};
assert_eq!(
config.trusted_network_macs(),
vec![mac("aa:bb:cc:dd:ee:ff")]
);
}
#[test]
fn trusted_network_macs_skips_invalid_entries() {
let config = AppConfig {
trusted_networks: vec!["not-a-mac".to_string(), "aa:bb:cc:dd:ee:ff".to_string()],
..Default::default()
};
assert_eq!(
config.trusted_network_macs(),
vec![mac("aa:bb:cc:dd:ee:ff")]
);
}
#[test]
fn trusted_network_macs_empty_by_default() {
assert!(AppConfig::default().trusted_network_macs().is_empty());
}