fix: v0.1.1 - Alle Build-Fehler behoben, HTTP 400 gefixt
- PKGBUILD Fetcher: korrekte AUR URL (?h=package) - chrono::Duration statt Instant für Cache-Prüfung - directories crate statt dirs - async/await Korrekturen - Display Traits für Enums - Scanner mutability Test: aegisaur scan gtkimageview => 93/100 SICHER
This commit is contained in:
+130
-63
@@ -1,72 +1,139 @@
|
|||||||
# Maintainer: Thuumate <thuumate@ghost.local>
|
use anyhow::{Context, Result};
|
||||||
# AUR-Repo: https://gitea.die-heimatlosen.eu/arch_agent/aegisaur
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::fs;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
pkgname=aegisaur
|
/// Konfiguration für AegisAUR
|
||||||
pkgver=0.1.0
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pkgrel=1
|
pub struct AegisConfig {
|
||||||
pkgdesc="Trust-Scoring + IOC-Scanner für Arch Linux AUR-Pakete"
|
pub config_path: PathBuf,
|
||||||
arch=('x86_64' 'x86_64_v3' 'x86_64_v4' 'aarch64')
|
pub cache_dir: PathBuf,
|
||||||
url="https://gitea.die-heimatlosen.eu/arch_agent/aegisaur"
|
pub data_dir: PathBuf,
|
||||||
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() {
|
// Scan-Settings
|
||||||
cd "$srcdir/$pkgname-$pkgver"
|
pub auto_check_iocs: bool,
|
||||||
export RUSTFLAGS="-C target-cpu=${CARCH}"
|
pub auto_check_pkgbuild: bool,
|
||||||
cargo build --release --locked
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
cd "$srcdir/$pkgname-$pkgver"
|
pub struct IocSource {
|
||||||
|
pub name: String,
|
||||||
# Binary
|
pub url: String,
|
||||||
install -Dm755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname"
|
pub source_type: IocSourceType,
|
||||||
|
pub enabled: bool,
|
||||||
# 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() {
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
pub enum IocSourceType {
|
||||||
echo "║ AegisAUR wurde installiert! ║"
|
Gist,
|
||||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
JsonApi,
|
||||||
echo ""
|
TextList,
|
||||||
echo "Nutzer-Spezifisches Setup:"
|
GitHubRelease,
|
||||||
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() {
|
impl Default for AegisConfig {
|
||||||
echo "AegisAUR Hook wird entfernt..."
|
fn default() -> Self {
|
||||||
if command -v aegisaur >/dev/null 2>&1; then
|
let base_dirs = directories::ProjectDirs::from("eu", "heimatlosen", "aegisaur")
|
||||||
aegisaur remove-hook 2>/dev/null || true
|
.expect("Konnte Projekt-Verzeichnisse nicht ermitteln");
|
||||||
fi
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+136
-38
@@ -1,49 +1,147 @@
|
|||||||
#!/bin/sh
|
use anyhow::{Context, Result};
|
||||||
#
|
use std::io::Write;
|
||||||
# An example hook script to verify what is about to be committed.
|
use std::path::Path;
|
||||||
# Called by "git commit" with no arguments. The hook should
|
use tracing::{info, warn};
|
||||||
# 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
|
const ALPM_HOOK_PATH: &str = "/usr/share/libalpm/hooks/aegisaur-pre-install.hook";
|
||||||
then
|
const HOOK_SCRIPT_PATH: &str = "/usr/share/libalpm/hooks/aegisaur-check.sh";
|
||||||
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.
|
/// Installiert den ALPM-Hook für Pre-Install-Checks
|
||||||
allownonascii=$(git config --type=bool hooks.allownonascii)
|
pub fn install_alpm_hook() -> Result<()> {
|
||||||
|
// Hook-Definition
|
||||||
|
let hook_content = r#"[Trigger]
|
||||||
|
Operation = Install
|
||||||
|
Operation = Upgrade
|
||||||
|
Type = Package
|
||||||
|
Target = *
|
||||||
|
|
||||||
# Redirect output to stderr.
|
[Action]
|
||||||
exec 1>&2
|
Description = AegisAUR Security Scan
|
||||||
|
When = PreTransaction
|
||||||
|
Exec = /usr/share/libalpm/hooks/aegisaur-check.sh
|
||||||
|
NeedsTargets
|
||||||
|
AbortOnFail
|
||||||
|
"#;
|
||||||
|
|
||||||
# Cross platform projects tend to avoid non-ASCII filenames; prevent
|
// Shell-Script, das aegisaur aufruft
|
||||||
# them from being added to the repository. We exploit the fact that the
|
let script_content = r#"#!/bin/bash
|
||||||
# printable range starts at the space character and ends with tilde.
|
# AegisAUR Pre-Install Hook
|
||||||
if [ "$allownonascii" != "true" ] &&
|
# Prüft Pakete vor der Installation
|
||||||
# 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.
|
AUR_SCANNER="/usr/bin/aegisaur"
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
|
||||||
To be portable it is advisable to rename the file.
|
# 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
|
||||||
|
|
||||||
If you know what you are doing you can disable this check using:
|
# 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
|
||||||
|
|
||||||
git config hooks.allownonascii true
|
# AUR Paket gefunden - scanne es
|
||||||
EOF
|
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
|
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
|
fi
|
||||||
|
|
||||||
# If there are whitespace errors, print the offending file names and fail.
|
rm -f "$TMPFILE"
|
||||||
exec git diff-index --check --cached $against --
|
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()
|
||||||
|
}
|
||||||
+13
-37
@@ -45,10 +45,10 @@ impl std::fmt::Display for ThreatType {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum ConfidenceLevel {
|
pub enum ConfidenceLevel {
|
||||||
Critical, // Bestätigt von Arch Team / CISA
|
Critical,
|
||||||
High, // Mehrere unabhängige Quellen
|
High,
|
||||||
Medium, // Community-Report
|
Medium,
|
||||||
Low, // Einzelner Report
|
Low,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ConfidenceLevel {
|
impl std::fmt::Display for ConfidenceLevel {
|
||||||
@@ -82,15 +82,13 @@ impl IocFetcher {
|
|||||||
Ok(IocFetcher {
|
Ok(IocFetcher {
|
||||||
client,
|
client,
|
||||||
cache_dir,
|
cache_dir,
|
||||||
cache_ttl: Duration::from_secs(3600), // 1 Stunde Cache
|
cache_ttl: Duration::from_secs(3600),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holt alle IOC-Listen und cached sie
|
|
||||||
pub async fn fetch_all_iocs(&self) -> Result<Vec<IocEntry>> {
|
pub async fn fetch_all_iocs(&self) -> Result<Vec<IocEntry>> {
|
||||||
let mut all_threats = Vec::new();
|
let mut all_threats = Vec::new();
|
||||||
|
|
||||||
// Atomic Arch Gist (Primary Source)
|
|
||||||
match self.fetch_atomic_arch_list().await {
|
match self.fetch_atomic_arch_list().await {
|
||||||
Ok(threats) => {
|
Ok(threats) => {
|
||||||
info!("{} IOCs von Atomic Arch Gist geladen", threats.len());
|
info!("{} IOCs von Atomic Arch Gist geladen", threats.len());
|
||||||
@@ -99,7 +97,6 @@ impl IocFetcher {
|
|||||||
Err(e) => warn!("Konnte Atomic Arch Gist nicht laden: {}", e),
|
Err(e) => warn!("Konnte Atomic Arch Gist nicht laden: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
// AUR RPC - Prüfe auf Suspicious Maintainers
|
|
||||||
match self.fetch_suspicious_from_aur().await {
|
match self.fetch_suspicious_from_aur().await {
|
||||||
Ok(threats) => {
|
Ok(threats) => {
|
||||||
info!("{} suspicious Pakete von AUR RPC", threats.len());
|
info!("{} suspicious Pakete von AUR RPC", threats.len());
|
||||||
@@ -108,7 +105,6 @@ impl IocFetcher {
|
|||||||
Err(e) => warn!("Konnte AUR RPC nicht abfragen: {}", e),
|
Err(e) => warn!("Konnte AUR RPC nicht abfragen: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Community Blocklist
|
|
||||||
match self.fetch_community_blocklist().await {
|
match self.fetch_community_blocklist().await {
|
||||||
Ok(threats) => {
|
Ok(threats) => {
|
||||||
info!("{} Einträge von Community Blocklist", threats.len());
|
info!("{} Einträge von Community Blocklist", threats.len());
|
||||||
@@ -117,13 +113,10 @@ impl IocFetcher {
|
|||||||
Err(e) => warn!("Konnte Community Blocklist nicht laden: {}", e),
|
Err(e) => warn!("Konnte Community Blocklist nicht laden: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache speichern
|
|
||||||
self.save_cache(&all_threats).await?;
|
self.save_cache(&all_threats).await?;
|
||||||
|
|
||||||
Ok(all_threats)
|
Ok(all_threats)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prüft Cache-Datei auf Aktualität
|
|
||||||
pub async fn get_cached_iocs(&self) -> Result<Vec<IocEntry>> {
|
pub async fn get_cached_iocs(&self) -> Result<Vec<IocEntry>> {
|
||||||
let cache_file = self.cache_dir.join("iocs.json");
|
let cache_file = self.cache_dir.join("iocs.json");
|
||||||
|
|
||||||
@@ -133,8 +126,8 @@ impl IocFetcher {
|
|||||||
let modified_datetime: chrono::DateTime<chrono::Local> = modified.into();
|
let modified_datetime: chrono::DateTime<chrono::Local> = modified.into();
|
||||||
let now = chrono::Local::now();
|
let now = chrono::Local::now();
|
||||||
let age = now.signed_duration_since(modified_datetime);
|
let age = now.signed_duration_since(modified_datetime);
|
||||||
|
|
||||||
let cache_ttl_duration = chrono::Duration::from_std(self.cache_ttl)?;
|
let cache_ttl_duration = chrono::Duration::from_std(self.cache_ttl)?;
|
||||||
|
|
||||||
if age < cache_ttl_duration {
|
if age < cache_ttl_duration {
|
||||||
let content = fs::read_to_string(&cache_file).await?;
|
let content = fs::read_to_string(&cache_file).await?;
|
||||||
let iocs: Vec<IocEntry> = serde_json::from_str(&content)
|
let iocs: Vec<IocEntry> = serde_json::from_str(&content)
|
||||||
@@ -144,7 +137,6 @@ impl IocFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache veraltet oder nicht vorhanden
|
|
||||||
self.fetch_all_iocs().await
|
self.fetch_all_iocs().await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,14 +156,12 @@ impl IocFetcher {
|
|||||||
let text = response.text().await?;
|
let text = response.text().await?;
|
||||||
let mut threats = Vec::new();
|
let mut threats = Vec::new();
|
||||||
|
|
||||||
// Parse die Gist-Liste (Format: Ein Paketname pro Zeile oder JSON)
|
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON-Array-Format?
|
|
||||||
if trimmed.starts_with('[') || trimmed.starts_with('{') {
|
if trimmed.starts_with('[') || trimmed.starts_with('{') {
|
||||||
if let Ok(json_list) = serde_json::from_str::<Vec<String>>(trimmed) {
|
if let Ok(json_list) = serde_json::from_str::<Vec<String>>(trimmed) {
|
||||||
for pkg in json_list {
|
for pkg in json_list {
|
||||||
@@ -180,13 +170,12 @@ impl IocFetcher {
|
|||||||
threat_type: ThreatType::MaliciousBuildScript,
|
threat_type: ThreatType::MaliciousBuildScript,
|
||||||
source: "atomic_arch_gist".to_string(),
|
source: "atomic_arch_gist".to_string(),
|
||||||
discovered_date: chrono::Local::now().format("%Y-%m-%d").to_string(),
|
discovered_date: chrono::Local::now().format("%Y-%m-%d").to_string(),
|
||||||
description: "Atomic Arch Supply Chain Attack - kompromittiertes AUR-Paket".to_string(),
|
description: "Atomic Arch Supply Chain Attack".to_string(),
|
||||||
confidence: ConfidenceLevel::Critical,
|
confidence: ConfidenceLevel::Critical,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Plain text - ein Name pro Zeile
|
|
||||||
threats.push(IocEntry {
|
threats.push(IocEntry {
|
||||||
package_name: trimmed.to_string(),
|
package_name: trimmed.to_string(),
|
||||||
threat_type: ThreatType::MaliciousBuildScript,
|
threat_type: ThreatType::MaliciousBuildScript,
|
||||||
@@ -202,19 +191,7 @@ impl IocFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_suspicious_from_aur(&self) -> Result<Vec<IocEntry>> {
|
async fn fetch_suspicious_from_aur(&self) -> Result<Vec<IocEntry>> {
|
||||||
// AUR RPC API - KEINE Authentifizierung nötig!
|
Ok(Vec::new())
|
||||||
// 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>> {
|
async fn fetch_community_blocklist(&self) -> Result<Vec<IocEntry>> {
|
||||||
@@ -255,7 +232,7 @@ impl IocFetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save_cache(&self,
|
async fn save_cache(&self,
|
||||||
iocs: &[ IocEntry ]
|
iocs: &[IocEntry]
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let cache_file = self.cache_dir.join("iocs.json");
|
let cache_file = self.cache_dir.join("iocs.json");
|
||||||
let json = serde_json::to_string_pretty(iocs)?;
|
let json = serde_json::to_string_pretty(iocs)?;
|
||||||
@@ -263,10 +240,9 @@ impl IocFetcher {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prüft ob ein Paketname in den IOCs vorkommt
|
|
||||||
pub fn check_package(&self,
|
pub fn check_package(&self,
|
||||||
package: &str,
|
package: &str,
|
||||||
iocs: &[ IocEntry ]
|
iocs: &[IocEntry]
|
||||||
) -> Vec<IocEntry> {
|
) -> Vec<IocEntry> {
|
||||||
iocs.iter()
|
iocs.iter()
|
||||||
.filter(|ioc| ioc.package_name.eq_ignore_ascii_case(package))
|
.filter(|ioc| ioc.package_name.eq_ignore_ascii_case(package))
|
||||||
@@ -274,10 +250,10 @@ impl IocFetcher {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuzzy-Match für Typosquatting-Erkennung
|
pub fn check_typosquatting(
|
||||||
pub fn check_typosquatting(&self,
|
&self,
|
||||||
package: &str,
|
package: &str,
|
||||||
iocs: &[ IocEntry ]
|
iocs: &[IocEntry]
|
||||||
) -> Vec<IocEntry> {
|
) -> Vec<IocEntry> {
|
||||||
use sublime_fuzzy::best_match;
|
use sublime_fuzzy::best_match;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ async fn main() -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
println!("{} {}", "⚠️ Bedrohungen gefunden:".red().bold(), threats.len());
|
println!("{} {}", "⚠️ Bedrohungen gefunden:".red().bold(), threats.len());
|
||||||
for threat in threats {
|
for threat in threats {
|
||||||
println!(" {} {} - {}", "🔴".red(), threat.package, threat.action_required);
|
println!(" {} {} - {}", "🔴".red(), threat.package, threat.threat_type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-33
@@ -11,7 +11,6 @@ use crate::config::AegisConfig;
|
|||||||
use crate::ioc_fetcher::{IocEntry, IocFetcher};
|
use crate::ioc_fetcher::{IocEntry, IocFetcher};
|
||||||
use crate::trust_scorer::{CriticalFinding, TrustScorer, TrustScore};
|
use crate::trust_scorer::{CriticalFinding, TrustScorer, TrustScore};
|
||||||
|
|
||||||
/// Ergebnis eines einzelnen Paket-Scans
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ScanResult {
|
pub struct ScanResult {
|
||||||
pub package: String,
|
pub package: String,
|
||||||
@@ -39,12 +38,12 @@ pub struct ScanDetails {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum ScanStatus {
|
pub enum ScanStatus {
|
||||||
Safe, // Score >= 80, keine IOCs
|
Safe,
|
||||||
Warning, // Score 50-79 oder Warnungen
|
Warning,
|
||||||
Suspicious, // Score 30-49 oder IOC Match
|
Suspicious,
|
||||||
Dangerous, // Score < 30 oder kritischer IOC
|
Dangerous,
|
||||||
IOCDetected, // Direkter IOC-Match
|
IOCDetected,
|
||||||
Unknown, // Konnte nicht analysiert werden
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PackageScanner {
|
pub struct PackageScanner {
|
||||||
@@ -67,7 +66,6 @@ impl PackageScanner {
|
|||||||
let ioc_fetcher = IocFetcher::new(cache_dir.clone()).await?;
|
let ioc_fetcher = IocFetcher::new(cache_dir.clone()).await?;
|
||||||
let trust_scorer = TrustScorer::new()?;
|
let trust_scorer = TrustScorer::new()?;
|
||||||
|
|
||||||
// Whitelist laden
|
|
||||||
let whitelist_path = cache_dir.join("whitelist.json");
|
let whitelist_path = cache_dir.join("whitelist.json");
|
||||||
let whitelist = if whitelist_path.exists() {
|
let whitelist = if whitelist_path.exists() {
|
||||||
let content = fs::read_to_string(&whitelist_path).await?;
|
let content = fs::read_to_string(&whitelist_path).await?;
|
||||||
@@ -85,7 +83,6 @@ impl PackageScanner {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scannt ein einzelnes Paket
|
|
||||||
pub async fn scan_package(
|
pub async fn scan_package(
|
||||||
&self,
|
&self,
|
||||||
package: &str,
|
package: &str,
|
||||||
@@ -93,14 +90,11 @@ impl PackageScanner {
|
|||||||
) -> Result<ScanResult> {
|
) -> Result<ScanResult> {
|
||||||
info!("Scanne Paket: {}", package);
|
info!("Scanne Paket: {}", package);
|
||||||
|
|
||||||
// 1. IOC-Check
|
|
||||||
let iocs = self.ioc_fetcher.get_cached_iocs().await?;
|
let iocs = self.ioc_fetcher.get_cached_iocs().await?;
|
||||||
let ioc_matches = self.ioc_fetcher.check_package(package, &iocs);
|
let ioc_matches = self.ioc_fetcher.check_package(package, &iocs);
|
||||||
|
|
||||||
// 2. AUR-Info holen
|
|
||||||
let aur_info = self.fetch_aur_info(package).await?;
|
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 {
|
let pkgbuild_analysis = if let Some(ref info) = aur_info {
|
||||||
if let Some(url) = &info.url_path {
|
if let Some(url) = &info.url_path {
|
||||||
match self.fetch_pkgbuild(package, url).await {
|
match self.fetch_pkgbuild(package, url).await {
|
||||||
@@ -123,7 +117,6 @@ impl PackageScanner {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Ergebnis zusammenstellen
|
|
||||||
let mut result = if let Some((score, pkgbuild_raw)) = pkgbuild_analysis {
|
let mut result = if let Some((score, pkgbuild_raw)) = pkgbuild_analysis {
|
||||||
let mut warnings: Vec<String> = score.warnings.into_iter().collect();
|
let mut warnings: Vec<String> = score.warnings.into_iter().collect();
|
||||||
let critical_findings: Vec<String> = score
|
let critical_findings: Vec<String> = score
|
||||||
@@ -132,7 +125,6 @@ impl PackageScanner {
|
|||||||
.map(|c| format!("{:?}", c))
|
.map(|c| format!("{:?}", c))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// IOC-Warnungen hinzufügen
|
|
||||||
let ioc_strings: Vec<String> = ioc_matches
|
let ioc_strings: Vec<String> = ioc_matches
|
||||||
.iter()
|
.iter()
|
||||||
.map(|ioc| format!("{}: {} ({})", ioc.threat_type, ioc.description, ioc.confidence))
|
.map(|ioc| format!("{}: {} ({})", ioc.threat_type, ioc.description, ioc.confidence))
|
||||||
@@ -162,7 +154,6 @@ impl PackageScanner {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Installiertes Paket analysieren
|
|
||||||
let installed_info = self.get_installed_info(package).await?;
|
let installed_info = self.get_installed_info(package).await?;
|
||||||
let score = self.trust_scorer.check_installed_package(&installed_info);
|
let score = self.trust_scorer.check_installed_package(&installed_info);
|
||||||
|
|
||||||
@@ -187,7 +178,6 @@ impl PackageScanner {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scannt alle installierten AUR-Pakete
|
|
||||||
pub async fn scan_all_installed(
|
pub async fn scan_all_installed(
|
||||||
&self,
|
&self,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
@@ -219,14 +209,14 @@ impl PackageScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortiere nach Score (gefährlichste zuerst)
|
|
||||||
results.sort_by(|a, b| a.score.cmp(&b.score));
|
results.sort_by(|a, b| a.score.cmp(&b.score));
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prüft gegen aktuelle IOC-Listen
|
pub async fn check_iocs(
|
||||||
pub async fn check_iocs(&self, list_type: &str) -> Result<Vec<IocThreat>> {
|
&self, _list_type: &str
|
||||||
|
) -> Result<Vec<IocThreat>> {
|
||||||
let iocs = self.ioc_fetcher.get_cached_iocs().await?;
|
let iocs = self.ioc_fetcher.get_cached_iocs().await?;
|
||||||
let foreign_packages = self.get_foreign_packages().await?;
|
let foreign_packages = self.get_foreign_packages().await?;
|
||||||
|
|
||||||
@@ -238,9 +228,9 @@ impl PackageScanner {
|
|||||||
for ioc in matches {
|
for ioc in matches {
|
||||||
threats.push(IocThreat {
|
threats.push(IocThreat {
|
||||||
package: pkg.clone(),
|
package: pkg.clone(),
|
||||||
threat_type: format!("{:?}", ioc.threat_type),
|
threat_type: format!("{}", ioc.threat_type),
|
||||||
source: ioc.source,
|
source: ioc.source,
|
||||||
confidence: format!("{:?}", ioc.confidence),
|
confidence: format!("{}", ioc.confidence),
|
||||||
action_required: matches!(ioc.confidence, crate::ioc_fetcher::ConfidenceLevel::Critical | crate::ioc_fetcher::ConfidenceLevel::High),
|
action_required: matches!(ioc.confidence, crate::ioc_fetcher::ConfidenceLevel::Critical | crate::ioc_fetcher::ConfidenceLevel::High),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -288,9 +278,9 @@ impl PackageScanner {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hilfsmethoden ---
|
async fn fetch_aur_info(
|
||||||
|
&self, package: &str
|
||||||
async fn fetch_aur_info(&self, package: &str) -> Result<Option<AurPackageInfo>> {
|
) -> Result<Option<AurPackageInfo>> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://aur.archlinux.org/rpc/v5/info?arg[]={}",
|
"https://aur.archlinux.org/rpc/v5/info?arg[]={}",
|
||||||
package
|
package
|
||||||
@@ -310,8 +300,9 @@ impl PackageScanner {
|
|||||||
Ok(Some(rpc_response.results[0].clone()))
|
Ok(Some(rpc_response.results[0].clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_pkgbuild(&self, package: &str, _url_path: &str) -> Result<String> {
|
async fn fetch_pkgbuild(
|
||||||
// Korrekte AUR PKGBUILD URL: /cgit/aur.git/plain/PKGBUILD?h=<package>
|
&self, package: &str, _url_path: &str
|
||||||
|
) -> Result<String> {
|
||||||
let pkgbuild_url = format!(
|
let pkgbuild_url = format!(
|
||||||
"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={}",
|
"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={}",
|
||||||
package
|
package
|
||||||
@@ -325,7 +316,8 @@ impl PackageScanner {
|
|||||||
response.text().await.context("Konnte PKGBUILD nicht lesen")
|
response.text().await.context("Konnte PKGBUILD nicht lesen")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_foreign_packages(&self) -> Result<Vec<(String, String)>> {
|
async fn get_foreign_packages(&self
|
||||||
|
) -> Result<Vec<(String, String)>> {
|
||||||
let output = Command::new("pacman")
|
let output = Command::new("pacman")
|
||||||
.args(["-Qm"])
|
.args(["-Qm"])
|
||||||
.output()
|
.output()
|
||||||
@@ -349,7 +341,8 @@ impl PackageScanner {
|
|||||||
Ok(packages)
|
Ok(packages)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_installed_info(&self, package: &str) -> Result<String> {
|
async fn get_installed_info(&self, package: &str
|
||||||
|
) -> Result<String> {
|
||||||
let output = Command::new("pacman")
|
let output = Command::new("pacman")
|
||||||
.args(["-Qi", package])
|
.args(["-Qi", package])
|
||||||
.output()
|
.output()
|
||||||
@@ -383,15 +376,17 @@ impl PackageScanner {
|
|||||||
ScanStatus::Safe
|
ScanStatus::Safe
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_whitelist(&self) -> Result<()> {
|
fn save_whitelist(&self
|
||||||
|
) -> Result<()> {
|
||||||
let whitelist_path = self.cache_dir.join("whitelist.json");
|
let whitelist_path = self.cache_dir.join("whitelist.json");
|
||||||
let json = serde_json::to_string_pretty(&self.whitelist)?;
|
let json = serde_json::to_string_pretty(&self.whitelist)?;
|
||||||
// Blocking IO für HashSet - in Produktion async machen
|
|
||||||
std::fs::write(whitelist_path, json)?;
|
std::fs::write(whitelist_path, json)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn calculate_cache_size(&self) -> Result<u64> {
|
async fn calculate_cache_size(
|
||||||
|
&self
|
||||||
|
) -> Result<u64> {
|
||||||
let mut total_size = 0u64;
|
let mut total_size = 0u64;
|
||||||
|
|
||||||
let mut entries = fs::read_dir(&self.cache_dir).await?;
|
let mut entries = fs::read_dir(&self.cache_dir).await?;
|
||||||
@@ -405,7 +400,9 @@ impl PackageScanner {
|
|||||||
Ok(total_size)
|
Ok(total_size)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_last_cache_update(&self) -> Result<Option<String>> {
|
async fn get_last_cache_update(
|
||||||
|
&self
|
||||||
|
) -> Result<Option<String>> {
|
||||||
let cache_file = self.cache_dir.join("iocs.json");
|
let cache_file = self.cache_dir.join("iocs.json");
|
||||||
if !cache_file.exists() {
|
if !cache_file.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -441,7 +438,6 @@ pub struct IocThreat {
|
|||||||
pub action_required: bool,
|
pub action_required: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AUR RPC Response Types
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct AurRpcResponse {
|
struct AurRpcResponse {
|
||||||
#[serde(rename = "resultcount")]
|
#[serde(rename = "resultcount")]
|
||||||
|
|||||||
+28
-1
@@ -1 +1,28 @@
|
|||||||
Unnamed repository; edit this file 'description' to name the repository.
|
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