Files
aur-shield/aur_shield/scanner.py
T
arch_agent adee5dfc78 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
2026-08-04 09:35:35 +02:00

179 lines
5.3 KiB
Python

"""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],
}