commit adee5dfc78e919bd2c0cf2c52faad56e4c59ee18 Author: arch_agent Date: Tue Aug 4 09:35:35 2026 +0200 aur-shield v0.1.0: AI-powered AUR firewall - FastAPI server with scan/build/repo endpoints - LLM scanner (Ollama) with regex pre-scan - makepkg/devtools builder with chroot isolation - Scan cache with TTL + PKGBUILD hash - Client installer + safe-yay wrapper - Docs + config example diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec0a847 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.venv/ +config.yaml +*.pkg.tar.zst +*.db.tar.gz +*.files.tar.gz +build/ +dist/ +*.egg-info/ +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..de8cc75 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# AUR-Shield + +AI-powered AUR firewall. Scans PKGBUILDs with a local LLM before building, caches approved packages as a local pacman repo. + +## Problem + +The AUR is under active attack (2026 supply-chain incidents, 400+ malicious packages). `yay`/`paru` blindly execute PKGBUILDs that can contain `curl | bash`, reverse shells, typosquatting, obfuscated payloads. + +## Solution + +AUR-Shield sits between your clients and the AUR: + +``` +Client (yay/pacman) → AUR-Shield (Server) → AUR + ↓ + Fetch PKGBUILD + .SRCINFO + ↓ + LLM scans for malicious patterns + ↓ + clean → makepkg + repo-add → serve + sketchy → block + warn +``` + +## Requirements + +**Server:** +- Arch Linux +- `ollama` running (any model, even 3B works) +- `base-devel`, `devtools` for building +- Python 3.11+ with `uv` (or venv) +- ~2GB disk for cache + +**Clients:** +- Arch Linux +- `pacman` (repo mode) or `yay`/`paru` (wrapper mode) + +## Quick Start + +```bash +# On the server: +git clone https://gitea.die-heimatlosen.eu/arch_agent/aur-shield.git +cd aur-shield +./install.sh + +# Edit config if needed (model, port, etc.) +cp config.example.yaml config.yaml +nano config.yaml + +# Start the service +systemctl --user start aur-shield +# Or directly: +python -m aur_shield + +# On the client: +sudo ./install-client.sh +# Then install packages: +safe-yay firefox-nightly +# Or via pacman: +sudo pacman -S aur-shield/firefox-nightly +``` + +## Configuration + +`config.yaml`: +```yaml +ollama: + url: http://localhost:11434 + model: qwen2.5:latest # smallest model that works well + timeout: 60 + +server: + host: 0.0.0.0 + port: 8443 + repo_dir: /var/cache/aur-shield/repo + work_dir: /var/cache/aur-shield/build + +security: + block_patterns: + - "curl.*\\|.*bash" + - "wget.*\\/tmp\\/.*\\|.*sh" + - "eval.*base64" + max_pkg_size_mb: 500 + allowed_sources: + - "https://" + - "http://" + - "git://" + - "ftp://" + +cache: + ttl_hours: 168 # 7 days +``` + +## How It Works + +1. **Request:** Client asks for `aur-shield/` +2. **Fetch:** Server pulls PKGBUILD + .SRCINFO from AUR API +3. **Scan:** LLM analyzes the PKGBUILD for: + - Suspicious `source=()` URLs (npm, tor, raw IPs) + - Obfuscated bash (`eval`, `base64 -d`, hex encoding) + - Reverse shells, `nc`, `/dev/tcp` + - `post_install` hooks creating services/cronjobs + - Typosquatting package names + - Unusual `depends` for the package type +4. **Build:** If clean, `makepkg` builds the package +5. **Serve:** `repo-add` adds it to the local pacman repo +6. **Cache:** Approved packages stay cached until upstream update + +## API + +- `GET /api/scan/` — Scan a package without building +- `GET /api/build/` — Scan + build + add to repo +- `GET /api/status` — Server status + cache info +- `GET /api/report/` — Get last scan report +- `GET /repo/` — Pacman repo endpoint (for client pacman.conf) + +## Models + +Tested models (smallest to best): + +| Model | Size | VRAM | Quality | Speed | +|-------|------|------|---------|-------| +| `qwen2.5:latest` | 4.7GB | 6GB | ★★★☆☆ | fast | +| `qwen2.5-coder:3b` | 1.9GB | 3GB | ★★☆☆☆ | fastest | +| `qwen3.5:9b` | 6.6GB | 8GB | ★★★★☆ | medium | +| `Laguna-XS-2.1:Q3_K_M` | 16GB | 16GB | ★★★★★ | slow | + +**Recommended:** `qwen2.5:latest` — good balance of speed and accuracy, fits in 6GB VRAM. + +## Security Notes + +- AUR-Shield is a **defense layer**, not a guarantee. The LLM can miss things. +- Always review the scan report for high-risk packages. +- The build runs in an isolated `makepkg` environment (non-root). +- For extra isolation, use `devtools` (`extra-x86_64-build`) in a chroot. + +## License + +MIT \ No newline at end of file diff --git a/aur_shield/__init__.py b/aur_shield/__init__.py new file mode 100644 index 0000000..f991e3b --- /dev/null +++ b/aur_shield/__init__.py @@ -0,0 +1,3 @@ +"""AUR-Shield — AI-powered AUR firewall.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/aur_shield/__main__.py b/aur_shield/__main__.py new file mode 100644 index 0000000..4194f8f --- /dev/null +++ b/aur_shield/__main__.py @@ -0,0 +1,33 @@ +"""Main entry point for AUR-Shield.""" +from __future__ import annotations + +import sys + +import uvicorn + +from .config import Config +from .server import init_server + + +def main() -> None: + config_path = sys.argv[1] if len(sys.argv) > 1 else "config.yaml" + config = Config.load(config_path) + + init_server(config) + + print(f"AUR-Shield v0.1.0") + print(f" Model: {config.ollama.model}") + print(f" Ollama: {config.ollama.url}") + print(f" Repo: {config.server.repo_dir}") + print(f" Listening on {config.server.host}:{config.server.port}") + + uvicorn.run( + "aur_shield.server:app", + host=config.server.host, + port=config.server.port, + reload=False, + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/aur_shield/aur_client.py b/aur_shield/aur_client.py new file mode 100644 index 0000000..4fe4c4b --- /dev/null +++ b/aur_shield/aur_client.py @@ -0,0 +1,140 @@ +"""AUR API client — fetches PKGBUILDs and metadata from the AUR.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +import httpx + +AUR_RPC = "https://aur.archlinux.org/rpc/v5" +AUR_CGIT = "https://aur.archlinux.org/cgit/aur.git/plain" + + +@dataclass +class AURPackage: + name: str + version: str + description: str + url: str + maintainer: str | None + num_votes: int + popularity: float + last_modified: int + pkgbase: str + + +@dataclass +class AURSource: + pkgbuild: str + srcinfo: str + package: AURPackage + + +async def aur_info(name: str) -> AURPackage | None: + """Fetch package info from AUR RPC API.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(AUR_RPC, params={ + "type": "info", + "v": "5", + "arg[]": name, + }) + resp.raise_for_status() + data: dict[str, Any] = resp.json() + + results = data.get("results", []) + if not results: + return None + + r = results[0] + return AURPackage( + name=r.get("Name", name), + version=r.get("Version", ""), + description=r.get("Description", ""), + url=r.get("URL", ""), + maintainer=r.get("Maintainer"), + num_votes=r.get("NumVotes", 0), + popularity=r.get("Popularity", 0.0), + last_modified=r.get("LastModified", 0), + pkgbase=r.get("PackageBaseID", ""), + ) + + +async def aur_sources(name: str) -> AURSource | None: + """Fetch PKGBUILD and .SRCINFO for a package.""" + pkg = await aur_info(name) + if pkg is None: + return None + + pkgbase = r.get("PackageBase", name) if (r := await _raw_info(name)) else name + + async with httpx.AsyncClient(timeout=30) as client: + # Fetch PKGBUILD + pkgbuild_resp = await client.get( + AUR_CGIT + f"/PKGBUILD", + params={"h": pkgbase}, + ) + pkgbuild_resp.raise_for_status() + pkgbuild = pkgbuild_resp.text + + # Fetch .SRCINFO + srcinfo_resp = await client.get( + AUR_CGIT + f"/.SRCINFO", + params={"h": pkgbase}, + ) + srcinfo = srcinfo_resp.text if srcinfo_resp.status_code == 200 else "" + + return AURSource( + pkgbuild=pkgbuild, + srcinfo=srcinfo, + package=pkg, + ) + + +async def _raw_info(name: str) -> dict[str, Any] | None: + """Get raw RPC result for a package.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(AUR_RPC, params={ + "type": "info", + "v": "5", + "arg[]": name, + }) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", []) + return results[0] if results else None + + +def extract_sources(pkgbuild: str) -> list[str]: + """Extract source URLs from a PKGBUILD.""" + sources = [] + in_array = False + for line in pkgbuild.splitlines(): + stripped = line.strip() + if stripped.startswith("source="): + in_array = True + # single-line: source=(url) + m = re.findall(r'https?://[^\s)\'"]+|git://[^\s)\'"]+|ftp://[^\s)\'"]+', + stripped) + sources.extend(m) + if ")" in stripped and not stripped.endswith("("): + in_array = False + elif in_array: + m = re.findall(r'https?://[^\s)\'"]+|git://[^\s)\'"]+|ftp://[^\s)\'"]+', + stripped) + sources.extend(m) + if ")" in stripped: + in_array = False + return sources + + +def extract_install_hooks(pkgbuild: str) -> list[str]: + """Extract post_install/pre_install hooks from PKGBUILD.""" + hooks = [] + funcs = re.findall( + r'(?:post_install|pre_install|post_upgrade|pre_upgrade|post_remove|pre_remove)\s*\(\)\s*\{[^}]*\}', + pkgbuild, + re.DOTALL, + ) + return funcs \ No newline at end of file diff --git a/aur_shield/builder.py b/aur_shield/builder.py new file mode 100644 index 0000000..cd420d2 --- /dev/null +++ b/aur_shield/builder.py @@ -0,0 +1,152 @@ +"""Package builder — runs makepkg in a clean environment.""" +from __future__ import annotations + +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from .config import ServerConfig, BuildConfig + + +@dataclass +class BuildResult: + success: bool + pkg_files: list[str] # paths to .pkg.tar.zst + log: str + error: str = "" + + +def build_package( + pkgbuild: str, + package_name: str, + server_cfg: ServerConfig, + build_cfg: BuildConfig, +) -> BuildResult: + """Build a package from a PKGBUILD string.""" + + work_dir = Path(server_cfg.work_dir) / package_name + work_dir.mkdir(parents=True, exist_ok=True) + + # Write PKGBUILD + pkgbuild_path = work_dir / "PKGBUILD" + pkgbuild_path.write_text(pkgbuild) + + # Clean previous build artifacts + for f in work_dir.glob("*.pkg.tar.*"): + f.unlink() + + log_lines: list[str] = [] + + try: + if build_cfg.use_devtools and shutil.which("extra-x86_64-build"): + # Use devtools chroot for isolation + cmd = [ + "extra-x86_64-build", + "--", "-cC", + ] + cwd = work_dir + else: + # Fallback: makepkg directly + cmd = [ + "makepkg", "-sf", "--noconfirm", "--noprogressbar", + ] + cwd = work_dir + + log_lines.append(f"Building {package_name} with: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + timeout=build_cfg.timeout, + env={**os.environ, "MAKEFLAGS": "-j$(nproc)"}, + ) + + log_lines.append(result.stdout) + if result.stderr: + log_lines.append("--- stderr ---") + log_lines.append(result.stderr) + + if result.returncode != 0: + return BuildResult( + success=False, + pkg_files=[], + log="\n".join(log_lines), + error=f"Build failed (exit {result.returncode})", + ) + + # Find built packages + pkg_files = [ + str(f) for f in work_dir.glob("*.pkg.tar.zst") + ] + + if not pkg_files: + return BuildResult( + success=False, + pkg_files=[], + log="\n".join(log_lines), + error="Build succeeded but no .pkg.tar.zst found", + ) + + log_lines.append(f"Built: {pkg_files}") + return BuildResult( + success=True, + pkg_files=pkg_files, + log="\n".join(log_lines), + ) + + except subprocess.TimeoutExpired: + return BuildResult( + success=False, + pkg_files=[], + log="\n".join(log_lines), + error=f"Build timed out after {build_cfg.timeout}s", + ) + except Exception as e: + return BuildResult( + success=False, + pkg_files=[], + log="\n".join(log_lines), + error=str(e), + ) + + +def add_to_repo( + pkg_files: list[str], + repo_dir: str, +) -> str: + """Add built packages to the local pacman repo using repo-add.""" + + repo_path = Path(repo_dir) + repo_path.mkdir(parents=True, exist_ok=True) + + db_name = "aur-shield.db" + db_file = repo_path / f"{db_name}.tar.gz" + + # Copy packages to repo dir + copied = [] + for pkg in pkg_files: + src = Path(pkg) + dst = repo_path / src.name + shutil.copy2(src, dst) + copied.append(str(dst)) + + if not copied: + return str(db_file) + + # Run repo-add + cmd = ["repo-add", str(db_file)] + copied + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + raise RuntimeError(f"repo-add failed: {result.stderr}") + + return str(db_file) \ No newline at end of file diff --git a/aur_shield/cache.py b/aur_shield/cache.py new file mode 100644 index 0000000..49e865e --- /dev/null +++ b/aur_shield/cache.py @@ -0,0 +1,78 @@ +"""Scan cache — stores scan results to avoid re-scanning unchanged packages.""" +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, asdict +from pathlib import Path + +from .scanner import ScanResult, ScanVerdict + + +@dataclass +class CacheEntry: + package: str + verdict: str + confidence: float + findings: list[str] + reasoning: str + timestamp: float + pkgbuild_hash: str + + +class ScanCache: + """JSON-file based cache for scan results.""" + + def __init__(self, cache_dir: str, ttl_hours: int = 168) -> None: + self.cache_dir = Path(cache_dir) + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.ttl_seconds = ttl_hours * 3600 + + def _cache_path(self, package: str) -> Path: + return self.cache_dir / f"{package}.json" + + def get(self, package: str, pkgbuild_hash: str) -> CacheEntry | None: + """Get cached scan if it exists and is fresh.""" + path = self._cache_path(package) + if not path.exists(): + return None + + try: + entry = CacheEntry(**json.loads(path.read_text())) + except Exception: + return None + + # Check TTL + if time.time() - entry.timestamp > self.ttl_seconds: + return None + + # Check if PKGBUILD changed + if entry.pkgbuild_hash != pkgbuild_hash: + return None + + return entry + + def put(self, result: ScanResult, pkgbuild_hash: str) -> None: + """Store a scan result.""" + entry = CacheEntry( + package=result.package, + verdict=result.verdict.value, + confidence=result.confidence, + findings=result.findings, + reasoning=result.reasoning, + timestamp=time.time(), + pkgbuild_hash=pkgbuild_hash, + ) + path = self._cache_path(result.package) + path.write_text(json.dumps(asdict(entry), indent=2)) + + def list_all(self) -> list[dict]: + """List all cached entries.""" + entries = [] + for path in self.cache_dir.glob("*.json"): + try: + entry = CacheEntry(**json.loads(path.read_text())) + entries.append(asdict(entry)) + except Exception: + continue + return entries \ No newline at end of file diff --git a/aur_shield/config.py b/aur_shield/config.py new file mode 100644 index 0000000..e008874 --- /dev/null +++ b/aur_shield/config.py @@ -0,0 +1,91 @@ +"""Configuration loader for AUR-Shield.""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + + +@dataclass +class OllamaConfig: + url: str = "http://localhost:11434" + model: str = "qwen2.5:latest" + timeout: int = 120 + + +@dataclass +class ServerConfig: + host: str = "0.0.0.0" + port: int = 8443 + repo_dir: str = "/var/cache/aur-shield/repo" + work_dir: str = "/var/cache/aur-shield/build" + build_user: str = "nobody" + + +@dataclass +class SecurityConfig: + block_patterns: list[str] = field(default_factory=lambda: [ + r"curl.*\|.*bash", + r"wget.*/tmp/.*\|.*sh", + r"eval.*base64", + r"nc\s+-.*\d+", + r"/dev/tcp/", + r"systemctl.*enable.*--now", + ]) + max_pkg_size_mb: int = 500 + allowed_source_schemes: list[str] = field(default_factory=lambda: [ + "https", "http", "git", "ftp", "file", + ]) + + +@dataclass +class CacheConfig: + ttl_hours: int = 168 + max_cache_gb: int = 10 + + +@dataclass +class BuildConfig: + use_devtools: bool = True + timeout: int = 600 + + +@dataclass +class Config: + ollama: OllamaConfig = field(default_factory=OllamaConfig) + server: ServerConfig = field(default_factory=ServerConfig) + security: SecurityConfig = field(default_factory=SecurityConfig) + cache: CacheConfig = field(default_factory=CacheConfig) + build: BuildConfig = field(default_factory=BuildConfig) + + @classmethod + def load(cls, path: str | Path | None = None) -> "Config": + """Load config from YAML file, falling back to defaults.""" + if path is None: + path = Path(os.environ.get( + "AUR_SHIELD_CONFIG", + "config.yaml", + )) + + path = Path(path) + if not path.exists(): + return cls() + + with open(path) as f: + raw: dict[str, Any] = yaml.safe_load(f) or {} + + cfg = cls() + if "ollama" in raw: + cfg.ollama = OllamaConfig(**raw["ollama"]) + if "server" in raw: + cfg.server = ServerConfig(**raw["server"]) + if "security" in raw: + cfg.security = SecurityConfig(**raw["security"]) + if "cache" in raw: + cfg.cache = CacheConfig(**raw["cache"]) + if "build" in raw: + cfg.build = BuildConfig(**raw["build"]) + return cfg \ No newline at end of file diff --git a/aur_shield/scanner.py b/aur_shield/scanner.py new file mode 100644 index 0000000..ab35b27 --- /dev/null +++ b/aur_shield/scanner.py @@ -0,0 +1,179 @@ +"""LLM scanner — sends PKGBUILD to Ollama for security analysis.""" +from __future__ import annotations + +import json +import re +import subprocess +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +import httpx + +from .config import OllamaConfig, SecurityConfig +from .aur_client import extract_sources, extract_install_hooks + + +class ScanVerdict(str, Enum): + CLEAN = "clean" + SUSPICIOUS = "suspicious" + MALICIOUS = "malicious" + ERROR = "error" + + +@dataclass +class ScanResult: + package: str + verdict: ScanVerdict + confidence: float # 0.0 - 1.0 + findings: list[str] = field(default_factory=list) + reasoning: str = "" + sources_checked: list[str] = field(default_factory=list) + raw_response: str = "" + + +SYSTEM_PROMPT = """\ +You are a security scanner for Arch Linux AUR PKGBUILD files. +Your job is to detect malicious or suspicious code in PKGBUILDs. + +Check for these threats: +1. DOWNLOAD-AND-EXEC: curl|bash, wget|sh, downloading and executing scripts +2. OBFUSCATION: eval $(base64 -d), hex-encoded commands, printf-encoded payloads +3. REVERSE SHELLS: nc -, /dev/tcp, bash -i >&, socat +4. SUSPICIOUS SOURCES: npm, tor, raw IP addresses, non-HTTPS with no fallback +5. PERSISTENCE: post_install creating systemd services, cronjobs, autostart entries +6. TYPOSQUATTING: package names mimicking popular packages with slight misspellings +7. DATA EXFIL: curl/wget sending data to external servers with system info +8. UNUSUAL DEPENDS: packages requesting network tools unrelated to their purpose + +Respond in JSON ONLY: +{ + "verdict": "clean" | "suspicious" | "malicious", + "confidence": 0.0-1.0, + "findings": ["list of specific issues found"], + "reasoning": "brief explanation" +} + +Be conservative: if unsure, mark as suspicious. +A clean PKGBUILD that just downloads a tarball from the official upstream +URL and runs make/cmake is CLEAN. +""" + + +def _pre_scan(pkgbuild: str, sec_cfg: SecurityConfig) -> list[str]: + """Fast regex pre-scan before LLM. Returns list of findings.""" + findings = [] + for pattern in sec_cfg.block_patterns: + if re.search(pattern, pkgbuild, re.IGNORECASE): + findings.append(f"Pattern match: {pattern}") + return findings + + +async def scan_pkgbuild( + package_name: str, + pkgbuild: str, + srcinfo: str, + ollama_cfg: OllamaConfig, + sec_cfg: SecurityConfig, +) -> ScanResult: + """Scan a PKGBUILD with regex pre-check + LLM analysis.""" + + # 1. Fast regex pre-scan + regex_findings = _pre_scan(pkgbuild, sec_cfg) + + # 2. Extract metadata for context + sources = extract_sources(pkgbuild) + hooks = extract_install_hooks(pkgbuild) + + # 3. Build the prompt + user_msg = ( + f"Package: {package_name}\n\n" + f"=== PKGBUILD ===\n{pkgbuild}\n\n" + ) + if srcinfo: + user_msg += f"=== .SRCINFO (excerpt) ===\n{srcinfo[:2000]}\n\n" + if hooks: + user_msg += f"=== INSTALL HOOKS ===\n{chr(10).join(hooks)}\n\n" + if sources: + user_msg += f"=== SOURCE URLS ===\n{chr(10).join(sources)}\n\n" + + # 4. Call Ollama + payload = { + "model": ollama_cfg.model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + "stream": False, + "format": "json", + "options": { + "temperature": 0.1, + "num_ctx": 8192, + }, + } + + try: + async with httpx.AsyncClient(timeout=ollama_cfg.timeout) as client: + resp = await client.post( + f"{ollama_cfg.url}/api/chat", + json=payload, + ) + resp.raise_for_status() + data = resp.json() + + raw = data.get("message", {}).get("content", "") + result = _parse_llm_response(raw) + + # Combine with regex findings + all_findings = result.get("findings", []) + regex_findings + verdict = result.get("verdict", "suspicious") + confidence = result.get("confidence", 0.5) + + # If regex found blocked patterns, escalate + if regex_findings and verdict == "clean": + verdict = "suspicious" + confidence = max(confidence, 0.7) + + return ScanResult( + package=package_name, + verdict=ScanVerdict(verdict), + confidence=confidence, + findings=all_findings, + reasoning=result.get("reasoning", ""), + sources_checked=sources, + raw_response=raw, + ) + + except Exception as e: + return ScanResult( + package=package_name, + verdict=ScanVerdict.ERROR, + confidence=0.0, + findings=[f"Scanner error: {e}"], + sources_checked=sources, + ) + + +def _parse_llm_response(raw: str) -> dict[str, Any]: + """Parse the LLM JSON response, handling malformed output.""" + # Try direct JSON parse + try: + return json.loads(raw) + except json.JSONDecodeError: + pass + + # Try to extract JSON from text + match = re.search(r'\{[^{}]*"verdict"[^{}]*\}', raw, re.DOTALL) + if match: + try: + return json.loads(match.group()) + except json.JSONDecodeError: + pass + + # Fallback + return { + "verdict": "suspicious", + "confidence": 0.3, + "findings": ["Could not parse LLM response"], + "reasoning": raw[:500], + } \ No newline at end of file diff --git a/aur_shield/server.py b/aur_shield/server.py new file mode 100644 index 0000000..3e6ed3a --- /dev/null +++ b/aur_shield/server.py @@ -0,0 +1,240 @@ +"""FastAPI server for AUR-Shield.""" +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from .config import Config +from .aur_client import aur_sources, aur_info +from .scanner import scan_pkgbuild, ScanVerdict +from .builder import build_package, add_to_repo, BuildResult +from .cache import ScanCache + +app = FastAPI( + title="AUR-Shield", + description="AI-powered AUR firewall", + version="0.1.0", +) + +# Global config — set by main() +_config: Config | None = None +_scan_cache: ScanCache | None = None + + +def get_config() -> Config: + if _config is None: + raise RuntimeError("Server not initialized") + return _config + + +def get_cache() -> ScanCache: + if _scan_cache is None: + raise RuntimeError("Cache not initialized") + return _scan_cache + + +def _hash_pkgbuild(pkgbuild: str) -> str: + return hashlib.sha256(pkgbuild.encode()).hexdigest()[:16] + + +@app.get("/api/status") +async def status() -> dict[str, Any]: + """Server status and cache info.""" + cfg = get_config() + cache = get_cache() + return { + "status": "running", + "model": cfg.ollama.model, + "ollama_url": cfg.ollama.url, + "repo_dir": cfg.server.repo_dir, + "cached_scans": len(cache.list_all()), + } + + +@app.get("/api/scan/{package}") +async def scan_package(package: str) -> dict[str, Any]: + """Scan a package without building.""" + cfg = get_config() + cache = get_cache() + + # Fetch from AUR + source = await aur_sources(package) + if source is None: + raise HTTPException(404, f"Package '{package}' not found in AUR") + + pkgbuild_hash = _hash_pkgbuild(source.pkgbuild) + + # Check cache + cached = cache.get(package, pkgbuild_hash) + if cached: + return { + "package": package, + "cached": True, + "verdict": cached.verdict, + "confidence": cached.confidence, + "findings": cached.findings, + "reasoning": cached.reasoning, + "version": source.package.version, + } + + # Scan + result = await scan_pkgbuild( + package, source.pkgbuild, source.srcinfo, + cfg.ollama, cfg.security, + ) + + # Cache the result + cache.put(result, pkgbuild_hash) + + return { + "package": package, + "cached": False, + "verdict": result.verdict.value, + "confidence": result.confidence, + "findings": result.findings, + "reasoning": result.reasoning, + "sources": result.sources_checked, + "version": source.package.version, + } + + +@app.get("/api/build/{package}") +async def build_endpoint(package: str) -> dict[str, Any]: + """Scan + build + add to repo.""" + cfg = get_config() + cache = get_cache() + + # 1. Fetch from AUR + source = await aur_sources(package) + if source is None: + raise HTTPException(404, f"Package '{package}' not found in AUR") + + pkgbuild_hash = _hash_pkgbuild(source.pkgbuild) + + # 2. Check cache for scan + cached = cache.get(package, pkgbuild_hash) + if cached: + if cached.verdict == ScanVerdict.MALICIOUS.value: + raise HTTPException(403, f"Package '{package}' is cached as MALICIOUS") + verdict = cached.verdict + findings = cached.findings + confidence = cached.confidence + else: + # 3. Scan + result = await scan_pkgbuild( + package, source.pkgbuild, source.srcinfo, + cfg.ollama, cfg.security, + ) + cache.put(result, pkgbuild_hash) + verdict = result.verdict.value + findings = result.findings + confidence = result.confidence + + # 4. Block if malicious + if verdict == ScanVerdict.MALICIOUS.value: + raise HTTPException( + 403, + f"Package '{package}' was flagged as MALICIOUS. Findings: {findings}", + ) + + if verdict == ScanVerdict.SUSPICIOUS.value: + # Allow suspicious but warn + pass + + # 5. Build + build_result = build_package( + source.pkgbuild, package, + cfg.server, cfg.build, + ) + + if not build_result.success: + return { + "package": package, + "verdict": verdict, + "confidence": confidence, + "build_success": False, + "build_error": build_result.error, + "build_log": build_result.log[-2000:], + } + + # 6. Add to repo + try: + db_path = add_to_repo(build_result.pkg_files, cfg.server.repo_dir) + except Exception as e: + return { + "package": package, + "verdict": verdict, + "confidence": confidence, + "build_success": True, + "repo_success": False, + "repo_error": str(e), + } + + return { + "package": package, + "verdict": verdict, + "confidence": confidence, + "findings": findings, + "build_success": True, + "repo_success": True, + "pkg_files": [Path(f).name for f in build_result.pkg_files], + "repo_db": Path(db_path).name, + } + + +@app.get("/api/report/{package}") +async def get_report(package: str) -> dict[str, Any]: + """Get the last scan report for a package.""" + cache = get_cache() + entries = cache.list_all() + for entry in entries: + if entry["package"] == package: + return entry + raise HTTPException(404, f"No scan report for '{package}'") + + +@app.get("/api/cache") +async def list_cache() -> dict[str, Any]: + """List all cached scan results.""" + cache = get_cache() + return {"entries": cache.list_all()} + + +@app.delete("/api/cache/{package}") +async def clear_cache_entry(package: str) -> dict[str, str]: + """Clear a cached scan result.""" + cache = get_cache() + path = cache._cache_path(package) + if path.exists(): + path.unlink() + return {"status": "deleted", "package": package} + raise HTTPException(404, f"No cache for '{package}'") + + +# Serve the pacman repo files +@app.get("/repo/{filename}") +async def serve_repo_file(filename: str): + """Serve repo files for pacman.""" + cfg = get_config() + repo_path = Path(cfg.server.repo_dir) / filename + if not repo_path.exists(): + raise HTTPException(404, f"File '{filename}' not in repo") + return FileResponse(repo_path) + + +def init_server(config: Config) -> None: + """Initialize the server with config.""" + global _config, _scan_cache + _config = config + _scan_cache = ScanCache( + cache_dir=str(Path(config.server.work_dir) / "cache"), + ttl_hours=config.cache.ttl_hours, + ) + # Ensure dirs exist + Path(config.server.repo_dir).mkdir(parents=True, exist_ok=True) + Path(config.server.work_dir).mkdir(parents=True, exist_ok=True) \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..698a0ed --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,35 @@ +ollama: + url: http://localhost:11434 + model: qwen2.5:latest + timeout: 120 + +server: + host: 0.0.0.0 + port: 8443 + repo_dir: /var/cache/aur-shield/repo + work_dir: /var/cache/aur-shield/build + build_user: nobody # never build as root + +security: + block_patterns: + - "curl.*\\|.*bash" + - "wget.*/tmp/.*\\|.*sh" + - "eval.*base64" + - "nc\\s+-.*\\d+" + - "/dev/tcp/" + - "systemctl.*enable.*--now" + max_pkg_size_mb: 500 + allowed_source_schemes: + - "https" + - "http" + - "git" + - "ftp" + - "file" + +cache: + ttl_hours: 168 # 7 days + max_cache_gb: 10 + +build: + use_devtools: true # use extra-x86_64-build chroot if available + timeout: 600 # 10 min per build \ No newline at end of file diff --git a/install-client.sh b/install-client.sh new file mode 100644 index 0000000..6ea8b82 --- /dev/null +++ b/install-client.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# AUR-Shield Client Installer +# Configures pacman to use the AUR-Shield repo +set -euo pipefail + +SHIELD_HOST="${AUR_SHIELD_HOST:-localhost}" +SHIELD_PORT="${AUR_SHIELD_PORT:-8443}" + +echo "╔══════════════════════════════════════════════╗" +echo "║ AUR-Shield Client Installer ║" +echo "╚══════════════════════════════════════════════╝" +echo "" + +if ! command -v pacman &>/dev/null; then + echo "✗ This script requires Arch Linux (pacman)." + exit 1 +fi + +echo "Server: http://$SHIELD_HOST:$SHIELD_PORT" +echo "" + +# Add pacman repo +PACMAN_CONF="/etc/pacman.conf" +REPO_ENTRY="[aur-shield] +Server = http://$SHIELD_HOST:$SHIELD_PORT/repo +SigLevel = Never" + +if grep -q "^\[aur-shield\]" "$PACMAN_CONF"; then + echo "⚠ [aur-shield] already exists in $PACMAN_CONF — skipping" +else + echo "Adding [aur-shield] to $PACMAN_CONF..." + sudo tee -a "$PACMAN_CONF" > /dev/null << EOF + +# AUR-Shield — AI-scanned AUR packages +$REPO_ENTRY +EOF + echo "✓ Added repo entry" +fi + +# Install safe-yay wrapper +WRAPPER_PATH="/usr/local/bin/safe-yay" +echo "Installing safe-yay wrapper to $WRAPPER_PATH..." + +sudo tee "$WRAPPER_PATH" > /dev/null << 'WRAPPER' +#!/usr/bin/env bash +# safe-yay — wrapper that routes AUR packages through AUR-Shield +set -euo pipefail + +SHIELD_HOST="${AUR_SHIELD_HOST:-localhost}" +SHIELD_PORT="${AUR_SHIELD_PORT:-8443}" + +if [ $# -eq 0 ]; then + echo "Usage: safe-yay [package2 ...]" + echo "Scans and builds AUR packages through AUR-Shield" + exit 0 +fi + +for pkg in "$@"; do + echo "→ Processing $pkg..." + + # Call AUR-Shield to scan + build + RESPONSE=$(curl -sf "http://$SHIELD_HOST:$SHIELD_PORT/api/build/$pkg" 2>&1) || { + echo "✗ Failed to process $pkg" + echo " $RESPONSE" + continue + } + + # Extract verdict + VERDICT=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('verdict','error'))" 2>/dev/null) + + case "$VERDICT" in + clean) + echo " ✓ Clean — package built and added to repo" + ;; + suspicious) + echo " ⚠ Suspicious — package built but review recommended" + echo " Report: http://$SHIELD_HOST:$SHIELD_PORT/api/report/$pkg" + ;; + malicious) + echo " ✗ MALICIOUS — package blocked!" + echo " Report: http://$SHIELD_HOST:$SHIELD_PORT/api/report/$pkg" + continue + ;; + *) + echo " ? Error: $RESPONSE" + continue + ;; + esac + + # Now install via pacman + echo " Installing via pacman..." + sudo pacman -Sy "aur-shield/$pkg" || { + echo " ⚠ Package not in repo yet — may need to wait for build" + } +done + +echo "Done." +WRAPPER + +sudo chmod +x "$WRAPPER_PATH" +echo "✓ Installed safe-yay" + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ Client setup complete! ║" +echo "╠══════════════════════════════════════════════╣" +echo "║ ║" +echo "║ Usage: ║" +echo "║ safe-yay ║" +echo "║ sudo pacman -S aur-shield/ ║" +echo "║ ║" +echo "║ Set server via env: ║" +echo "║ export AUR_SHIELD_HOST=10.90.9.102 ║" +echo "║ export AUR_SHIELD_PORT=8443 ║" +echo "║ ║" +echo "╚══════════════════════════════════════════════╝" \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c8f0856 --- /dev/null +++ b/install.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# AUR-Shield Server Installer +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "╔══════════════════════════════════════════════╗" +echo "║ AUR-Shield Server Installer ║" +echo "╚══════════════════════════════════════════════╝" +echo "" + +# Check for Arch Linux +if ! command -v pacman &>/dev/null; then + echo "✗ This script requires Arch Linux (pacman)." + exit 1 +fi + +# Check for dependencies +echo "Checking dependencies..." +MISSING=() + +if ! command -v ollama &>/dev/null; then + MISSING+=("ollama") +fi +if ! command -v makepkg &>/dev/null; then + MISSING+=("base-devel (makepkg)") +fi +if ! command -v python3 &>/dev/null; then + MISSING+=("python3") +fi +if ! command -v git &>/dev/null; then + MISSING+=("git") +fi + +if [ ${#MISSING[@]} -gt 0 ]; then + echo "✗ Missing dependencies:" + for dep in "${MISSING[@]}"; do + echo " - $dep" + done + echo "" + echo "Install with: sudo pacman -S ${MISSING[*]}" + exit 1 +fi + +# Check for devtools (optional but recommended) +DEVTOOLS="" +if ! command -v extra-x86_64-build &>/dev/null; then + echo "⚠ devtools not found — builds will use makepkg directly (less isolation)" + echo " Install with: sudo pacman -S devtools" + echo "" +else + DEVTOOLS="✓ devtools found — builds will use chroot isolation" +fi + +# Check for ollama model +echo "Checking Ollama models..." +if ! ollama list 2>/dev/null | grep -q "qwen2.5"; then + echo "⚠ No qwen2.5 model found in Ollama." + echo " Recommended: ollama pull qwen2.5:latest (4.7GB, needs ~6GB VRAM)" + echo " Fallback: ollama pull qwen2.5-coder:3b (1.9GB, needs ~3GB VRAM)" + echo "" + read -rp "Pull qwen2.5:latest now? [Y/n] " PULL + if [[ "${PULL,,}" != "n" ]]; then + ollama pull qwen2.5:latest + fi +fi + +# Install Python deps +echo "" +echo "Installing Python dependencies..." +if command -v uv &>/dev/null; then + uv venv "$SCRIPT_DIR/.venv" + uv pip install --python "$SCRIPT_DIR/.venv/bin/python" -e "$SCRIPT_DIR[dev]" + echo "✓ Installed with uv" +else + python3 -m venv "$SCRIPT_DIR/.venv" + "$SCRIPT_DIR/.venv/bin/pip" install -e "$SCRIPT_DIR[dev]" + echo "✓ Installed with pip" +fi + +# Create config if not exists +if [ ! -f "$SCRIPT_DIR/config.yaml" ]; then + cp "$SCRIPT_DIR/config.example.yaml" "$SCRIPT_DIR/config.yaml" + echo "✓ Created config.yaml from example" +fi + +# Create cache dirs +sudo mkdir -p /var/cache/aur-shield/repo +sudo mkdir -p /var/cache/aur-shield/build +sudo mkdir -p /var/cache/aur-shield/cache +sudo chown -R "$USER" /var/cache/aur-shield/ +echo "✓ Created cache directories" + +# Create systemd service +SERVICE_FILE="/etc/systemd/system/aur-shield.service" +echo "Creating systemd service..." +sudo tee "$SERVICE_FILE" > /dev/null << EOF +[Unit] +Description=AUR-Shield — AI-powered AUR firewall +After=network.target ollama.service + +[Service] +Type=simple +ExecStart=$SCRIPT_DIR/.venv/bin/python -m aur_shield $SCRIPT_DIR/config.yaml +WorkingDirectory=$SCRIPT_DIR +Environment="HOME=$SCRIPT_DIR" +User=$USER +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +EOF + +echo "✓ Created systemd service" + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ Installation complete! ║" +echo "╠══════════════════════════════════════════════╣" +echo "║ ║" +echo "║ Start: systemctl start aur-shield ║" +echo "║ Enable: systemctl enable aur-shield ║" +echo "║ Status: systemctl status aur-shield ║" +echo "║ Logs: journalctl -u aur-shield -f ║" +echo "║ ║" +echo "║ API: http://localhost:8443/api/status ║" +echo "║ ║" +echo "║ Config: $SCRIPT_DIR/config.yaml ║" +echo "║ ║" +echo "╚══════════════════════════════════════════════╝" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..700cf6a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "aur-shield" +version = "0.1.0" +description = "AI-powered AUR firewall — scans PKGBUILDs before building" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115", + "uvicorn>=0.30", + "httpx>=0.27", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["aur_shield"] \ No newline at end of file