vlnr discovers high-risk PyPI packages, scans them for vulnerabilities with
deterministic AST taint analysis and external SAST tools, classifies findings
with a rule-based noise filter, and generates proof-of-concept exploits in
Docker containers via a bounded retry loop. The multi-agent architecture adds
optional LLM-based strategic planning, maker-checker verification, parallel
package scanning, memory management, structured observability logging, and
human-in-the-loop exit points — all gated behind CLI flags.
For authorized security research only. PoC execution runs in transient Docker containers. Never run against systems you do not own.
- Python 3.14+
uvpackage manager- Docker (for sandbox PoC validation)
- An OpenAI-compatible LLM API (only needed for LLM-assisted triage, discovery scoring, and PoC generation/refinement)
git clone https://github.com/nandrzej/vlnr
cd vlnr
uv sync# .env
CUSTOM_OPENAI_API_KEY=<your-key>
GITHUB_TOKEN=<optional — higher-rate GitHub API access>Model routing is configured in llm_config.yaml:
default:
base_url: "https://api.vlnr.ai/v1"
model: "qwen-3.5-4b"
temperature: 0.0
reasoning_effort: "low"
tier_1: # PoC generation, deep reasoning
model: "openai/qwen-3.5-397b-instruct"
temperature: 0.1
reasoning_effort: "high"
tier_2: # Triage, refinement
model: "openai/gemma-4-31b-it"
temperature: 0.0
tier_3: # Metadata classification, intent scoring
model: "openai/qwen-3.5-4b-instruct"
temperature: 0.0# Full pipeline: discover → scan → agent
vlnr run --out-dir results/ --osv-dump osv.zip --packages requests
# Scan only (static analysis, no agent)
vlnr run --out-dir results/ --osv-dump osv.zip --packages requests --skip-agent
# With LLM triage
vlnr run --out-dir results/ --osv-dump osv.zip --packages requests --llm-triage
# Parallel scanning (4 concurrent packages)
vlnr run --out-dir results/ --osv-dump osv.zip --packages requests --parallel 4
# Full multi-agent mode: planner + verifier + parallel + observability
vlnr run --out-dir results/ --osv-dump osv.zip --packages requests \
--llm-triage --planner --verify --parallel 4 --obs-log trace.jsonl
# Apply operator review decisions
vlnr run --out-dir results/ --osv-dump osv.zip --packages requests \
--resolve-reviews my_decisions.json
# Run stages individually
vlnr discover --packages requests,flask --out-dir results/ --osv-dump osv.zip
vlnr scan results/candidates.json --out-dir findings/ --llm-triage
vlnr agent --package requests --state-path results/agent_session.json| Old | New |
|---|---|
poc-find-candidates main |
vlnr discover |
poc-find-candidates agent |
vlnr agent |
poc-scan-vulnerabilities |
vlnr scan |
| (none) | vlnr run (full pipeline) |
The legacy scripts still work but emit a DeprecationWarning.
Loads OSV/EPSS data, fetches PyPI package lists, and scores candidates by
centrality (reverse-dependency count), downloads, GitHub stars, and recency.
An optional LLM intent score (Tier 3) can be blended 50/50 with the static
score — gated behind VLNR_LLM_DISCOVERY=1. Output: candidates.json.
Per candidate package:
- Clone — shallow git clone of the source repository
- Entry points — discover from
pyproject.toml,setup.cfg,setup.py - Metadata scan — inspect
.dist-info/METADATAfor suspicious patterns - External SAST — bandit, ruff, semgrep (best-effort; skipped if tools are missing)
- AST taint analysis — intra-procedural dataflow from sources
(
sys.argv,os.environ,input) to sinks (subprocess.run,eval,pickle.loads,open, etc.) - External hit fallback — unmatched tool hits get their own slices
- Conjunctive escalation — co-occurring signals (base64+exec, network+subprocess, metadata+exec) escalate severity
- Slice construction — per-sink code snippets with surrounding context
- Static scoring — per-slice risk score (base 0.5, Command Injection 0.8, Deserialization 0.6, with adjustments for static class and sources)
- LLM triage (optional, Tier 2) — batch evaluation of slices with
risk_score_static > 0.3or tool hits (≤10 per call) - NoiseFilterAgent — deterministic 6-rule classifier assigns every slice
a
SliceDecision:POC_CANDIDATE,STATIC_ONLY,IGNORE, orHUMAN_REVIEW - Output — findings JSON, slices JSONL, VEX for false positives
A deterministic scheduler drives a while iterations < max_iterations and budget_remaining > 0 loop with optional multi-agent features:
- Memory management — hard cap on history entries (default 100), summarize older entries beyond the recent threshold (50), prune completed slices.
- Planner (optional,
--planner) — LLM-based strategic planning (Tier 3). Generates ordered PlanItems. Falls back to deterministic if the LLM fails. Replans every 5 iterations (Tier 2). Additive only — never replaces the deterministic scheduler. - Verifier (optional,
--verify) — Maker-checker for POC_CANDIDATE decisions using a second LLM (Tier 3). Disagreement escalates to HUMAN_REVIEW. LLM errors pass through. - HITL surfacing — HUMAN_REVIEW slices are surfaced as structured
HumanReviewPromptobjects inhuman_review_prompts.json. Operator decisions can be applied via--resolve-reviews. deterministic_next_action(state)— pure function, no LLM (fallback):- Priority 1: slices with
decision == POC_CANDIDATEand no PoC data →run_poc_loop - Priority 2: unscanned packages in
candidate_pool→scan_package - Priority 3: nothing left →
stop
- Priority 1: slices with
_do_dispatch— routes toprocess_package()(parallel via ThreadPoolExecutor when--parallel N>1) orPocAgent.run_loop(), with structured JSONL observability logging.- Update state and persist to
agent_session.json(resumable)
Budget deduction: $0.06 before each run_poc_loop. No per-LLM-call deduction
in the scheduler.
Bounded retry loop (default 2 attempts):
- Attempt 0:
generate_poc(Tier 1 LLM) → validate in Docker - Attempt 1+:
refine_poc(Tier 2 LLM) with prior exploit code and failure logs → re-validate Runtime_Reachable→ success, breakContainerIsolationError→ stop immediately (Docker unavailable)- Exhaustion →
verification_steps = "Failed_after_N_attempts"
All model routing in llm_config.yaml. One model per tier. API key from
CUSTOM_OPENAI_API_KEY env var or api_key in config. Fallback when config
is missing: http://127.0.0.1:1234/v1 with qwen3.5-2b-mlx.
| Tier | Enum | Role | Example Model |
|---|---|---|---|
tier_1 |
LLMTier.TIER_1 |
PoC generation, whole-repo reasoning | openai/qwen-3.5-397b-instruct |
tier_2 |
LLMTier.TIER_2 |
Contextual triage, PoC refinement, replanning | openai/gemma-4-31b-it |
tier_3 |
LLMTier.TIER_3 |
Metadata classification, intent scoring, strategic planning, maker-checker verification | openai/qwen-3.5-4b-instruct |
vlnr uses five specialized agents, each with a formal identity (AgentId,
AgentIdentity, LifecycleState). All LLM-based agents are gated behind CLI
flags and fall back safely on failure.
The central coordinator. Runs a while loop of determine → dispatch → update
→ manage memory → surface reviews. Scheduling is deterministic by default
— a pure function (deterministic_next_action) picks the next action without
any LLM call. When --planner is enabled, the loop consults the PlannerAgent
before falling back to deterministic scheduling.
Key responsibilities: action dispatch, state merge, memory management, human-review surfacing, observability event timing, budget tracking, AgentBus wiring.
Deterministic 6-rule post-triage classifier. Takes a Slice and returns a
SliceDecision. Never calls an LLM on its own — the only LLM path is the
mid-range refinement which is invoked by the existing triage pipeline.
Pure function: no side effects, no state access.
Decisions: IGNORE (false positive), STATIC_ONLY (low risk),
POC_CANDIDATE (worth exploiting), HUMAN_REVIEW (operator needed).
Bounded retry loop for exploit generation and validation. Generates PoC code via Tier 1 LLM, validates in a Docker container, and refines via Tier 2 LLM on failure. Default 2 attempts. Stops immediately on container errors.
Key behaviors: Tier 1 → validate → Tier 2 refine → re-validate. Container unavailable → stop. Exhaustion → last attempt recorded.
Cross-checks NoiseFilterAgent decisions before expensive PoC execution.
Only inspects POC_CANDIDATE slices — all other decisions pass through
unchecked. Uses Tier 3 (cheapest model). Disagreement escalates the slice
to HUMAN_REVIEW. LLM failures result in pass-through (never blocks the
pipeline). Off by default.
LLM-based strategic planning for the agent loop. Generates an ordered list
of PlanItems (which packages to scan, which slices to run PoC on) using
Tier 3. Replans every 5 iterations using Tier 2 when the plan is stale or
exhausted. Additive only — never replaces the deterministic scheduler.
If the LLM fails or returns an empty plan, the loop falls back to
deterministic_next_action. Off by default.
Agents communicate over an in-process AgentBus with typed Pydantic messages
(TASK, RESULT, ERROR). The bus is wired for future async operation but
currently wraps the existing direct dispatch path. Excluded from state
serialization.
| Flag | Default | Description |
|---|---|---|
--planner |
off | Enable LLM-based strategic planner (Tier 3 plan, Tier 2 replan) |
--verify |
off | Enable maker-checker verification of POC_CANDIDATE decisions |
--parallel N |
1 | Max parallel package scans (1 = sequential) |
--max-history N |
100 | Cap on agent history entries |
--summarize-threshold N |
50 | Recent entries to retain before summarization |
--[no-]prune-completed |
prune | Enable/disable pruning of completed slices from active state |
--obs-log PATH |
agent_trace.jsonl |
Path for structured observability JSONL log |
--resolve-reviews PATH |
none | Apply operator review decisions from a JSON file |
--max-workers N |
4 | Max concurrent package scans for vlnr scan |
--max-fetches N |
2 | Max concurrent source fetches |
# Quiet run with just the deterministic scheduler
vlnr run --out-dir out/ --osv-dump osv.zip --packages requests
# Multi-agent: parallel scanning + planner + verifier + observability
vlnr run --out-dir out/ --osv-dump osv.zip --packages requests \
--llm-triage --parallel 4 --planner --verify --obs-log trace.jsonl
# With memory tuning for long runs
vlnr run --out-dir out/ --osv-dump osv.zip --packages requests \
--llm-triage --max-history 200 --summarize-threshold 75 --parallel 8
# Resume with operator review decisions
vlnr run --out-dir out/ --osv-dump osv.zip --packages requests \
--llm-triage --resolve-reviews my_decisions.json| File | Description |
|---|---|
candidates.json |
Ranked candidates with scores, OSV IDs, category |
all-findings-index.json |
Index of all scanned packages with stats |
<pkg>-findings.json |
Per-package structured findings |
<pkg>-slices.jsonl |
Per-package taint dataflow slices |
<pkg>-<slice_id>-vex.json |
OpenVEX v0.2.0 records (false positives only) |
agent_session.json |
Resumable agent state |
agent_trace.jsonl |
Structured observability log (configurable via --obs-log) |
human_review_prompts.json |
Structured prompts for HUMAN_REVIEW slices |
| CWE | Sink | Detection |
|---|---|---|
| CWE-78 | subprocess.run(shell=True) |
AST taint + bypass scan |
| CWE-22 | open(file_path) |
Path traversal via os.path.join |
| CWE-502 | pickle.loads() |
__reduce__ payload construction |
| CWE-94 | eval(), exec() |
Builtins reflection via getattr |
| CWE-918 | urllib, httpx, aiohttp |
Semgrep SSRF rules (vlnr/rules/ssrf.yaml) |
- Intra-procedural AST — taint analysis does not track dataflow across
function boundaries. Each
FunctionDefis analyzed independently. - Hardcoded budget estimates — $0.06 deducted before each PoC loop. LLM calls within the loop are not tracked. No reconciliation against actual API billing.
- Docker required — PoC validation runs in Docker containers.
ContainerIsolationErrorstops the PoC loop if Docker is unavailable. - VEX only for false positives — OpenVEX documents are generated exclusively for slices marked as false positive.
- AST parse timeout — a 5-second
threading.Timerguards source parsing. Thread-safe and works on all platforms (replaced the old Unix-only SIGALRM). - External tools best-effort — bandit, ruff, and semgrep are attempted
via
subprocess.run. Missing tools are skipped with a warning. - Planner and verifier are LLM-dependent — both require an LLM client. Planner falls back to deterministic scheduling on failure. Verifier passes through POC_CANDIDATE decisions on failure.
MIT — see LICENSE.