mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
172 lines
7.1 KiB
Python
172 lines
7.1 KiB
Python
# /// script
|
|
# requires-python = ">=3.12"
|
|
# dependencies = [
|
|
# "fastapi==0.162.0", "uvicorn==0.54.0", "python-multipart==0.0.33",
|
|
# "skillspector @ git+https://github.com/NVIDIA/skillspector.git@62c2fe8622f580ac292d4078c3b71f790194dbbd",
|
|
# ]
|
|
# ///
|
|
import asyncio
|
|
import hmac
|
|
import json
|
|
import os
|
|
import signal
|
|
import sys
|
|
import tempfile
|
|
from contextlib import suppress
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Annotated, Final
|
|
from urllib.parse import quote, urlsplit
|
|
|
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
from pydantic import AwareDatetime, TypeAdapter, ValidationError
|
|
|
|
MAX_UPLOAD: Final = 64 * 1024 * 1024
|
|
MAX_REPORT: Final = 1024 * 1024
|
|
SCAN_TIMEOUT: Final = 570
|
|
SOURCE: Final = TypeAdapter(dict[str, str])
|
|
SLOTS: Final = asyncio.Semaphore(2)
|
|
app: Final = FastAPI(title="SkillSpector adapter", docs_url=None, redoc_url=None, openapi_url=None)
|
|
|
|
|
|
def authenticate(credentials: Annotated[HTTPAuthorizationCredentials, Depends(HTTPBearer())]) -> None:
|
|
token: Final = os.environ.get("SKILL_SCANNER_API_KEY", "")
|
|
if not token or not hmac.compare_digest(credentials.credentials, token):
|
|
raise HTTPException(401, "Invalid scanner credentials")
|
|
|
|
|
|
def source_target(source: dict[str, str]) -> str:
|
|
target: Final = (
|
|
"https://github.com/" + source.get("repo", "") if source.get("source") == "github" else source.get("url", "")
|
|
)
|
|
parsed: Final = urlsplit(target)
|
|
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
|
|
raise HTTPException(400, "Remote sources require an HTTPS URL without credentials or a fragment")
|
|
ref: Final = source.get("ref")
|
|
subdir: Final = source.get("path")
|
|
if ref or subdir:
|
|
if parsed.hostname != "github.com" or len(parsed.path.strip("/").split("/")) != 2 or not ref:
|
|
raise HTTPException(400, "Upload a ZIP to scan this source's exact ref and subdirectory")
|
|
suffix: Final = "/" + quote(subdir, safe="/") if subdir else ""
|
|
return target.removesuffix(".git").rstrip("/") + "/tree/" + quote(ref, safe="/") + suffix
|
|
return target
|
|
|
|
|
|
async def worker(target: str, output: Path, use_llm: bool) -> None:
|
|
from skillspector.mcp_server import run_scan
|
|
|
|
result: Final = await run_scan(target, use_llm=use_llm, allow_local_targets=not target.startswith("https://"))
|
|
coverage: Final = result.get("analysis_completeness", {})
|
|
findings: Final = result["findings"]
|
|
complete: Final = (
|
|
result.get("execution_successful") is True
|
|
and coverage.get("is_complete") is True
|
|
and (not use_llm or (result.get("llm_available") is True and result.get("llm_used") is True))
|
|
and len(findings) <= 500
|
|
)
|
|
limitations: Final = tuple(str(value)[:2000] for value in coverage.get("limitations", ()))
|
|
report: Final = {
|
|
"scanner_version": result["version"],
|
|
"complete": complete,
|
|
"scan_mode": result["scan_mode"],
|
|
"risk_score": result["risk_score"],
|
|
"findings": [
|
|
{
|
|
"rule_id": finding["id"],
|
|
"severity": finding["severity"],
|
|
"message": finding.get("pattern") or finding.get("finding") or "Security finding",
|
|
"file": finding["location"]["file"],
|
|
"start_line": finding["location"].get("start_line"),
|
|
"explanation": finding.get("explanation"),
|
|
"remediation": finding.get("remediation"),
|
|
}
|
|
for finding in findings[:500]
|
|
],
|
|
"limitations": (
|
|
*limitations,
|
|
*(("Only the first 500 findings are displayed.",) if len(findings) > 500 else ()),
|
|
),
|
|
}
|
|
encoded: Final = json.dumps(report).encode()
|
|
if len(encoded) > MAX_REPORT:
|
|
raise ValueError("Scanner report exceeded the response limit")
|
|
output.write_bytes(encoded)
|
|
|
|
|
|
async def run_worker(target: str, output: Path, use_llm: bool, expires_at: datetime | None = None) -> dict[str, object]:
|
|
timeout: Final = (
|
|
SCAN_TIMEOUT
|
|
if expires_at is None
|
|
else min(SCAN_TIMEOUT, (expires_at - datetime.now(timezone.utc)).total_seconds())
|
|
)
|
|
if timeout <= 0:
|
|
raise HTTPException(504, "SkillSpector scan deadline expired")
|
|
process: Final = await asyncio.create_subprocess_exec(
|
|
sys.executable,
|
|
str(Path(__file__).resolve()),
|
|
"--scan",
|
|
target,
|
|
str(output),
|
|
str(use_llm).lower(),
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
env={
|
|
**os.environ,
|
|
"HOME": str(output.parent),
|
|
"TMPDIR": str(output.parent),
|
|
"GIT_CONFIG_GLOBAL": os.devnull,
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"GIT_ALLOW_PROTOCOL": "https",
|
|
},
|
|
)
|
|
try:
|
|
code: Final = await asyncio.wait_for(process.wait(), timeout)
|
|
if code != 0 or not output.is_file() or output.stat().st_size > MAX_REPORT:
|
|
raise HTTPException(502, "SkillSpector could not produce a report for this source")
|
|
return TypeAdapter(dict[str, object]).validate_json(output.read_bytes())
|
|
except TimeoutError:
|
|
raise HTTPException(504, "SkillSpector scan timed out") from None
|
|
finally:
|
|
with suppress(ProcessLookupError):
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
await process.wait()
|
|
|
|
|
|
@app.post("/scan", dependencies=[Depends(authenticate)])
|
|
async def scan(
|
|
source: Annotated[str, Form(max_length=16384)],
|
|
use_llm: Annotated[bool, Form()] = False,
|
|
expires_at: Annotated[AwareDatetime | None, Form()] = None,
|
|
file: Annotated[UploadFile | None, File()] = None,
|
|
) -> dict[str, object]:
|
|
if SLOTS.locked():
|
|
raise HTTPException(429, "Scanner is busy; try again shortly")
|
|
try:
|
|
parsed: Final = SOURCE.validate_json(source)
|
|
except ValidationError:
|
|
raise HTTPException(400, "Invalid source metadata") from None
|
|
async with SLOTS:
|
|
with tempfile.TemporaryDirectory(prefix="skill-scan-") as directory:
|
|
root: Final = Path(directory)
|
|
if file is not None:
|
|
content: Final = await file.read(MAX_UPLOAD + 1)
|
|
if len(content) > MAX_UPLOAD:
|
|
raise HTTPException(413, "Upload exceeds 64 MiB")
|
|
archive: Final = root / "skill.zip"
|
|
archive.write_bytes(content)
|
|
return await run_worker(str(archive), root / "report.json", use_llm, expires_at)
|
|
return await run_worker(source_target(parsed), root / "report.json", use_llm, expires_at)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) > 1 and sys.argv[1] == "--scan":
|
|
asyncio.run(worker(sys.argv[2], Path(sys.argv[3]), sys.argv[4] == "true"))
|
|
else:
|
|
import uvicorn
|
|
|
|
if not os.environ.get("SKILL_SCANNER_API_KEY"):
|
|
raise SystemExit("Set SKILL_SCANNER_API_KEY before starting the scanner")
|
|
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8099")))
|