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
This commit is contained in:
arch_agent
2026-08-04 09:35:35 +02:00
commit adee5dfc78
14 changed files with 1368 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.pyc
.venv/
config.yaml
*.pkg.tar.zst
*.db.tar.gz
*.files.tar.gz
build/
dist/
*.egg-info/
.env
+138
View File
@@ -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/<package>`
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/<package>` — Scan a package without building
- `GET /api/build/<package>` — Scan + build + add to repo
- `GET /api/status` — Server status + cache info
- `GET /api/report/<package>` — Get last scan report
- `GET /repo/<file>` — 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
+3
View File
@@ -0,0 +1,3 @@
"""AUR-Shield — AI-powered AUR firewall."""
__version__ = "0.1.0"
+33
View File
@@ -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()
+140
View File
@@ -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
+152
View File
@@ -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)
+78
View File
@@ -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
+91
View File
@@ -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
+179
View File
@@ -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],
}
+240
View File
@@ -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)
+35
View File
@@ -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
+116
View File
@@ -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 <package> [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 <package> ║"
echo "║ sudo pacman -S aur-shield/<package> ║"
echo "║ ║"
echo "║ Set server via env: ║"
echo "║ export AUR_SHIELD_HOST=10.90.9.102 ║"
echo "║ export AUR_SHIELD_PORT=8443 ║"
echo "║ ║"
echo "╚══════════════════════════════════════════════╝"
+131
View File
@@ -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 "╚══════════════════════════════════════════════╝"
+21
View File
@@ -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"]