Initial ThreatLens release

This commit is contained in:
2026-06-27 21:24:57 -04:00
commit d96e326d4d
27 changed files with 3059 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""utils package — shared helpers for VulnForge (API client, embeds, helpers)."""
Executable
+329
View File
@@ -0,0 +1,329 @@
"""
utils/api.py — async data layer for NVD CVE API v2 and CISA KEV feed.
Responsibilities:
* Single shared aiohttp.ClientSession (created on bot startup, closed on shutdown).
* Rate-limit-friendly: on-disk TTL caching + retry/backoff for NVD 403/429/5xx.
* Normalises raw API JSON into simple dataclasses (CVERecord, KEVEntry) so the
cogs/embeds never touch the messy upstream schema directly.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
import aiohttp
from config import settings
from utils.helpers import (
JSONCache,
parse_nvd_datetime,
severity_from_score,
)
log = logging.getLogger("vulnforge.api")
# ──────────────────────────────────────────────────────────────
# Data models
# ──────────────────────────────────────────────────────────────
@dataclass
class CVERecord:
"""A normalised view of a single NVD CVE entry."""
cve_id: str
description: str
published: datetime | None
last_modified: datetime | None
cvss_score: float | None
cvss_severity: str
cvss_vector: str | None
cvss_version: str | None
cwes: list[str] = field(default_factory=list)
references: list[str] = field(default_factory=list)
status: str = "Unknown"
@property
def nvd_url(self) -> str:
return f"{settings.nvd_cve_web}/{self.cve_id}"
@dataclass
class KEVEntry:
"""A normalised CISA Known-Exploited-Vulnerability entry."""
cve_id: str
vendor: str
product: str
name: str
date_added: str
short_description: str
required_action: str
due_date: str
ransomware: str
@property
def date_added_dt(self) -> datetime | None:
try:
return datetime.strptime(self.date_added, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
except (ValueError, TypeError):
return None
# ──────────────────────────────────────────────────────────────
# Client
# ──────────────────────────────────────────────────────────────
class SecurityAPI:
"""Async client for NVD + CISA KEV with caching and retry/backoff."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
self._cache = JSONCache(settings.cache_dir)
# In-memory KEV index so per-CVE lookups don't reparse the whole feed.
self._kev_index: dict[str, KEVEntry] = {}
self._kev_loaded_at: datetime | None = None
self._kev_lock = asyncio.Lock()
# ── lifecycle ────────────────────────────────────────────
async def start(self) -> None:
if self._session is None or self._session.closed:
headers = {"User-Agent": settings.user_agent}
if settings.nvd_api_key:
headers["apiKey"] = settings.nvd_api_key
timeout = aiohttp.ClientTimeout(total=settings.http_timeout)
self._session = aiohttp.ClientSession(headers=headers, timeout=timeout)
log.info("aiohttp session started (NVD key: %s)", bool(settings.nvd_api_key))
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
log.info("aiohttp session closed")
def _require_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
raise RuntimeError("SecurityAPI session not started — call start() first.")
return self._session
# ── low-level GET with retry/backoff ─────────────────────
async def _get_json(
self, url: str, params: dict[str, Any] | None = None
) -> dict[str, Any] | None:
session = self._require_session()
delay = 2.0
for attempt in range(1, settings.request_retries + 1):
try:
async with session.get(url, params=params) as resp:
if resp.status == 200:
return await resp.json()
if resp.status in (403, 429, 503, 502, 500):
log.warning(
"GET %s%s (attempt %s/%s), backing off %.1fs",
url, resp.status, attempt, settings.request_retries, delay,
)
await asyncio.sleep(delay)
delay *= 2
continue
log.error("GET %s → unexpected status %s", url, resp.status)
return None
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
log.warning(
"GET %s failed (attempt %s/%s): %s",
url, attempt, settings.request_retries, exc,
)
await asyncio.sleep(delay)
delay *= 2
log.error("GET %s exhausted retries", url)
return None
# ── NVD: single CVE ──────────────────────────────────────
async def fetch_cve(self, cve_id: str) -> CVERecord | None:
"""Fetch a single CVE by id (cached for cve_cache_ttl)."""
cache_key = f"cve_{cve_id}"
cached = self._cache.get(cache_key, settings.cve_cache_ttl)
if cached is not None:
return self._parse_cve_item(cached)
data = await self._get_json(settings.nvd_base_url, {"cveId": cve_id})
if not data:
return None
vulns = data.get("vulnerabilities") or []
if not vulns:
return None
item = vulns[0]
self._cache.set(cache_key, item)
return self._parse_cve_item(item)
# ── NVD: recent CVEs (last N days) ───────────────────────
async def fetch_recent_cves(self, days: int = 7) -> list[CVERecord]:
"""
Fetch CVEs published in the last `days` days.
NVD limits the pubStartDate/pubEndDate window to 120 days and paginates
with resultsPerPage / startIndex. We page through everything in the
window and cache the assembled list.
"""
cache_key = f"recent_{days}d"
cached = self._cache.get(cache_key, settings.cve_cache_ttl)
if cached is not None:
return [self._parse_cve_item(i) for i in cached]
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
iso = "%Y-%m-%dT%H:%M:%S.000"
results_per_page = 2000
start_index = 0
collected: list[dict[str, Any]] = []
while True:
params = {
"pubStartDate": start.strftime(iso),
"pubEndDate": end.strftime(iso),
"resultsPerPage": results_per_page,
"startIndex": start_index,
}
data = await self._get_json(settings.nvd_base_url, params)
if not data:
break
vulns = data.get("vulnerabilities") or []
collected.extend(vulns)
total = int(data.get("totalResults", 0))
start_index += results_per_page
if start_index >= total or not vulns:
break
# Be gentle on the API between pages.
await asyncio.sleep(0.7 if settings.nvd_api_key else 6.0)
self._cache.set(cache_key, collected)
log.info("Fetched %s CVEs from the last %s days", len(collected), days)
return [self._parse_cve_item(i) for i in collected]
# ── CISA KEV feed ────────────────────────────────────────
async def _load_kev(self, force: bool = False) -> dict[str, KEVEntry]:
"""Load + index the KEV feed (cached on disk + in memory)."""
async with self._kev_lock:
fresh = (
self._kev_index
and self._kev_loaded_at
and datetime.now(timezone.utc) - self._kev_loaded_at
< timedelta(seconds=settings.kev_cache_ttl)
)
if fresh and not force:
return self._kev_index
payload = None if force else self._cache.get("kev_feed", settings.kev_cache_ttl)
if payload is None:
payload = await self._get_json(settings.cisa_kev_url)
if payload:
self._cache.set("kev_feed", payload)
index: dict[str, KEVEntry] = {}
for v in (payload or {}).get("vulnerabilities", []):
entry = KEVEntry(
cve_id=v.get("cveID", "").upper(),
vendor=v.get("vendorProject", "Unknown"),
product=v.get("product", "Unknown"),
name=v.get("vulnerabilityName", ""),
date_added=v.get("dateAdded", ""),
short_description=v.get("shortDescription", ""),
required_action=v.get("requiredAction", ""),
due_date=v.get("dueDate", ""),
ransomware=v.get("knownRansomwareCampaignUse", "Unknown"),
)
if entry.cve_id:
index[entry.cve_id] = entry
self._kev_index = index
self._kev_loaded_at = datetime.now(timezone.utc)
log.info("KEV feed loaded: %s entries", len(index))
return index
async def get_kev_entry(self, cve_id: str) -> KEVEntry | None:
index = await self._load_kev()
return index.get(cve_id.upper())
async def is_in_kev(self, cve_id: str) -> bool:
return (await self.get_kev_entry(cve_id)) is not None
async def latest_kev(self, limit: int = 10) -> list[KEVEntry]:
"""Return the most recently added KEV entries (newest first)."""
index = await self._load_kev()
entries = sorted(
index.values(),
key=lambda e: e.date_added or "",
reverse=True,
)
return entries[:limit]
async def kev_added_since(self, days: int = 7) -> list[KEVEntry]:
"""KEV entries whose dateAdded falls within the last `days` days."""
index = await self._load_kev()
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
out: list[KEVEntry] = []
for e in index.values():
dt = e.date_added_dt
if dt and dt >= cutoff:
out.append(e)
out.sort(key=lambda e: e.date_added or "", reverse=True)
return out
# ── parsing ──────────────────────────────────────────────
@staticmethod
def _parse_cve_item(item: dict[str, Any]) -> CVERecord:
"""Convert a raw NVD `vulnerabilities[]` item into a CVERecord."""
cve = item.get("cve", {})
cve_id = cve.get("id", "UNKNOWN")
# Description: prefer English.
description = ""
for d in cve.get("descriptions", []):
if d.get("lang") == "en":
description = d.get("value", "")
break
if not description and cve.get("descriptions"):
description = cve["descriptions"][0].get("value", "")
# CVSS: prefer v3.1 → v3.0 → v2 (highest available).
score = severity = vector = version = None
metrics = cve.get("metrics", {})
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
arr = metrics.get(key)
if arr:
data = arr[0].get("cvssData", {})
score = data.get("baseScore")
vector = data.get("vectorString")
version = data.get("version")
severity = (
data.get("baseSeverity")
or arr[0].get("baseSeverity")
or severity_from_score(score)
)
break
cwes: list[str] = []
for weakness in cve.get("weaknesses", []):
for desc in weakness.get("description", []):
val = desc.get("value")
if val and val.upper() != "NVD-CWE-NOINFO" and val not in cwes:
cwes.append(val)
refs = [r.get("url") for r in cve.get("references", []) if r.get("url")]
return CVERecord(
cve_id=cve_id,
description=description or "No description available.",
published=parse_nvd_datetime(cve.get("published")),
last_modified=parse_nvd_datetime(cve.get("lastModified")),
cvss_score=score,
cvss_severity=(severity or severity_from_score(score)).upper(),
cvss_vector=vector,
cvss_version=version,
cwes=cwes,
references=refs,
status=cve.get("vulnStatus", "Unknown"),
)
+293
View File
@@ -0,0 +1,293 @@
"""
utils/embeds.py — cyberpunk / hacker-aesthetic embed builders.
Every embed in VulnForge is built here so styling stays consistent:
dark backdrops, neon severity colours, monospace code blocks, and tidy
mobile + desktop formatting. Functions accept normalised dataclasses from
utils.api and return ready-to-send nextcord.Embed objects.
"""
from __future__ import annotations
from datetime import datetime, timezone
import nextcord
from config import settings
from utils.api import CVERecord, KEVEntry
from utils.enricher import cve_links
from utils.helpers import (
discord_timestamp,
severity_color,
severity_emoji,
truncate,
)
# Visual furniture reused across embeds.
DIVIDER = "▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰"
BRAND = "VulnForge // Security Intelligence"
BRAND_ICON = "https://www.cisa.gov/profiles/cisad8_gov/themes/custom/gesso/logo.svg"
def cve_pivots(cve_id: str, *, compact: bool = False) -> str:
"""One-line markdown pivot links for a CVE — PoC search / exploit-db / etc.
Used to enrich every CVE-bearing embed (cve, weekly, kev) with quick links
to GitHub PoCs, exploit-db, Nuclei templates, trickest/cve and Vulncheck.
Pass `compact=True` for tighter rendering in list-style embeds.
"""
links = {name: url for name, url in cve_links(cve_id)}
if compact:
order = ("GitHub PoC search", "exploit-db", "Nuclei templates")
labels = {"GitHub PoC search": "PoC", "exploit-db": "ExDB", "Nuclei templates": "Nuclei"}
else:
order = ("GitHub PoC search", "exploit-db", "Nuclei templates",
"trickest/cve", "Vulncheck", "GitHub Advisory")
labels = {
"GitHub PoC search": "GH PoC",
"exploit-db": "Exploit-DB",
"Nuclei templates": "Nuclei",
"trickest/cve": "trickest/cve",
"Vulncheck": "Vulncheck",
"GitHub Advisory": "GH Advisory",
}
return " · ".join(f"[{labels[k]}]({links[k]})" for k in order if k in links)
def _footer(embed: nextcord.Embed) -> nextcord.Embed:
embed.set_footer(text=f"{BRAND}")
embed.timestamp = datetime.now(timezone.utc)
return embed
# ──────────────────────────────────────────────────────────────
# CVE detail embed
# ──────────────────────────────────────────────────────────────
def build_cve_embed(
cve: CVERecord,
kev: KEVEntry | None,
*,
show_details: bool = True,
show_kev: bool = True,
show_references: bool = True,
show_cwe: bool = True,
notes: list[dict] | None = None,
) -> nextcord.Embed:
"""Build the rich, option-driven CVE embed returned by /cve."""
sev = cve.cvss_severity
color = settings.color_kev if kev else severity_color(sev)
exploited = " ☣ ACTIVELY EXPLOITED" if kev else ""
embed = nextcord.Embed(
title=f"{severity_emoji(sev)} {cve.cve_id}{exploited}",
url=cve.nvd_url,
description=truncate(cve.description, 1024) if show_details else None,
color=color,
)
# Score line — always shown; it's the headline metric.
score_str = f"`{cve.cvss_score}`" if cve.cvss_score is not None else "`N/A`"
ver = f" (CVSS v{cve.cvss_version})" if cve.cvss_version else ""
embed.add_field(
name="◈ Severity",
value=f"{severity_emoji(sev)} **{sev}**{ver}",
inline=True,
)
embed.add_field(name="◈ Base Score", value=score_str, inline=True)
embed.add_field(name="◈ Status", value=f"`{cve.status}`", inline=True)
if show_details:
embed.add_field(
name="◈ Published",
value=discord_timestamp(cve.published, "D"),
inline=True,
)
embed.add_field(
name="◈ Modified",
value=discord_timestamp(cve.last_modified, "R"),
inline=True,
)
if cve.cvss_vector:
embed.add_field(
name="◈ Vector",
value=f"```{truncate(cve.cvss_vector, 240)}```",
inline=False,
)
if show_cwe and cve.cwes:
embed.add_field(
name="◈ Weaknesses (CWE)",
value=" ".join(f"`{c}`" for c in cve.cwes[:8]),
inline=False,
)
# KEV block — the most security-critical signal.
if show_kev:
if kev:
ransom = kev.ransomware if kev.ransomware else "Unknown"
embed.add_field(name=DIVIDER, value="**☣ CISA KEV — ACTIVE EXPLOITATION**", inline=False)
embed.add_field(name="▸ Vendor / Product", value=f"`{kev.vendor}` / `{kev.product}`", inline=True)
embed.add_field(name="▸ Added to KEV", value=discord_timestamp(kev.date_added_dt, "D"), inline=True)
embed.add_field(name="▸ Remediation Due", value=f"`{kev.due_date or 'N/A'}`", inline=True)
embed.add_field(name="▸ Ransomware Use", value=f"`{ransom}`", inline=True)
if kev.required_action:
embed.add_field(
name="▸ Required Action",
value=truncate(kev.required_action, 1024),
inline=False,
)
else:
embed.add_field(
name="◈ CISA KEV Status",
value="✅ Not currently listed in the Known Exploited Vulnerabilities catalog.",
inline=False,
)
if show_references and cve.references:
links = "\n".join(f"• [{truncate(u, 70)}]({u})" for u in cve.references[:6])
embed.add_field(name="◈ References", value=truncate(links, 1024), inline=False)
if notes:
rendered = "\n".join(
f"• <@{n['user_id']}>: {truncate(n['note'], 150)}" for n in notes[:5]
)
embed.add_field(name="◈ Researcher Notes", value=truncate(rendered, 1024), inline=False)
pivots = cve_pivots(cve.cve_id)
if pivots:
embed.add_field(name="◈ Recon Pivots", value=pivots, inline=False)
embed.add_field(
name="◈ Links",
value=f"[🛰 NVD]({cve.nvd_url}) • [🛡 CISA KEV]({settings.cisa_kev_web})",
inline=False,
)
return _footer(embed)
# ──────────────────────────────────────────────────────────────
# Weekly digest embed
# ──────────────────────────────────────────────────────────────
def build_weekly_embed(
*,
total_new: int,
in_kev_count: int,
newly_added_kev: list[KEVEntry],
top_cves: list[CVERecord],
days: int = 7,
) -> nextcord.Embed:
"""Build the automated weekly threat-intelligence digest embed."""
embed = nextcord.Embed(
title="☣ VULNFORGE WEEKLY THREAT DIGEST",
description=(
f"```ansi\n\u001b[1;31m> Intelligence sweep — last {days} days\u001b[0m\n```\n"
f"Aggregated from **NVD** and the **CISA KEV** catalog."
),
color=settings.color_critical if newly_added_kev else settings.color_info,
url=settings.cisa_kev_web,
)
# Headline metrics block.
embed.add_field(name="🆕 New CVEs", value=f"**{total_new:,}**", inline=True)
embed.add_field(name="☣ Already in KEV", value=f"**{in_kev_count:,}**", inline=True)
embed.add_field(name="⚠ Newly Added to KEV", value=f"**{len(newly_added_kev):,}**", inline=True)
embed.add_field(name=DIVIDER, value="**▶ TOP CRITICAL VULNERABILITIES**", inline=False)
if top_cves:
for cve in top_cves:
sev = cve.cvss_severity
score = cve.cvss_score if cve.cvss_score is not None else "N/A"
pivots = cve_pivots(cve.cve_id, compact=True)
link_line = f"[NVD]({cve.nvd_url})"
if pivots:
link_line += f" · {pivots}"
embed.add_field(
name=f"{severity_emoji(sev)} {cve.cve_id}{score}",
value=f"{truncate(cve.description, 140)}\n{link_line}",
inline=False,
)
else:
embed.add_field(
name="",
value="No scored CVEs found in the window.",
inline=False,
)
if newly_added_kev:
kev_lines = "\n".join(
f"• `{e.cve_id}` — {truncate(e.product, 38)} ({e.vendor}) — {cve_pivots(e.cve_id, compact=True)}"
for e in newly_added_kev[:8]
)
embed.add_field(
name="☣ Newly Weaponized (Added to KEV)",
value=truncate(kev_lines, 1024),
inline=False,
)
embed.add_field(
name="◈ Sources",
value=(
"[🛰 NVD Feed](https://nvd.nist.gov/vuln) • "
f"[🛡 CISA KEV]({settings.cisa_kev_web})"
),
inline=False,
)
return _footer(embed)
# ──────────────────────────────────────────────────────────────
# KEV list embed
# ──────────────────────────────────────────────────────────────
def build_kev_list_embed(entries: list[KEVEntry], limit: int) -> nextcord.Embed:
"""Build the embed for the /kev command (latest KEV additions)."""
embed = nextcord.Embed(
title="☣ CISA KEV — LATEST KNOWN EXPLOITED VULNERABILITIES",
description=(
"```ansi\n\u001b[1;31m> Actively exploited in the wild\u001b[0m\n```"
f"Showing the **{min(limit, len(entries))}** most recent additions."
),
color=settings.color_kev,
url=settings.cisa_kev_web,
)
for e in entries[:limit]:
pivots = cve_pivots(e.cve_id, compact=True)
link_line = f"[NVD]({settings.nvd_cve_web}/{e.cve_id})"
if pivots:
link_line += f" · {pivots}"
embed.add_field(
name=f"🩸 {e.cve_id}{truncate(e.product, 50)}",
value=truncate(
f"**Vendor:** {e.vendor}\n"
f"**Added:** {discord_timestamp(e.date_added_dt, 'D')}"
f"**Ransomware:** `{e.ransomware}`\n"
f"{truncate(e.short_description, 140)}\n"
f"{link_line}",
1024,
),
inline=False,
)
if not entries:
embed.description = "No KEV entries could be retrieved right now."
return _footer(embed)
# ──────────────────────────────────────────────────────────────
# Error / utility embeds
# ──────────────────────────────────────────────────────────────
def build_error_embed(title: str, message: str) -> nextcord.Embed:
embed = nextcord.Embed(
title=f"{title}",
description=message,
color=settings.color_critical,
)
return _footer(embed)
def build_info_embed(title: str, message: str) -> nextcord.Embed:
embed = nextcord.Embed(
title=f"{title}",
description=message,
color=settings.color_success,
)
return _footer(embed)
+728
View File
@@ -0,0 +1,728 @@
"""
utils/enricher.py — Markdown artifact enrichment.
Scans a markdown report for security-relevant artifacts (CVEs, IPv4 addresses,
hostnames, emails, GitHub repos / commits, raw URLs, vendor product names) and:
1. Rewrites each occurrence inline as a clickable markdown link pointing at
the most useful primary lookup (NVD for CVEs, Shodan for IPs, github.com
for repos, etc.).
2. Appends an "INTEL APPENDIX" section grouping every unique artifact with
the FULL set of enrichment links (Shodan + Censys + VirusTotal for IPs,
NVD + GitHub PoC search + exploit-db + nuclei templates for CVEs, etc.).
Code fences, existing markdown links and inline code spans are preserved
unmodified so the original report stays readable.
Public API:
enrich_markdown(text: str) -> tuple[str, Stats]
"""
from __future__ import annotations
import ipaddress
import re
from dataclasses import dataclass, field
from typing import Callable
from urllib.parse import quote, quote_plus
# ──────────────────────────────────────────────────────────────────────────
# Regex patterns
# ──────────────────────────────────────────────────────────────────────────
# CVE: CVE-YYYY-NNNN(N+) — case insensitive, normalised to uppercase.
CVE_RE = re.compile(r"\bCVE-\d{4}-\d{4,7}\b", re.IGNORECASE)
# IPv4 with optional CIDR; we filter private/invalid in post-processing.
IPV4_RE = re.compile(
r"\b(?:25[0-5]|2[0-4]\d|[01]?\d\d?)"
r"(?:\.(?:25[0-5]|2[0-4]\d|[01]?\d\d?)){3}"
r"(?:/\d{1,2})?\b"
)
# Emails — RFC-ish, good enough for report text.
EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]{2,}\b")
# Raw http(s) URLs — used both for "wrap as link" and for hostname extraction.
URL_RE = re.compile(r"https?://[^\s<>()\[\]\"']+", re.IGNORECASE)
# GitHub repo refs like org/repo, optionally followed by @sha or #commit-sha.
# Restrictive: 1st segment 1-39 chars (GitHub login limit), 2nd 1-100 chars,
# both must contain at least one letter to avoid matching numeric paths.
GH_REPO_RE = re.compile(
r"(?<![\w/])"
r"([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))"
r"/"
r"([A-Za-z0-9_][A-Za-z0-9_.-]{0,99})"
r"(?:[@#]([0-9a-f]{7,40}))?"
r"(?![\w/])"
)
# Bare hostnames (FQDNs not part of a URL). We extract from the full text but
# skip ones already inside URLs to avoid double-linking.
HOSTNAME_RE = re.compile(
r"(?<![\w/@.-])"
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){1,}"
r"(?:[a-z]{2,24})"
r"(?![\w-])",
re.IGNORECASE,
)
# Git commit SHA (7-40 hex). Only enriched when paired with a known repo.
SHA_RE = re.compile(r"\b([0-9a-f]{7,40})\b")
# Code fence / inline code stripper — protects these regions from rewriting.
FENCED_BLOCK_RE = re.compile(r"```.*?```", re.DOTALL)
INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
EXISTING_LINK_RE = re.compile(r"\[[^\]]+\]\([^)]+\)")
MD_TABLE_DIVIDER_RE = re.compile(r"^\s*\|?\s*:?-{3,}", re.MULTILINE)
# Vendors / products → vendor pages. Map of regex → (display, url).
# Keys are matched case-insensitively as whole "words" (word boundaries).
VENDORS: dict[str, tuple[str, str]] = {
r"Blackboard Learn": (
"Blackboard Learn",
"https://help.blackboard.com/Learn",
),
r"OmniUpdate(?: CMS)?|Modern Campus CMS|OU Campus": (
"Modern Campus CMS (OmniUpdate)",
"https://moderncampus.com/cms/",
),
r"Apache TomEE": (
"Apache TomEE",
"https://tomee.apache.org/security/",
),
r"Sitefinity": (
"Progress Sitefinity",
"https://www.progress.com/sitefinity-cms",
),
r"Anthology Engage(?:CMS)?": (
"Anthology Engage",
"https://www.anthology.com/products/engagement/engage",
),
r"PeopleSoft": (
"Oracle PeopleSoft",
"https://www.oracle.com/applications/peoplesoft/",
),
r"CourseLeaf": (
"CourseLeaf (Leepfrog)",
"https://courseleaf.com/",
),
r"\bInfor\b": (
"Infor",
"https://www.infor.com/",
),
r"Kaltura": (
"Kaltura",
"https://corp.kaltura.com/",
),
r"TeamDynamix": (
"TeamDynamix",
"https://www.teamdynamix.com/",
),
r"FortiGuard": (
"FortiGuard Labs",
"https://www.fortiguard.com/",
),
r"Cisco SCCP|Cisco SIP|Skinny Call Control Protocol": (
"Cisco SCCP/SIP",
"https://www.cisco.com/c/en/us/products/unified-communications/index.html",
),
r"New Relic": (
"New Relic",
"https://newrelic.com/",
),
r"Supabase": (
"Supabase",
"https://supabase.com/docs",
),
r"Vercel": (
"Vercel",
"https://vercel.com/",
),
r"ProtonMail": (
"Proton Mail",
"https://proton.me/mail",
),
}
# Pre-compile vendor regex (case-insensitive, word boundaries where sensible).
_VENDOR_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [
(re.compile(rf"\b(?:{pat})\b", re.IGNORECASE), disp, url)
for pat, (disp, url) in VENDORS.items()
]
# ──────────────────────────────────────────────────────────────────────────
# Enrichment link factories
# ──────────────────────────────────────────────────────────────────────────
def cve_links(cve: str) -> list[tuple[str, str]]:
"""Return (label, url) tuples for a CVE id."""
q = quote_plus(cve)
return [
("NVD", f"https://nvd.nist.gov/vuln/detail/{cve}"),
("MITRE", f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve}"),
("GitHub Advisory", f"https://github.com/advisories?query={q}"),
("GitHub PoC search", f"https://github.com/search?q={q}&type=repositories"),
("exploit-db", f"https://www.exploit-db.com/search?cve={cve.removeprefix('CVE-')}"),
("Nuclei templates", f"https://github.com/projectdiscovery/nuclei-templates/search?q={q}"),
("trickest/cve", f"https://github.com/trickest/cve/tree/main/{cve.split('-')[1]}"),
("Vulncheck", f"https://vulncheck.com/browse/cve/{cve}"),
]
def ipv4_links(ip: str) -> list[tuple[str, str]]:
"""Return (label, url) tuples for an IPv4 address (no CIDR suffix)."""
return [
("Shodan", f"https://www.shodan.io/host/{ip}"),
("Censys", f"https://search.censys.io/hosts/{ip}"),
("VirusTotal", f"https://www.virustotal.com/gui/ip-address/{ip}"),
("GreyNoise", f"https://viz.greynoise.io/ip/{ip}"),
("AbuseIPDB", f"https://www.abuseipdb.com/check/{ip}"),
("ipinfo.io", f"https://ipinfo.io/{ip}"),
]
def hostname_links(host: str) -> list[tuple[str, str]]:
"""Return (label, url) tuples for a hostname / FQDN."""
host_q = quote(host)
return [
("Open", f"https://{host}"),
("crt.sh", f"https://crt.sh/?q=%25.{host_q}"),
("urlscan.io", f"https://urlscan.io/domain/{host_q}"),
("Shodan", f"https://www.shodan.io/search?query=hostname%3A{host_q}"),
("VirusTotal", f"https://www.virustotal.com/gui/domain/{host_q}"),
("SecurityTrails", f"https://securitytrails.com/domain/{host_q}/dns"),
("Wayback", f"https://web.archive.org/web/*/{host_q}/*"),
]
def email_links(email: str) -> list[tuple[str, str]]:
"""Return (label, url) tuples for an email address."""
local, _, domain = email.partition("@")
return [
("mailto", f"mailto:{email}"),
("HIBP", f"https://haveibeenpwned.com/account/{quote(email)}"),
("Hunter (domain)", f"https://hunter.io/search/{quote(domain)}"),
("Epieos", f"https://epieos.com/?q={quote(email)}"),
]
def github_links(org: str, repo: str, sha: str | None) -> list[tuple[str, str]]:
"""Return (label, url) tuples for a GitHub repo, optionally at a commit."""
base = f"https://github.com/{org}/{repo}"
out = [
("Repo", base),
("Commits", f"{base}/commits"),
("Issues", f"{base}/issues"),
("Secret scan (TruffleHog)", "https://github.com/trufflesecurity/trufflehog"),
("GH advisories", f"https://github.com/{org}/{repo}/security/advisories"),
]
if sha:
out.insert(1, (f"Commit {sha[:7]}", f"{base}/commit/{sha}"))
return out
# ──────────────────────────────────────────────────────────────────────────
# Stats container
# ──────────────────────────────────────────────────────────────────────────
@dataclass
class Stats:
"""Counts + unique artifact sets for the enrichment appendix."""
cves: set[str] = field(default_factory=set)
ipv4s: set[str] = field(default_factory=set)
cidrs: set[str] = field(default_factory=set)
hostnames: set[str] = field(default_factory=set)
emails: set[str] = field(default_factory=set)
repos: set[tuple[str, str]] = field(default_factory=set) # (org, repo)
commits: set[tuple[str, str, str]] = field(default_factory=set) # (org, repo, sha)
urls: set[str] = field(default_factory=set)
vendors: set[str] = field(default_factory=set)
def total(self) -> int:
return (
len(self.cves) + len(self.ipv4s) + len(self.cidrs) + len(self.hostnames)
+ len(self.emails) + len(self.repos) + len(self.commits) + len(self.urls)
+ len(self.vendors)
)
def as_summary(self) -> dict[str, int]:
return {
"CVEs": len(self.cves),
"IPv4s": len(self.ipv4s),
"CIDRs": len(self.cidrs),
"Hostnames": len(self.hostnames),
"Emails": len(self.emails),
"GitHub repos": len(self.repos),
"Git commits": len(self.commits),
"URLs": len(self.urls),
"Vendors": len(self.vendors),
}
# ──────────────────────────────────────────────────────────────────────────
# Protect/restore code spans + existing links
# ──────────────────────────────────────────────────────────────────────────
_PLACEHOLDER = "\x00ENRICH{}\x00"
def _stash(text: str, stats: Stats) -> tuple[str, list[str]]:
"""Replace code fences / inline code / existing links with placeholders.
Returns (modified_text, stash) where placeholders look like \\x00ENRICH<i>\\x00
so the rewrite passes won't touch them.
Inline backticks (`x`) get *special* handling: if `x` is a single, clean
artifact (CVE, IP, IPv4/CIDR, email, FQDN, org/repo, URL), the WHOLE
backtick span is replaced with `[\`x\`](url)` so the artifact is clickable
while still rendering as code. Otherwise the span is stashed unchanged.
"""
stash: list[str] = []
def stash_repl(m: re.Match[str]) -> str:
stash.append(m.group(0))
return _PLACEHOLDER.format(len(stash) - 1)
def inline_code_repl(m: re.Match[str]) -> str:
raw = m.group(0) # e.g. "`devcolor/codebenders-datathon`"
inner = raw[1:-1].strip() # strip the backticks
link = _single_artifact_link(inner, stats)
if link is None:
# Plain code — keep the original span out of further passes.
stash.append(raw)
return _PLACEHOLDER.format(len(stash) - 1)
# Replace with a markdown link wrapping the code, then stash the
# whole thing so subsequent passes don't try to re-match the inner.
replacement = f"[{raw}]({link})"
stash.append(replacement)
return _PLACEHOLDER.format(len(stash) - 1)
text = FENCED_BLOCK_RE.sub(stash_repl, text)
text = INLINE_CODE_RE.sub(inline_code_repl, text)
text = EXISTING_LINK_RE.sub(stash_repl, text)
return text, stash
def _single_artifact_link(content: str, stats: Stats) -> str | None:
"""If `content` is exactly one recognisable artifact, return its primary
enrichment URL and update stats. Otherwise return None.
"""
s = content.strip()
if not s:
return None
# CVE
if CVE_RE.fullmatch(s):
cve = s.upper()
stats.cves.add(cve)
return f"https://nvd.nist.gov/vuln/detail/{cve}"
# URL
if URL_RE.fullmatch(s):
url = s.rstrip(".,;:)")
stats.urls.add(url)
host_match = re.match(r"https?://([^/:]+)", url, re.IGNORECASE)
if host_match and _looks_like_hostname(host_match.group(1).lower()):
stats.hostnames.add(host_match.group(1).lower())
return url
# Email
if EMAIL_RE.fullmatch(s):
stats.emails.add(s)
return f"mailto:{s}"
# GitHub org/repo (optionally @sha) — full-string match is strong signal
# that the user intended it as a repo ref.
gh = GH_REPO_RE.fullmatch(s)
if gh:
org, repo, sha = gh.group(1), gh.group(2), gh.group(3)
if _looks_like_repo(org, repo) and "." not in org:
stats.repos.add((org, repo))
base = f"https://github.com/{org}/{repo}"
if sha:
stats.commits.add((org, repo, sha))
return f"{base}/commit/{sha}"
return base
# IPv4 (with optional CIDR)
if IPV4_RE.fullmatch(s):
ip, _, cidr = s.partition("/")
if cidr:
stats.cidrs.add(s)
return f"https://www.shodan.io/search?query=net%3A{quote(s)}"
if _is_public_ipv4(ip):
stats.ipv4s.add(ip)
return f"https://www.shodan.io/host/{ip}"
return None
# Hostname
if HOSTNAME_RE.fullmatch(s) and _looks_like_hostname(s):
host = s.lower()
stats.hostnames.add(host)
return f"https://{host}"
return None
def _restore(text: str, stash: list[str]) -> str:
for i, original in enumerate(stash):
text = text.replace(_PLACEHOLDER.format(i), original)
return text
# ──────────────────────────────────────────────────────────────────────────
# Hostname filtering — skip noisy false positives
# ──────────────────────────────────────────────────────────────────────────
_HOST_SKIP_TLDS = {
# Common file extensions that look like TLDs in our regex.
"md", "txt", "log", "csv", "json", "yml", "yaml", "ini", "cfg",
"py", "js", "ts", "go", "rs", "java", "html", "css", "xml", "sh",
"ps1", "bat", "exe", "dll", "so", "dylib", "png", "jpg", "jpeg",
"gif", "svg", "pdf", "zip", "tar", "gz", "rar", "7z", "iso",
"bak", "old", "tmp", "swp", "db", "sql",
# Common code/JS identifiers mis-detected as TLDs.
"redirect", "then", "catch", "call", "apply", "bind",
"prototype", "constructor", "length", "value", "name",
}
_REPO_SKIP_SUFFIXES = (
".py", ".js", ".ts", ".java", ".rb", ".go", ".rs", ".c", ".cpp",
".h", ".hpp", ".cs", ".php", ".html", ".css", ".json", ".yml",
".yaml", ".xml", ".sh", ".md", ".txt", ".log", ".sql", ".bak",
)
# Recognised gTLDs / common ccTLDs. Anything outside this set OR a 2-letter
# all-alpha ccTLD gets rejected as not-a-real-hostname (catches JS method
# names like jquery.parseHtml, internal aliases like kctcsfin.ps, etc.).
_KNOWN_TLDS = {
# gTLDs
"com", "net", "org", "edu", "gov", "mil", "int", "info", "biz",
"name", "pro", "mobi", "aero", "coop", "museum",
# Modern / brand
"io", "co", "dev", "app", "ai", "cloud", "tech", "xyz", "online",
"site", "store", "shop", "blog", "page", "wiki", "news", "today",
"news", "live", "world", "group", "club", "fun", "studio", "design",
"art", "video", "tv", "fm", "gg", "ly", "sh", "me", "to", "vc",
"cc", "ws", "asia", "jobs", "travel", "post", "tel", "cat",
# Software / infra
"software", "services", "agency", "network", "social", "life",
"academy", "consulting", "solutions", "systems", "support", "ventures",
"finance", "money", "security", "wtf",
}
def _looks_like_hostname(host: str) -> bool:
"""Heuristic: must look like a real internet hostname, not a code
identifier (`jquery.parseHtml`) or internal alias (`kctcsfin.ps`).
"""
host = host.strip(".")
if "." not in host:
return False
parts = host.split(".")
if any(p == "" for p in parts):
return False
tld = parts[-1].lower()
if tld in _HOST_SKIP_TLDS:
return False
if not tld.isalpha():
return False
# TLD must be either a known gTLD/brand OR a 2-letter all-letter ccTLD.
if not (tld in _KNOWN_TLDS or len(tld) == 2):
return False
# Reject if any segment has internal mixed case (camelCase identifier).
for seg in parts:
if re.search(r"[a-z][A-Z]", seg):
return False
return True
def _looks_like_repo(org: str, repo: str) -> bool:
"""Heuristic for GH org/repo refs (used by both inline and backtick passes)."""
if not (re.search(r"[A-Za-z]", org) and re.search(r"[A-Za-z]", repo)):
return False
if org.isdigit() or repo.isdigit():
return False
rl = repo.lower()
if any(rl.endswith(suf) for suf in _REPO_SKIP_SUFFIXES):
return False
return True
def _is_public_ipv4(ip: str) -> bool:
"""True if the IP is globally routable (skip RFC1918, loopback, etc.)."""
try:
addr = ipaddress.IPv4Address(ip)
except (ipaddress.AddressValueError, ValueError):
return False
return not (addr.is_private or addr.is_loopback or addr.is_link_local
or addr.is_multicast or addr.is_unspecified or addr.is_reserved)
# ──────────────────────────────────────────────────────────────────────────
# Inline rewrite passes
# ──────────────────────────────────────────────────────────────────────────
def _rewrite_cves(text: str, stats: Stats) -> str:
def repl(m: re.Match[str]) -> str:
cve = m.group(0).upper()
stats.cves.add(cve)
return f"[{cve}](https://nvd.nist.gov/vuln/detail/{cve})"
return CVE_RE.sub(repl, text)
def _rewrite_ipv4s(text: str, stats: Stats) -> str:
def repl(m: re.Match[str]) -> str:
raw = m.group(0)
ip, _, cidr = raw.partition("/")
if cidr:
stats.cidrs.add(raw)
# Link CIDR to Shodan net: query.
return f"[{raw}](https://www.shodan.io/search?query=net%3A{quote(raw)})"
if not _is_public_ipv4(ip):
return raw
stats.ipv4s.add(ip)
return f"[{ip}](https://www.shodan.io/host/{ip})"
return IPV4_RE.sub(repl, text)
def _rewrite_emails(text: str, stats: Stats) -> str:
def repl(m: re.Match[str]) -> str:
email = m.group(0)
stats.emails.add(email)
return f"[{email}](mailto:{email})"
return EMAIL_RE.sub(repl, text)
def _rewrite_urls(text: str, stats: Stats) -> str:
def repl(m: re.Match[str]) -> str:
url = m.group(0).rstrip(".,;:)")
trailing = m.group(0)[len(url):]
stats.urls.add(url)
# Extract hostname for the appendix too.
host_match = re.match(r"https?://([^/:]+)", url, re.IGNORECASE)
if host_match:
host = host_match.group(1).lower()
if _looks_like_hostname(host):
stats.hostnames.add(host)
return f"[{url}]({url}){trailing}"
return URL_RE.sub(repl, text)
def _rewrite_hostnames(text: str, stats: Stats) -> str:
def repl(m: re.Match[str]) -> str:
host = m.group(0).lower()
if not _looks_like_hostname(host):
return m.group(0)
stats.hostnames.add(host)
return f"[{host}](https://{host})"
return HOSTNAME_RE.sub(repl, text)
def _rewrite_github(text: str, stats: Stats) -> str:
"""Rewrite `org/repo` and `org/repo@sha` refs as GitHub links.
`org/repo` is an extremely ambiguous shape ("voice/data", "OWA/Exchange",
"443/tcp", "Dev/test"). To avoid false positives we require ALL of:
- both segments contain at least one letter
- neither segment is a known protocol/port-style stopword
- both segments contain a hyphen (a strong GitHub-style convention), AND
- a context word (github / git history / repo / commit) appears within
80 chars before the match.
Single-hyphen / no-hyphen refs that are genuine repos (e.g. `shadcn/ui` or
`devcolor/codebenders-datathon`) are expected to appear in backticks in
the source; those are already handled by the inline-code pass in _stash().
"""
org_skip = {"http", "https", "ftp", "mailto", "tcp", "udp", "icmp"}
def repl(m: re.Match[str]) -> str:
org, repo, sha = m.group(1), m.group(2), m.group(3)
if org.lower() in org_skip or repo.lower() in org_skip:
return m.group(0)
if not _looks_like_repo(org, repo):
return m.group(0)
if not ("-" in org and "-" in repo):
return m.group(0)
start = m.start()
window = text[max(0, start - 80):start].lower()
if not re.search(r"github|git history|\bgit \b|\brepo\b|\bcommit\b", window):
return m.group(0)
stats.repos.add((org, repo))
base = f"https://github.com/{org}/{repo}"
if sha:
stats.commits.add((org, repo, sha))
return f"[{org}/{repo}@{sha[:7]}]({base}/commit/{sha})"
return f"[{org}/{repo}]({base})"
return GH_REPO_RE.sub(repl, text)
def _rewrite_vendors(text: str, stats: Stats) -> str:
"""Wrap vendor/product mentions with their docs link (first occurrence only).
To avoid noisy repetition, we only link the FIRST hit per vendor in the
document; subsequent occurrences stay plain text.
"""
seen: set[str] = set()
for pat, display, url in _VENDOR_PATTERNS:
if display in seen:
continue
# Only the first occurrence: use sub with count=1 and skip if already done.
def repl(m: re.Match[str], _disp=display, _url=url) -> str:
stats.vendors.add(_disp)
seen.add(_disp)
return f"[{m.group(0)}]({_url})"
text, n = pat.subn(repl, text, count=1)
if n:
seen.add(display)
return text
# ──────────────────────────────────────────────────────────────────────────
# Appendix builder
# ──────────────────────────────────────────────────────────────────────────
def _link_orphan_commits(text: str, stats: Stats) -> str:
"""After other passes have run, find lines that contain a GitHub repo
link plus one or more unlinked backticked SHAs, and link those SHAs to
that repo's commit URL.
Matches both `[\`abc1234\`]` (already wrapped via single-artifact pass that
failed because no repo context) and a bare ` `abc1234` ` code span.
"""
repo_link_re = re.compile(r"https?://github\.com/([\w.-]+)/([\w.-]+?)(?:[)/#?]|$)")
sha_code_re = re.compile(r"`([0-9a-f]{7,40})`")
out_lines: list[str] = []
for line in text.splitlines(keepends=True):
m = repo_link_re.search(line)
if not m:
out_lines.append(line)
continue
org, repo = m.group(1), m.group(2)
def repl(sm: re.Match[str], _org=org, _repo=repo) -> str:
sha = sm.group(1)
stats.commits.add((_org, _repo, sha))
return f"[`{sha}`](https://github.com/{_org}/{_repo}/commit/{sha})"
out_lines.append(sha_code_re.sub(repl, line))
return "".join(out_lines)
def _format_link_row(label: str, links: list[tuple[str, str]]) -> str:
bits = " · ".join(f"[{name}]({url})" for name, url in links)
return f"- **{label}** — {bits}"
def _build_appendix(stats: Stats) -> str:
if stats.total() == 0:
return ""
lines: list[str] = []
lines.append("\n---\n")
lines.append("## INTEL APPENDIX — Artifact Enrichment\n")
lines.append(
"_Auto-generated by VulnForge enricher. Each artifact below is paired with "
"external recon / threat-intel lookups for rapid pivot._\n"
)
if stats.cves:
lines.append("### CVEs\n")
for cve in sorted(stats.cves):
lines.append(_format_link_row(cve, cve_links(cve)))
lines.append("")
if stats.ipv4s:
lines.append("### IPv4 Hosts\n")
for ip in sorted(stats.ipv4s, key=lambda x: tuple(int(p) for p in x.split("."))):
lines.append(_format_link_row(ip, ipv4_links(ip)))
lines.append("")
if stats.cidrs:
lines.append("### IPv4 Ranges (CIDR)\n")
for cidr in sorted(stats.cidrs):
net_q = quote(cidr)
links = [
("Shodan net:", f"https://www.shodan.io/search?query=net%3A{net_q}"),
("Censys", f"https://search.censys.io/search?resource=hosts&q=ip%3A%5B{quote(cidr.split('/')[0])}%2F{cidr.split('/')[1]}%5D"),
("BGP.tools", f"https://bgp.tools/prefix/{net_q}"),
]
lines.append(_format_link_row(cidr, links))
lines.append("")
if stats.hostnames:
lines.append("### Hostnames / Domains\n")
for host in sorted(stats.hostnames):
lines.append(_format_link_row(host, hostname_links(host)))
lines.append("")
if stats.emails:
lines.append("### Emails\n")
for email in sorted(stats.emails):
lines.append(_format_link_row(email, email_links(email)))
lines.append("")
if stats.repos or stats.commits:
lines.append("### Source Code (GitHub)\n")
# First emit repos; commits get their own bullet under the parent repo.
repo_commits: dict[tuple[str, str], list[str]] = {}
for org, repo, sha in stats.commits:
repo_commits.setdefault((org, repo), []).append(sha)
for org, repo in sorted(stats.repos):
lines.append(_format_link_row(f"{org}/{repo}", github_links(org, repo, None)))
for sha in sorted(repo_commits.get((org, repo), [])):
commit_url = f"https://github.com/{org}/{repo}/commit/{sha}"
lines.append(f" - commit [`{sha[:12]}`]({commit_url})")
# Orphan commits (commit seen but repo wasn't matched) — rare.
for (org, repo), shas in repo_commits.items():
if (org, repo) not in stats.repos:
lines.append(_format_link_row(f"{org}/{repo}", github_links(org, repo, None)))
for sha in sorted(shas):
commit_url = f"https://github.com/{org}/{repo}/commit/{sha}"
lines.append(f" - commit [`{sha[:12]}`]({commit_url})")
lines.append("")
if stats.vendors:
lines.append("### Vendors / Products\n")
for vendor in sorted(stats.vendors):
# Find the URL we used for this vendor.
for pat, disp, url in _VENDOR_PATTERNS:
if disp == vendor:
lines.append(f"- **{vendor}** — [docs]({url})")
break
lines.append("")
return "\n".join(lines)
# ──────────────────────────────────────────────────────────────────────────
# Public entrypoint
# ──────────────────────────────────────────────────────────────────────────
def enrich_markdown(text: str) -> tuple[str, Stats]:
"""Rewrite artifacts inline and append an INTEL APPENDIX.
Code fences, inline code, and existing markdown links are preserved.
Returns the new text and a Stats object describing what was found.
"""
stats = Stats()
body, stash = _stash(text, stats)
# Order matters: URLs before hostnames (URL pass also collects hostnames
# from its URL strings into stats so they appear in the appendix). CVEs
# before any other text replacement so the "CVE-..." literal becomes the
# link text. GitHub repo pass before hostnames so "org/repo" isn't eaten.
passes: list[Callable[[str, Stats], str]] = [
_rewrite_urls,
_rewrite_cves,
_rewrite_emails,
_rewrite_github,
_rewrite_ipv4s,
_rewrite_hostnames,
_rewrite_vendors,
]
for fn in passes:
body = fn(body, stats)
body = _restore(body, stash)
# Post-pass: any backticked commit SHA on a line that also contains a
# github.com/<org>/<repo> link gets rewritten to that commit URL.
body = _link_orphan_commits(body, stats)
appendix = _build_appendix(stats)
return body + appendix, stats
+289
View File
@@ -0,0 +1,289 @@
"""
utils/helpers.py — small, dependency-light utilities.
Contains:
* Severity helpers (label → colour, ordering).
* CVE-ID validation/normalisation.
* Datetime parsing/formatting for NVD timestamps.
* A tiny on-disk JSON cache with TTL for rate-limit friendliness.
* The async SQLite store for bot config + user notes.
"""
from __future__ import annotations
import json
import logging
import re
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import aiosqlite
from config import settings
log = logging.getLogger("vulnforge.helpers")
# CVE IDs look like: CVE-2024-12345 (4-digit year, 4+ digit sequence).
_CVE_RE = re.compile(r"^CVE-\d{4}-\d{4,}$", re.IGNORECASE)
# ──────────────────────────────────────────────────────────────
# Severity helpers
# ──────────────────────────────────────────────────────────────
SEVERITY_ORDER = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "NONE": 0, "UNKNOWN": 0}
def severity_from_score(score: float | None) -> str:
"""Map a CVSS base score (0-10) to a qualitative severity label."""
if score is None:
return "UNKNOWN"
if score >= 9.0:
return "CRITICAL"
if score >= 7.0:
return "HIGH"
if score >= 4.0:
return "MEDIUM"
if score > 0.0:
return "LOW"
return "NONE"
def severity_color(severity: str) -> int:
"""Return the cyberpunk palette colour int for a severity label."""
mapping = {
"CRITICAL": settings.color_critical,
"HIGH": settings.color_high,
"MEDIUM": settings.color_medium,
"LOW": settings.color_low,
"NONE": settings.color_info,
"UNKNOWN": settings.color_dark,
}
return mapping.get(severity.upper(), settings.color_info)
def severity_emoji(severity: str) -> str:
"""A glanceable emoji marker per severity tier."""
return {
"CRITICAL": "🟥",
"HIGH": "🟧",
"MEDIUM": "🟨",
"LOW": "🟦",
"NONE": "",
"UNKNOWN": "",
}.get(severity.upper(), "")
# ──────────────────────────────────────────────────────────────
# CVE-ID + datetime helpers
# ──────────────────────────────────────────────────────────────
def is_valid_cve_id(cve_id: str) -> bool:
"""True when the string matches the canonical CVE-YYYY-NNNN format."""
return bool(_CVE_RE.match(cve_id.strip()))
def normalize_cve_id(cve_id: str) -> str:
"""Upper-case + strip a CVE id for consistent lookups/keys."""
return cve_id.strip().upper()
def parse_nvd_datetime(raw: str | None) -> datetime | None:
"""Parse NVD ISO timestamps (e.g. '2024-05-01T12:00:00.000') as UTC."""
if not raw:
return None
cleaned = raw.replace("Z", "+00:00")
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"):
try:
return datetime.strptime(cleaned, fmt)
except ValueError:
continue
try:
dt = datetime.fromisoformat(cleaned)
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except ValueError:
log.debug("Could not parse NVD datetime: %s", raw)
return None
def discord_timestamp(dt: datetime | None, style: str = "R") -> str:
"""Render a Discord dynamic timestamp tag, or 'N/A' if dt is None."""
if dt is None:
return "N/A"
return f"<t:{int(dt.timestamp())}:{style}>"
def truncate(text: str, limit: int) -> str:
"""Trim text to `limit` chars (Discord field/desc safe), adding an ellipsis."""
if text is None:
return ""
return text if len(text) <= limit else text[: limit - 1].rstrip() + ""
# ──────────────────────────────────────────────────────────────
# JSON file cache with TTL
# ──────────────────────────────────────────────────────────────
class JSONCache:
"""A minimal on-disk JSON cache keyed by filename, with TTL expiry."""
def __init__(self, cache_dir: Path) -> None:
self._dir = cache_dir
self._dir.mkdir(parents=True, exist_ok=True)
def _path(self, key: str) -> Path:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", key)
return self._dir / f"{safe}.json"
def get(self, key: str, ttl: int) -> Any | None:
"""Return cached payload if present and younger than ttl seconds."""
path = self._path(key)
if not path.exists():
return None
try:
if time.time() - path.stat().st_mtime > ttl:
return None
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
log.warning("Cache read failed for %s: %s", key, exc)
return None
def set(self, key: str, payload: Any) -> None:
"""Persist payload to disk (best-effort; failures are logged only)."""
path = self._path(key)
try:
with path.open("w", encoding="utf-8") as fh:
json.dump(payload, fh)
except OSError as exc:
log.warning("Cache write failed for %s: %s", key, exc)
# ──────────────────────────────────────────────────────────────
# SQLite store (config + user notes)
# ──────────────────────────────────────────────────────────────
class Store:
"""
Async SQLite wrapper.
Tables:
guild_config(guild_id PK, digest_channel_id, notify_role_id, updated_at)
cve_notes(id PK, guild_id, user_id, cve_id, note, created_at)
kev_seen(cve_id PK, date_added) -- tracks KEV entries we've recorded
"""
def __init__(self, db_path: Path) -> None:
self._db_path = db_path
async def init(self) -> None:
"""Create tables if they do not exist."""
self._db_path.parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
CREATE TABLE IF NOT EXISTS guild_config (
guild_id INTEGER PRIMARY KEY,
digest_channel_id INTEGER,
notify_role_id INTEGER,
updated_at TEXT
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS cve_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id INTEGER,
user_id INTEGER NOT NULL,
cve_id TEXT NOT NULL,
note TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS kev_seen (
cve_id TEXT PRIMARY KEY,
date_added TEXT
)
"""
)
await db.commit()
log.info("SQLite store initialised at %s", self._db_path)
# ── guild config ─────────────────────────────────────────
async def set_guild_config(
self,
guild_id: int,
digest_channel_id: int | None,
notify_role_id: int | None,
) -> None:
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
INSERT INTO guild_config (guild_id, digest_channel_id, notify_role_id, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(guild_id) DO UPDATE SET
digest_channel_id = excluded.digest_channel_id,
notify_role_id = excluded.notify_role_id,
updated_at = excluded.updated_at
""",
(
guild_id,
digest_channel_id,
notify_role_id,
datetime.now(timezone.utc).isoformat(),
),
)
await db.commit()
async def get_guild_config(self, guild_id: int) -> dict[str, Any] | None:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM guild_config WHERE guild_id = ?", (guild_id,)
) as cur:
row = await cur.fetchone()
return dict(row) if row else None
async def all_guild_configs(self) -> list[dict[str, Any]]:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM guild_config WHERE digest_channel_id IS NOT NULL"
) as cur:
return [dict(r) for r in await cur.fetchall()]
# ── user notes ───────────────────────────────────────────
async def add_note(
self, guild_id: int | None, user_id: int, cve_id: str, note: str
) -> None:
async with aiosqlite.connect(self._db_path) as db:
await db.execute(
"""
INSERT INTO cve_notes (guild_id, user_id, cve_id, note, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
guild_id,
user_id,
normalize_cve_id(cve_id),
note,
datetime.now(timezone.utc).isoformat(),
),
)
await db.commit()
async def get_notes(self, cve_id: str, limit: int = 5) -> list[dict[str, Any]]:
async with aiosqlite.connect(self._db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""
SELECT * FROM cve_notes
WHERE cve_id = ?
ORDER BY created_at DESC
LIMIT ?
""",
(normalize_cve_id(cve_id), limit),
) as cur:
return [dict(r) for r in await cur.fetchall()]