Security teams drown in advisory noise. NVD alone publishes hundreds of CVEs per week, the GitHub Advisory Database adds more, and every vendor has their own feed. Without filtering, your team either ignores everything or spends hours manually triaging bulletins to find the three that actually matter for your stack.
This guide shows how to build a lightweight Python agent that fetches advisories, uses a language model to triage and summarize them, and dispatches alerts only for what is relevant to your specific tech stack.
What We're Building
The agent has three stages:
- Fetch — pull advisories from NVD API v2
- Triage — pass each advisory through an LLM that classifies it against your dependency list
- Alert — send a formatted summary to Slack for advisories that score above your threshold
Dependencies: httpx for HTTP, any OpenAI-compatible SDK for LLM calls, and the standard library for everything else.
Fetching Advisories from NVD API v2
NIST updated their API in 2023 — the v1 endpoint is deprecated. The v2 API requires an API key for higher rate limits (2,000 requests/day on the free tier vs 5 per 30 seconds without one).
import httpx
from datetime import datetime, timedelta, timezone
from typing import Iterator
NVD_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
NVD_API_KEY = "your-nvd-api-key" # optional but strongly recommended
def fetch_recent_cves(hours_back: int = 24) -> Iterator[dict]:
now = datetime.now(timezone.utc)
start = now - timedelta(hours=hours_back)
params = {
"pubStartDate": start.strftime("%Y-%m-%dT%H:%M:%S.000"),
"pubEndDate": now.strftime("%Y-%m-%dT%H:%M:%S.000"),
"resultsPerPage": 100,
}
headers = {"apiKey": NVD_API_KEY} if NVD_API_KEY else {}
start_index = 0
total = None
while total is None or start_index < total:
params["startIndex"] = start_index
r = httpx.get(NVD_API_URL, params=params, headers=headers, timeout=30)
r.raise_for_status()
data = r.json()
total = data["totalResults"]
vulnerabilities = data.get("vulnerabilities", [])
for item in vulnerabilities:
cve = item["cve"]
yield {
"id": cve["id"],
"description": next(
(d["value"] for d in cve.get("descriptions", []) if d["lang"] == "en"),
"",
),
"cvss_score": _extract_cvss(cve),
"published": cve["published"],
}
start_index += len(vulnerabilities)
if not vulnerabilities:
break
def _extract_cvss(cve: dict) -> float | None:
metrics = cve.get("metrics", {})
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
if key in metrics:
return metrics[key][0]["cvssData"]["baseScore"]
return None
The pagination loop handles the 100-result-per-page cap transparently. On a typical day you will get 200–400 CVEs; filtering happens in the next stage.
LLM-Powered Triage
The raw feed is too large to alert on. We want to keep only advisories that affect packages we actually use. Rather than maintaining a complex regex ruleset against CVE descriptions, we pass each advisory to a language model with our stack manifest.
import json
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from environment
STACK_MANIFEST = (
"Python packages: fastapi, sqlalchemy, pydantic, httpx, celery, redis-py\n"
"Go modules: gin, gorm, pgx, fiber\n"
"Infrastructure: nginx 1.24, postgresql 15, redis 7"
)
TRIAGE_PROMPT = (
"You are a security triage assistant. Given a CVE and a tech stack, determine:\n"
"1. Whether this CVE affects any component in the stack (yes/no)\n"
"2. A one-sentence plain-English summary of the impact\n"
"3. An urgency level: critical (patch today), high (patch this week), low (monitor)\n\n"
"Respond in JSON only with keys: affects_stack (bool), summary (str), urgency (str).\n\n"
"Tech stack:\n{stack}\n\nCVE {cve_id} (CVSS: {cvss}):\n{description}"
)
def triage_cve(cve: dict) -> dict | None:
prompt = TRIAGE_PROMPT.format(
stack=STACK_MANIFEST,
cve_id=cve["id"],
cvss=cve["cvss_score"] or "N/A",
description=cve["description"],
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0,
)
result = json.loads(response.choices[0].message.content)
if not result.get("affects_stack"):
return None
return {**cve, **result}
Using response_format={"type": "json_object"} enforces structured output — no need to parse free-form text or handle hallucinated formats.
At roughly $0.00015 per 1K input tokens and an average CVE description of ~300 tokens, triaging 400 CVEs costs under $0.10/day.
Dispatching Alerts to Slack
Only critical and high-urgency advisories that affect your stack make it to the alert stage.
import httpx
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/your/webhook/url"
def send_slack_alert(cves: list[dict]) -> None:
if not cves:
return
blocks: list[dict] = [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"Security Advisory Alert: {len(cves)} finding(s)",
},
}
]
for cve in cves:
urgency_tag = "[CRITICAL]" if cve["urgency"] == "critical" else "[HIGH]"
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"*{urgency_tag} {cve['id']}* (CVSS {cve['cvss_score'] or '?'})\n"
f"{cve['summary']}\n"
f"<https://nvd.nist.gov/vuln/detail/{cve['id']}|View on NVD>"
),
},
})
httpx.post(SLACK_WEBHOOK_URL, json={"blocks": blocks}, timeout=10).raise_for_status()
Putting It Together
def run_advisory_agent(hours_back: int = 24, min_cvss: float = 5.0) -> None:
print(f"Fetching CVEs from the last {hours_back}h...")
cves = list(fetch_recent_cves(hours_back))
print(f" Fetched {len(cves)} CVEs total")
# Pre-filter by CVSS before spending LLM tokens
candidates = [c for c in cves if (c["cvss_score"] or 0) >= min_cvss]
print(f" {len(candidates)} above CVSS {min_cvss} — triaging with LLM...")
relevant = []
for cve in candidates:
result = triage_cve(cve)
if result and result["urgency"] in ("critical", "high"):
relevant.append(result)
print(f" {len(relevant)} relevant advisories — dispatching alert")
send_slack_alert(relevant)
if __name__ == "__main__":
run_advisory_agent()
Schedule this with cron or a cloud scheduler to run every 24 hours. A complete checklist for hardening this pipeline — including API key management, retry logic, and alert deduplication — is available at ayinedjimi-consultants.fr/checklists.
The Takeaway
The agent pattern here — fetch, pre-filter cheaply, triage with an LLM, alert — scales to any advisory source. You can swap NVD for the GitHub Advisory Database, OSV.dev, or vendor-specific feeds without changing the triage or alert layers.
A few practical notes:
- Pre-filter by CVSS before LLM calls. Most CVEs score below 7.0 and can be discarded without touching the model, cutting token costs significantly.
- Keep your stack manifest updated. An outdated manifest is worse than no manifest — it gives false confidence that an advisory does not affect you.
- Add deduplication. Store processed CVE IDs in a SQLite table to avoid re-alerting on the same advisory across runs.
- Tune the CVSS threshold. CVSS 5.0+ as a pre-filter and "critical"/"high" from the LLM is a reasonable starting point. Adjust based on your team's noise tolerance.
- Rotate your NVD API key like any other credential. The free tier allows 2,000 requests per day — more than enough for a daily agent.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.
Top comments (0)