Initial ThreatLens release
This commit is contained in:
Executable
+1
@@ -0,0 +1 @@
|
||||
"""utils package — shared helpers for VulnForge (API client, embeds, helpers)."""
|
||||
Executable
+329
@@ -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"),
|
||||
)
|
||||
Executable
+293
@@ -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)
|
||||
Executable
+728
@@ -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
|
||||
Executable
+289
@@ -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()]
|
||||
Reference in New Issue
Block a user