Initial ThreatLens release
This commit is contained in:
Executable
+29
@@ -0,0 +1,29 @@
|
||||
# ── Secrets & local data (never bake into the image) ──
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
*.log
|
||||
|
||||
# ── Python ──
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# ── Git / CI / docs ──
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
README.md
|
||||
|
||||
# ── OS / editor ──
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VulnForge — Environment variables
|
||||
# Copy this file to ".env" and fill in your own values.
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# REQUIRED: Your Discord bot token (Discord Developer Portal → Bot → Token)
|
||||
DISCORD_TOKEN=your-discord-bot-token-here
|
||||
|
||||
# OPTIONAL: Restrict slash-command sync to a single guild for instant updates.
|
||||
# Leave blank to sync globally (can take up to 1 hour to propagate).
|
||||
GUILD_ID=
|
||||
|
||||
# OPTIONAL: NVD API key. Greatly increases rate limits (50 req / 30s vs 5).
|
||||
# Request one for free: https://nvd.nist.gov/developers/request-an-api-key
|
||||
NVD_API_KEY=
|
||||
|
||||
# OPTIONAL: Default channel ID for the weekly digest. Can also be set via /setup.
|
||||
DIGEST_CHANNEL_ID=
|
||||
|
||||
# OPTIONAL: Role ID to ping on the weekly digest. Can also be set via /setup.
|
||||
NOTIFY_ROLE_ID=
|
||||
|
||||
# OPTIONAL: Logging verbosity (DEBUG, INFO, WARNING, ERROR)
|
||||
LOG_LEVEL=INFO
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VulnForge — production Docker image
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
# Faster, cleaner Python in containers.
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
TZ=UTC
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first to leverage Docker layer caching.
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the rest of the application source.
|
||||
COPY . .
|
||||
|
||||
# Run as a non-root user for security; give it ownership of /app/data
|
||||
# so the SQLite DB + cache are writable (also mounted as a volume).
|
||||
RUN useradd --create-home --uid 10001 vulnforge \
|
||||
&& mkdir -p /app/data/cache \
|
||||
&& chown -R vulnforge:vulnforge /app
|
||||
USER vulnforge
|
||||
|
||||
# Persist SQLite DB + on-disk cache across restarts.
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# Lightweight liveness check: confirms the Python runtime + deps import OK.
|
||||
HEALTHCHECK --interval=5m --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import nextcord, aiohttp, aiosqlite" || exit 1
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,149 @@
|
||||
# ☣ VulnForge
|
||||
|
||||
> **Security Intelligence Discord Bot** — weekly CVE/KEV threat digests and on-demand vulnerability lookups, wrapped in a dark cyberpunk aesthetic.
|
||||
|
||||
VulnForge fuses two authoritative sources — the **NVD CVE API v2** and the **CISA Known Exploited Vulnerabilities (KEV)** catalog — into a single Discord experience built for security researchers and blue/red teams.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Features
|
||||
|
||||
### 1. Automated Weekly Digest
|
||||
Every **Monday @ 09:00 UTC**, VulnForge posts a rich digest to your configured channel:
|
||||
- 🆕 Total new CVEs in the last 7 days
|
||||
- ☣ How many are already in CISA KEV / newly added to KEV
|
||||
- ▶ Top **8** most critical CVEs (sorted by CVSS, color-coded by severity)
|
||||
- 🔗 Direct links to NVD and CISA
|
||||
|
||||
### 2. Slash Commands
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/cve <cve_id>` | Opens a **modal** to pick output sections (details, KEV, refs, CWE) + add a researcher note, then replies with a rich embed. |
|
||||
| `/kev [limit]` | Latest CISA KEV entries (actively exploited in the wild). |
|
||||
| `/weekly` | **Admin-only** — force-post the weekly digest now. |
|
||||
| `/setup` | **Admin-only** — set the digest channel + notification role. |
|
||||
|
||||
### 3. Under the hood
|
||||
- **Nextcord** (slash commands, modals, views)
|
||||
- **aiohttp** with retry/backoff + on-disk **TTL caching** (rate-limit friendly)
|
||||
- **aiosqlite** for guild config + persistent researcher notes
|
||||
- Full **logging** and graceful shutdown
|
||||
- Severity-coded **cyberpunk embeds** (neon reds/oranges/cyans on near-black)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
vulnforge/
|
||||
├── main.py # Entrypoint: boots bot, wires API + store, loads cogs
|
||||
├── config.py # Env + endpoints + colour palette + tunables
|
||||
├── .env.example # Copy to .env and fill in
|
||||
├── requirements.txt
|
||||
├── README.md
|
||||
├── Dockerfile # Hardened, non-root production image
|
||||
├── docker-compose.yml # One-command deploy with persistent volume
|
||||
├── .dockerignore
|
||||
├── cogs/
|
||||
│ ├── __init__.py
|
||||
│ ├── weekly.py # Scheduled digest + /weekly
|
||||
│ └── cve.py # /cve modal, /kev, /setup
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py # NVD + CISA KEV async client (cache + retry)
|
||||
│ ├── embeds.py # Cyberpunk embed builders
|
||||
│ └── helpers.py # Severity maps, validation, JSON cache, SQLite store
|
||||
└── data/ # Caches + SQLite DB (auto-created)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Setup
|
||||
|
||||
### 1. Create a Discord application
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications).
|
||||
2. **New Application** → **Bot** → copy the **token**.
|
||||
3. Under **Installation / OAuth2**, enable the `bot` and `applications.commands` scopes.
|
||||
4. Invite the bot to your server with the **Send Messages** + **Embed Links** permissions.
|
||||
|
||||
### 2. Install
|
||||
```powershell
|
||||
cd vulnforge
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1 # Windows
|
||||
# source .venv/bin/activate # macOS/Linux
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. Configure
|
||||
```powershell
|
||||
copy .env.example .env # cp on macOS/Linux
|
||||
```
|
||||
Edit `.env`:
|
||||
- `DISCORD_TOKEN` — **required**
|
||||
- `GUILD_ID` — optional; set it for **instant** slash-command sync during development
|
||||
- `NVD_API_KEY` — optional but **strongly recommended** ([request one free](https://nvd.nist.gov/developers/request-an-api-key)) to lift rate limits
|
||||
|
||||
### 4. Run
|
||||
```powershell
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 5. Configure in Discord
|
||||
Run `/setup` in your server (needs **Manage Server**) to choose the digest channel and an optional notification role. Then test with `/weekly`.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
VulnForge ships with a hardened image (non-root user, healthcheck, persistent volume). It only makes **outbound** calls, so no ports are exposed.
|
||||
|
||||
### Compose (recommended)
|
||||
```powershell
|
||||
copy .env.example .env # fill in DISCORD_TOKEN
|
||||
docker compose up -d --build
|
||||
docker compose logs -f # tail logs
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
### Plain Docker
|
||||
```powershell
|
||||
docker build -t vulnforge:latest .
|
||||
docker run -d --name vulnforge --restart unless-stopped `
|
||||
--env-file .env `
|
||||
-v vulnforge_data:/app/data `
|
||||
vulnforge:latest
|
||||
```
|
||||
|
||||
The named volume `vulnforge_data` persists the SQLite DB (guild config + researcher notes) and the on-disk API cache across rebuilds. Update `.env` and re-run `docker compose up -d --build` to apply config changes.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Severity Colour Legend
|
||||
|
||||
| Severity | Marker | Colour |
|
||||
|----------|--------|--------|
|
||||
| Critical | 🟥 | Neon Red `#FF003C` |
|
||||
| High | 🟧 | Hot Orange `#FF6B00` |
|
||||
| Medium | 🟨 | Amber `#FFB300` |
|
||||
| Low | 🟦 | Cyan `#00E5FF` |
|
||||
| KEV / Exploited | 🩸 | Ruby `#E0115F` |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Notes & Limits
|
||||
- **NVD rate limits**: 5 requests / 30s without a key, 50 / 30s with one. The weekly sweep paginates the full 7-day window — a key makes it noticeably faster. Responses are cached on disk (`data/cache/`).
|
||||
- **KEV feed** is cached for 6 hours by default (`config.py → kev_cache_ttl`).
|
||||
- Never commit your real `.env` — only `.env.example` belongs in version control.
|
||||
- This tool is for **defensive research and awareness**. Use responsibly.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Customisation
|
||||
- Change the digest day/time in [cogs/weekly.py](cogs/weekly.py#L34) (`RUN_AT` + the Monday guard).
|
||||
- Tune `digest_top_n`, cache TTLs, and the colour palette in [config.py](config.py).
|
||||
- Embed styling lives entirely in [utils/embeds.py](utils/embeds.py).
|
||||
|
||||
---
|
||||
|
||||
*VulnForge // Security Intelligence — built with Nextcord.*
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
docker logs vulnforge 2>&1 | grep -iE 'sync|command|extension|error|exception|guild|cog|loaded' | tail -60
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""cogs package — VulnForge feature modules (weekly digest, CVE lookups)."""
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
cogs/cve.py — interactive CVE lookups + KEV listing + setup.
|
||||
|
||||
Slash commands:
|
||||
/cve <cve_id> → opens a modal letting the user toggle which sections to show,
|
||||
then replies with a rich severity-coloured embed.
|
||||
/kev → latest CISA KEV additions.
|
||||
/setup → admin-only; configure digest channel + notification role.
|
||||
|
||||
The modal pattern: the slash command pre-validates the CVE id, then sends a
|
||||
modal so the user can pick options (full details / KEV status / references /
|
||||
CWE / add a note). On submit we fetch from NVD + KEV and render the embed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import nextcord
|
||||
from nextcord import Interaction, SlashOption
|
||||
from nextcord.ext import commands
|
||||
|
||||
from config import settings
|
||||
from utils.embeds import (
|
||||
build_cve_embed,
|
||||
build_error_embed,
|
||||
build_info_embed,
|
||||
build_kev_list_embed,
|
||||
)
|
||||
from utils.helpers import is_valid_cve_id, normalize_cve_id
|
||||
|
||||
log = logging.getLogger("vulnforge.cogs.cve")
|
||||
|
||||
# Restrict slash sync to a guild when GUILD_ID is set (instant updates in dev).
|
||||
GUILD_IDS = [settings.guild_id] if settings.guild_id else None
|
||||
|
||||
|
||||
class CVEOptionsModal(nextcord.ui.Modal):
|
||||
"""Modal shown after /cve — lets the user choose output sections + a note."""
|
||||
|
||||
def __init__(self, cog: "CVECog", cve_id: str) -> None:
|
||||
super().__init__(title=f"Query {cve_id}", timeout=300)
|
||||
self._cog = cog
|
||||
self._cve_id = cve_id
|
||||
|
||||
# Free-text "options" field — keeps it works-everywhere simple:
|
||||
# comma-separated toggles, defaulting to "all".
|
||||
self.sections = nextcord.ui.TextInput(
|
||||
label="Sections (details, kev, refs, cwe, all)",
|
||||
placeholder="all —or— details, kev, refs",
|
||||
default_value="all",
|
||||
required=False,
|
||||
max_length=100,
|
||||
style=nextcord.TextInputStyle.short,
|
||||
)
|
||||
self.add_item(self.sections)
|
||||
|
||||
# Optional researcher note persisted to SQLite and shown on the embed.
|
||||
self.note = nextcord.ui.TextInput(
|
||||
label="Add a researcher note (optional)",
|
||||
placeholder="e.g. Confirmed exploitable on internal asset X",
|
||||
required=False,
|
||||
max_length=300,
|
||||
style=nextcord.TextInputStyle.paragraph,
|
||||
)
|
||||
self.add_item(self.note)
|
||||
|
||||
async def callback(self, interaction: Interaction) -> None:
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
raw = (self.sections.value or "all").lower()
|
||||
selected = {s.strip() for s in raw.split(",") if s.strip()}
|
||||
want_all = "all" in selected or not selected
|
||||
|
||||
show_details = want_all or "details" in selected
|
||||
show_kev = want_all or "kev" in selected
|
||||
show_refs = want_all or any(k in selected for k in ("refs", "references"))
|
||||
show_cwe = want_all or "cwe" in selected
|
||||
|
||||
api = self._cog.bot.api
|
||||
cve = await api.fetch_cve(self._cve_id)
|
||||
if cve is None:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"CVE Not Found",
|
||||
f"`{self._cve_id}` was not found on NVD, or the API is "
|
||||
"unavailable. Double-check the identifier and try again.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
kev = await api.get_kev_entry(self._cve_id)
|
||||
|
||||
# Persist + collect notes for this CVE.
|
||||
note_text = (self.note.value or "").strip()
|
||||
if note_text:
|
||||
try:
|
||||
await self._cog.bot.store.add_note(
|
||||
interaction.guild_id,
|
||||
interaction.user.id,
|
||||
self._cve_id,
|
||||
note_text,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — best-effort persistence
|
||||
log.warning("Failed saving note for %s: %s", self._cve_id, exc)
|
||||
notes = await self._cog.bot.store.get_notes(self._cve_id)
|
||||
|
||||
embed = build_cve_embed(
|
||||
cve,
|
||||
kev,
|
||||
show_details=show_details,
|
||||
show_kev=show_kev,
|
||||
show_references=show_refs,
|
||||
show_cwe=show_cwe,
|
||||
notes=notes,
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
|
||||
class SetupModal(nextcord.ui.Modal):
|
||||
"""Admin modal to set digest channel + notification role by ID."""
|
||||
|
||||
def __init__(self, cog: "CVECog") -> None:
|
||||
super().__init__(title="VulnForge Setup", timeout=300)
|
||||
self._cog = cog
|
||||
|
||||
self.channel_id = nextcord.ui.TextInput(
|
||||
label="Digest channel ID",
|
||||
placeholder="Right-click a channel → Copy Channel ID",
|
||||
required=True,
|
||||
max_length=25,
|
||||
style=nextcord.TextInputStyle.short,
|
||||
)
|
||||
self.add_item(self.channel_id)
|
||||
|
||||
self.role_id = nextcord.ui.TextInput(
|
||||
label="Notification role ID (optional)",
|
||||
placeholder="Role to ping on the weekly digest",
|
||||
required=False,
|
||||
max_length=25,
|
||||
style=nextcord.TextInputStyle.short,
|
||||
)
|
||||
self.add_item(self.role_id)
|
||||
|
||||
async def callback(self, interaction: Interaction) -> None:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
def _parse(value: str) -> int | None:
|
||||
value = (value or "").strip()
|
||||
return int(value) if value.isdigit() else None
|
||||
|
||||
channel_id = _parse(self.channel_id.value)
|
||||
role_id = _parse(self.role_id.value)
|
||||
|
||||
if channel_id is None:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed("Invalid Channel", "That channel ID is not numeric."),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
channel = interaction.guild.get_channel(channel_id) if interaction.guild else None
|
||||
if channel is None:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Channel Not Found",
|
||||
"I couldn't find that channel in this server. "
|
||||
"Make sure the ID is correct and I can see the channel.",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
await self._cog.bot.store.set_guild_config(
|
||||
interaction.guild_id, channel_id, role_id
|
||||
)
|
||||
role_note = f" • notifying <@&{role_id}>" if role_id else ""
|
||||
await interaction.followup.send(
|
||||
embed=build_info_embed(
|
||||
"Setup Complete",
|
||||
f"Weekly digest will post to {channel.mention}{role_note}.\n"
|
||||
"Use `/threatlens_weekly` to fire a test report.",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
|
||||
class CVECog(commands.Cog):
|
||||
"""Interactive CVE/KEV slash commands."""
|
||||
|
||||
def __init__(self, bot: commands.Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
@nextcord.slash_command(
|
||||
name="threatlens_cve",
|
||||
description="Look up a CVE and choose what details to display.",
|
||||
guild_ids=GUILD_IDS,
|
||||
)
|
||||
async def cve(
|
||||
self,
|
||||
interaction: Interaction,
|
||||
cve_id: str = SlashOption(
|
||||
description="CVE identifier, e.g. CVE-2024-3094",
|
||||
required=True,
|
||||
),
|
||||
) -> None:
|
||||
cve_id = normalize_cve_id(cve_id)
|
||||
if not is_valid_cve_id(cve_id):
|
||||
await interaction.response.send_message(
|
||||
embed=build_error_embed(
|
||||
"Invalid CVE ID",
|
||||
f"`{cve_id}` is not a valid identifier. Expected format: "
|
||||
"`CVE-YYYY-NNNN` (e.g. `CVE-2024-3094`).",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
# Open the options modal (must be the first response to the interaction).
|
||||
await interaction.response.send_modal(CVEOptionsModal(self, cve_id))
|
||||
|
||||
@nextcord.slash_command(
|
||||
name="threatlens_kev",
|
||||
description="Show the latest CISA Known Exploited Vulnerabilities.",
|
||||
guild_ids=GUILD_IDS,
|
||||
)
|
||||
async def kev(
|
||||
self,
|
||||
interaction: Interaction,
|
||||
limit: int = SlashOption(
|
||||
description="How many entries to show (1-15)",
|
||||
required=False,
|
||||
default=10,
|
||||
min_value=1,
|
||||
max_value=15,
|
||||
),
|
||||
) -> None:
|
||||
await interaction.response.defer()
|
||||
entries = await self.bot.api.latest_kev(limit=limit)
|
||||
await interaction.followup.send(embed=build_kev_list_embed(entries, limit))
|
||||
|
||||
@nextcord.slash_command(
|
||||
name="threatlens_setup",
|
||||
description="Admin: configure the weekly digest channel and role.",
|
||||
guild_ids=GUILD_IDS,
|
||||
)
|
||||
async def setup_cmd(self, interaction: Interaction) -> None:
|
||||
perms = interaction.user.guild_permissions if interaction.guild else None
|
||||
if not perms or not perms.manage_guild:
|
||||
await interaction.response.send_message(
|
||||
embed=build_error_embed(
|
||||
"Permission Denied",
|
||||
"You need the **Manage Server** permission to run setup.",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
await interaction.response.send_modal(SetupModal(self))
|
||||
|
||||
|
||||
def setup(bot: commands.Bot) -> None:
|
||||
bot.add_cog(CVECog(bot))
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
cogs/enrich.py — /threatlens_enrich slash command.
|
||||
|
||||
Accepts a markdown report (uploaded as an attachment OR pasted via a follow-up
|
||||
URL parameter), runs it through utils.enricher, and posts the enriched
|
||||
markdown back into the channel as a file attachment along with a stats embed
|
||||
summarising what was found.
|
||||
|
||||
Why a file attachment instead of an embed body?
|
||||
- Discord caps embed descriptions at 4 096 chars and total message at 6 000
|
||||
chars; a real pentest report is 30-150 KB. The enriched output (with all
|
||||
inline links + an INTEL APPENDIX) easily breaks that. A .md attachment
|
||||
preserves every clickable link and is downloadable by the team.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
import nextcord
|
||||
from nextcord import Interaction, SlashOption
|
||||
from nextcord.ext import commands
|
||||
|
||||
from config import settings
|
||||
from utils.embeds import build_error_embed
|
||||
from utils.enricher import enrich_markdown
|
||||
|
||||
log = logging.getLogger("vulnforge.cogs.enrich")
|
||||
|
||||
GUILD_IDS = [settings.guild_id] if settings.guild_id else None
|
||||
|
||||
MAX_INPUT_BYTES = 5 * 1024 * 1024 # 5 MB — protects the bot from huge uploads
|
||||
ALLOWED_EXTS = (".md", ".markdown", ".txt")
|
||||
|
||||
|
||||
def _build_stats_embed(filename: str, byte_count: int, stats_summary: dict[str, int]) -> nextcord.Embed:
|
||||
"""Compact embed showing what the enricher found."""
|
||||
nonzero = {k: v for k, v in stats_summary.items() if v}
|
||||
total = sum(nonzero.values())
|
||||
|
||||
embed = nextcord.Embed(
|
||||
title=f"📎 Report Enriched — `{filename}`",
|
||||
description=(
|
||||
f"**{total}** artifacts linked across **{len(nonzero)}** categories.\n"
|
||||
f"Output size: **{byte_count:,} bytes**.\n"
|
||||
"Every artifact below appears in the report inline as a clickable "
|
||||
"link AND in the new **INTEL APPENDIX** at the bottom of the file "
|
||||
"with full Shodan / Censys / NVD / VirusTotal / GitHub pivots."
|
||||
),
|
||||
color=settings.color_success,
|
||||
)
|
||||
|
||||
if nonzero:
|
||||
# Two columns of artifact categories with counts.
|
||||
items = list(nonzero.items())
|
||||
mid = (len(items) + 1) // 2
|
||||
col_a = "\n".join(f"• **{k}** — `{v}`" for k, v in items[:mid])
|
||||
col_b = "\n".join(f"• **{k}** — `{v}`" for k, v in items[mid:])
|
||||
embed.add_field(name="Artifacts", value=col_a or "—", inline=True)
|
||||
if col_b:
|
||||
embed.add_field(name="\u200b", value=col_b, inline=True)
|
||||
|
||||
embed.set_footer(text="⛓ VulnForge // Report Enricher")
|
||||
embed.timestamp = datetime.now(timezone.utc)
|
||||
return embed
|
||||
|
||||
|
||||
async def _fetch_url(session: aiohttp.ClientSession, url: str) -> tuple[bytes, str]:
|
||||
"""Fetch a remote markdown file. Returns (bytes, derived_filename)."""
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
||||
resp.raise_for_status()
|
||||
raw = await resp.read()
|
||||
# Derive a filename from the URL path; fall back to "report.md".
|
||||
name = url.rsplit("/", 1)[-1].split("?", 1)[0] or "report.md"
|
||||
if not name.lower().endswith(ALLOWED_EXTS):
|
||||
name = name + ".md"
|
||||
return raw, name
|
||||
|
||||
|
||||
def _parse_message_ref(ref: str) -> int | None:
|
||||
"""Accept a numeric ID or a full Discord message link and return the id."""
|
||||
ref = ref.strip()
|
||||
if ref.isdigit():
|
||||
return int(ref)
|
||||
# discord.com/channels/<guild>/<channel>/<message>
|
||||
parts = ref.rstrip("/").split("/")
|
||||
if len(parts) >= 1 and parts[-1].isdigit():
|
||||
return int(parts[-1])
|
||||
return None
|
||||
|
||||
|
||||
def _message_to_markdown(msg: nextcord.Message) -> str:
|
||||
"""Flatten a Discord message (content + embeds) into a markdown blob.
|
||||
|
||||
Embeds become `## title\n description\n ### field name\n field value`,
|
||||
so the enricher sees every CVE / IP / URL the bot itself rendered.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
if msg.content:
|
||||
chunks.append(msg.content)
|
||||
for emb in msg.embeds:
|
||||
if emb.title:
|
||||
chunks.append(f"## {emb.title}")
|
||||
if emb.url:
|
||||
chunks.append(emb.url)
|
||||
if emb.description:
|
||||
chunks.append(emb.description)
|
||||
for f in emb.fields:
|
||||
if f.name and f.name not in ("\u200b",):
|
||||
chunks.append(f"### {f.name}")
|
||||
if f.value:
|
||||
chunks.append(f.value)
|
||||
if emb.footer and emb.footer.text:
|
||||
chunks.append(f"_{emb.footer.text}_")
|
||||
return "\n\n".join(chunks)
|
||||
|
||||
|
||||
class EnrichCog(commands.Cog):
|
||||
"""Markdown report enrichment via slash command."""
|
||||
|
||||
def __init__(self, bot: commands.Bot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
@nextcord.slash_command(
|
||||
name="threatlens_enrich",
|
||||
description="Enrich a pentest markdown report with clickable artifact / PoC / source links.",
|
||||
guild_ids=GUILD_IDS,
|
||||
)
|
||||
async def enrich(
|
||||
self,
|
||||
interaction: Interaction,
|
||||
file: nextcord.Attachment | None = SlashOption(
|
||||
name="file",
|
||||
description="Markdown report (.md / .txt) to enrich.",
|
||||
required=False,
|
||||
),
|
||||
url: str | None = SlashOption(
|
||||
name="url",
|
||||
description="Or fetch a public raw-markdown URL (e.g. GitHub raw, gist).",
|
||||
required=False,
|
||||
),
|
||||
message: str | None = SlashOption(
|
||||
name="message",
|
||||
description="Or enrich a prior message: paste its ID or full Discord link.",
|
||||
required=False,
|
||||
),
|
||||
recent: bool | None = SlashOption(
|
||||
name="recent",
|
||||
description="Or auto-grab my last bot message in this channel and enrich it.",
|
||||
required=False,
|
||||
),
|
||||
) -> None:
|
||||
await interaction.response.defer(ephemeral=False)
|
||||
|
||||
if not file and not url and not message and not recent:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Nothing to enrich",
|
||||
"Pick one source: attach a `file`, pass a `url`, pass a "
|
||||
"`message` ID/link, or set `recent: true` to enrich the "
|
||||
"most recent bot message in this channel.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Resolve the source bytes + a filename.
|
||||
try:
|
||||
if file:
|
||||
if file.size and file.size > MAX_INPUT_BYTES:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"File too large",
|
||||
f"`{file.filename}` is {file.size:,} bytes; the limit "
|
||||
f"is {MAX_INPUT_BYTES:,}.",
|
||||
),
|
||||
)
|
||||
return
|
||||
if file.filename and not file.filename.lower().endswith(ALLOWED_EXTS):
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Unsupported file type",
|
||||
f"`{file.filename}` is not a markdown file. "
|
||||
f"Allowed extensions: {', '.join(ALLOWED_EXTS)}.",
|
||||
),
|
||||
)
|
||||
return
|
||||
raw = await file.read()
|
||||
src_name = file.filename or "report.md"
|
||||
elif message or recent:
|
||||
channel = interaction.channel
|
||||
if channel is None:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"No channel",
|
||||
"Could not resolve the channel for this interaction.",
|
||||
),
|
||||
)
|
||||
return
|
||||
target_msg: nextcord.Message | None = None
|
||||
if message:
|
||||
mid = _parse_message_ref(message)
|
||||
if mid is None:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Bad message reference",
|
||||
"Pass a numeric message ID or a full Discord "
|
||||
"message link (`discord.com/channels/.../.../<id>`).",
|
||||
),
|
||||
)
|
||||
return
|
||||
target_msg = await channel.fetch_message(mid)
|
||||
else:
|
||||
# recent=True — find the most recent bot-authored message
|
||||
# in this channel that has content or embeds.
|
||||
me = self.bot.user
|
||||
async for m in channel.history(limit=25):
|
||||
if me and m.author.id == me.id and (m.content or m.embeds):
|
||||
# skip the interaction's own deferred response if
|
||||
# discord happens to surface it; we want command output.
|
||||
target_msg = m
|
||||
break
|
||||
if target_msg is None:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"No recent bot message",
|
||||
"Couldn't find a recent VulnForge message in this "
|
||||
"channel to enrich. Run a `/threatlens_*` command first.",
|
||||
),
|
||||
)
|
||||
return
|
||||
md = _message_to_markdown(target_msg)
|
||||
if not md.strip():
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Empty message",
|
||||
"That message has no text or embed content to enrich.",
|
||||
),
|
||||
)
|
||||
return
|
||||
raw = md.encode("utf-8")
|
||||
src_name = f"discord-msg-{target_msg.id}.md"
|
||||
else:
|
||||
# Reuse the bot's existing aiohttp session if present so we
|
||||
# don't open another connection pool per call.
|
||||
session = getattr(getattr(self.bot, "api", None), "_session", None)
|
||||
if session is None or session.closed:
|
||||
async with aiohttp.ClientSession() as fresh:
|
||||
raw, src_name = await _fetch_url(fresh, url) # type: ignore[arg-type]
|
||||
else:
|
||||
raw, src_name = await _fetch_url(session, url) # type: ignore[arg-type]
|
||||
|
||||
if len(raw) > MAX_INPUT_BYTES:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Fetched file too large",
|
||||
f"Fetched {len(raw):,} bytes from the URL; the limit "
|
||||
f"is {MAX_INPUT_BYTES:,}.",
|
||||
),
|
||||
)
|
||||
return
|
||||
except aiohttp.ClientError as exc:
|
||||
log.warning("URL fetch failed: %s", exc)
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed("Fetch failed", f"`{exc}`"),
|
||||
)
|
||||
return
|
||||
except Exception: # noqa: BLE001 — surface any unexpected error to chat
|
||||
log.exception("enrich: failed to read source")
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed("Read failed", "Could not read the input."),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Decode failed",
|
||||
"Could not decode the file as UTF-8. Make sure it is a text/markdown file.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
enriched, stats = enrich_markdown(text)
|
||||
except Exception: # noqa: BLE001 — never crash the bot on bad input
|
||||
log.exception("enrich: enrichment pass crashed")
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"Enrichment failed",
|
||||
"The enrichment pass threw an unexpected error. Check the bot logs.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# Build output filename: foo.md -> foo.enriched.md
|
||||
stem = src_name.rsplit(".", 1)[0] if "." in src_name else src_name
|
||||
out_name = f"{stem}.enriched.md"
|
||||
|
||||
out_bytes = enriched.encode("utf-8")
|
||||
buf = io.BytesIO(out_bytes)
|
||||
attachment = nextcord.File(buf, filename=out_name)
|
||||
|
||||
embed = _build_stats_embed(out_name, len(out_bytes), stats.as_summary())
|
||||
|
||||
await interaction.followup.send(embed=embed, file=attachment)
|
||||
|
||||
|
||||
def setup(bot: commands.Bot) -> None:
|
||||
bot.add_cog(EnrichCog(bot))
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
cogs/weekly.py — automated weekly threat digest.
|
||||
|
||||
A background loop fires every Monday at 09:00 UTC and posts a rich digest to
|
||||
each configured guild channel:
|
||||
* total new CVEs (last 7 days)
|
||||
* how many are already in CISA KEV / newly added to KEV
|
||||
* the top 8 most critical CVEs by CVSS
|
||||
* source links
|
||||
|
||||
Admins can force a run with /weekly. The schedule uses nextcord.ext.tasks with
|
||||
an explicit UTC time, and a guard so it only sends on Mondays.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
import nextcord
|
||||
from nextcord import Interaction
|
||||
from nextcord.ext import commands, tasks
|
||||
|
||||
from config import settings
|
||||
from utils.api import CVERecord, KEVEntry
|
||||
from utils.embeds import build_error_embed, build_info_embed, build_weekly_embed
|
||||
from utils.helpers import SEVERITY_ORDER
|
||||
|
||||
log = logging.getLogger("vulnforge.cogs.weekly")
|
||||
|
||||
GUILD_IDS = [settings.guild_id] if settings.guild_id else None
|
||||
|
||||
# 09:00 UTC trigger. The loop runs every 24h aligned to this time; we then
|
||||
# check the weekday so it only actually posts on Mondays.
|
||||
RUN_AT = dt.time(hour=9, minute=0, tzinfo=dt.timezone.utc)
|
||||
|
||||
|
||||
class WeeklyCog(commands.Cog):
|
||||
"""Scheduled weekly digest + manual trigger."""
|
||||
|
||||
def __init__(self, bot: commands.Bot) -> None:
|
||||
self.bot = bot
|
||||
self.weekly_digest.start()
|
||||
|
||||
def cog_unload(self) -> None:
|
||||
self.weekly_digest.cancel()
|
||||
|
||||
# ── scheduled loop ───────────────────────────────────────
|
||||
@tasks.loop(time=RUN_AT)
|
||||
async def weekly_digest(self) -> None:
|
||||
# Only fire on Mondays (weekday() == 0).
|
||||
if dt.datetime.now(dt.timezone.utc).weekday() != 0:
|
||||
return
|
||||
log.info("Weekly digest schedule triggered (Monday 09:00 UTC)")
|
||||
await self._run_for_all_guilds()
|
||||
|
||||
@weekly_digest.before_loop
|
||||
async def _before(self) -> None:
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
# ── data assembly ────────────────────────────────────────
|
||||
async def _build_digest_embed(self, days: int = 7) -> nextcord.Embed:
|
||||
api = self.bot.api
|
||||
recent: list[CVERecord] = await api.fetch_recent_cves(days=days)
|
||||
newly_added_kev: list[KEVEntry] = await api.kev_added_since(days=days)
|
||||
|
||||
# Count how many recent CVEs are present in KEV.
|
||||
in_kev = 0
|
||||
for cve in recent:
|
||||
if await api.is_in_kev(cve.cve_id):
|
||||
in_kev += 1
|
||||
|
||||
# Top N by CVSS score (then by severity rank as tiebreaker).
|
||||
scored = [c for c in recent if c.cvss_score is not None]
|
||||
scored.sort(
|
||||
key=lambda c: (c.cvss_score or 0.0, SEVERITY_ORDER.get(c.cvss_severity, 0)),
|
||||
reverse=True,
|
||||
)
|
||||
top = scored[: settings.digest_top_n]
|
||||
|
||||
return build_weekly_embed(
|
||||
total_new=len(recent),
|
||||
in_kev_count=in_kev,
|
||||
newly_added_kev=newly_added_kev,
|
||||
top_cves=top,
|
||||
days=days,
|
||||
)
|
||||
|
||||
async def _run_for_all_guilds(self) -> None:
|
||||
embed = await self._build_digest_embed()
|
||||
configs = await self.bot.store.all_guild_configs()
|
||||
|
||||
# Fall back to env-configured channel if no guild has run /setup.
|
||||
if not configs and settings.digest_channel_id:
|
||||
configs = [
|
||||
{
|
||||
"digest_channel_id": settings.digest_channel_id,
|
||||
"notify_role_id": settings.notify_role_id,
|
||||
}
|
||||
]
|
||||
|
||||
for cfg in configs:
|
||||
channel = self.bot.get_channel(cfg["digest_channel_id"])
|
||||
if channel is None:
|
||||
log.warning("Digest channel %s not found/visible", cfg["digest_channel_id"])
|
||||
continue
|
||||
content = f"<@&{cfg['notify_role_id']}>" if cfg.get("notify_role_id") else None
|
||||
try:
|
||||
await channel.send(content=content, embed=embed)
|
||||
log.info("Weekly digest posted to channel %s", channel.id)
|
||||
except nextcord.DiscordException as exc:
|
||||
log.error("Failed posting digest to %s: %s", channel.id, exc)
|
||||
|
||||
# ── admin command ────────────────────────────────────────
|
||||
@nextcord.slash_command(
|
||||
name="threatlens_weekly",
|
||||
description="Admin: force-post the weekly threat digest now.",
|
||||
guild_ids=GUILD_IDS,
|
||||
)
|
||||
async def weekly(self, interaction: Interaction) -> None:
|
||||
perms = interaction.user.guild_permissions if interaction.guild else None
|
||||
if not perms or not perms.manage_guild:
|
||||
await interaction.response.send_message(
|
||||
embed=build_error_embed(
|
||||
"Permission Denied",
|
||||
"You need the **Manage Server** permission to force a digest.",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
embed = await self._build_digest_embed()
|
||||
|
||||
cfg = await self.bot.store.get_guild_config(interaction.guild_id)
|
||||
target_id = (cfg or {}).get("digest_channel_id") or settings.digest_channel_id
|
||||
role_id = (cfg or {}).get("notify_role_id") or settings.notify_role_id
|
||||
|
||||
channel = self.bot.get_channel(target_id) if target_id else interaction.channel
|
||||
if channel is None:
|
||||
await interaction.followup.send(
|
||||
embed=build_error_embed(
|
||||
"No Channel Configured",
|
||||
"Run `/setup` first to choose a digest channel.",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
content = f"<@&{role_id}>" if role_id else None
|
||||
await channel.send(content=content, embed=embed)
|
||||
await interaction.followup.send(
|
||||
embed=build_info_embed("Digest Sent", f"Weekly digest posted to {channel.mention}."),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
|
||||
def setup(bot: commands.Bot) -> None:
|
||||
bot.add_cog(WeeklyCog(bot))
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
config.py — Centralised configuration for VulnForge.
|
||||
|
||||
Loads environment variables, defines API endpoints, severity colour maps,
|
||||
cache TTLs and other tunables. Import `settings` everywhere instead of reading
|
||||
os.environ directly so configuration stays in one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from the project root (the directory this file lives in).
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
def _get_int(name: str) -> int | None:
|
||||
"""Parse an optional integer env var, returning None when unset/blank."""
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Immutable runtime settings sourced from environment + defaults."""
|
||||
|
||||
# ── Discord ──────────────────────────────────────────────
|
||||
discord_token: str = os.getenv("DISCORD_TOKEN", "").strip()
|
||||
guild_id: int | None = _get_int("GUILD_ID")
|
||||
digest_channel_id: int | None = _get_int("DIGEST_CHANNEL_ID")
|
||||
notify_role_id: int | None = _get_int("NOTIFY_ROLE_ID")
|
||||
|
||||
# ── External APIs ────────────────────────────────────────
|
||||
nvd_api_key: str = os.getenv("NVD_API_KEY", "").strip()
|
||||
nvd_base_url: str = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
||||
cisa_kev_url: str = (
|
||||
"https://www.cisa.gov/sites/default/files/feeds/"
|
||||
"known_exploited_vulnerabilities.json"
|
||||
)
|
||||
nvd_cve_web: str = "https://nvd.nist.gov/vuln/detail"
|
||||
cisa_kev_web: str = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────
|
||||
data_dir: Path = PROJECT_ROOT / "data"
|
||||
db_path: Path = PROJECT_ROOT / "data" / "vulnforge.db"
|
||||
cache_dir: Path = PROJECT_ROOT / "data" / "cache"
|
||||
|
||||
# ── Behaviour ────────────────────────────────────────────
|
||||
log_level: str = os.getenv("LOG_LEVEL", "INFO").strip().upper()
|
||||
# Cache TTL (seconds): KEV feed is large + slow-changing.
|
||||
kev_cache_ttl: int = 60 * 60 * 6 # 6 hours
|
||||
cve_cache_ttl: int = 60 * 30 # 30 minutes
|
||||
http_timeout: int = 30 # seconds per request
|
||||
digest_top_n: int = 8 # CVEs shown in weekly digest
|
||||
request_retries: int = 3 # NVD transient retry attempts
|
||||
|
||||
# ── Cyberpunk / hacker colour palette (hex → int) ─────────
|
||||
# Severity-coded so embeds read at a glance on mobile + desktop.
|
||||
color_critical: int = 0xFF003C # neon red
|
||||
color_high: int = 0xFF6B00 # hot orange
|
||||
color_medium: int = 0xFFB300 # amber
|
||||
color_low: int = 0x00E5FF # cyan
|
||||
color_info: int = 0x0A84FF # electric blue
|
||||
color_kev: int = 0xE0115F # ruby (actively exploited)
|
||||
color_dark: int = 0x0D1117 # near-black backdrop accent
|
||||
color_success: int = 0x00FF9C # matrix green
|
||||
user_agent: str = "VulnForge/1.0 (+https://github.com/your-org/vulnforge)"
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
"""Create data/cache directories if they do not yet exist."""
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
echo "=== GLOBAL COMMANDS ==="
|
||||
bash /tmp/diag.sh 2>&1 | sed -n '/GLOBAL COMMANDS/,/GUILD COMMANDS/p'
|
||||
echo
|
||||
echo "=== GUILD COMMANDS ==="
|
||||
bash /tmp/diag.sh 2>&1 | sed -n '/GUILD COMMANDS/,/BOT MEMBER/p'
|
||||
echo
|
||||
echo "=== inspect bot internals ==="
|
||||
docker exec vulnforge python -c "
|
||||
import sys; sys.path.insert(0, '/app')
|
||||
from main import VulnForge
|
||||
b = VulnForge()
|
||||
b.load_extension('cogs.cve')
|
||||
b.load_extension('cogs.weekly')
|
||||
print('Before manual add:')
|
||||
print(' get_application_commands():', len(b.get_application_commands()))
|
||||
print(' get_all_application_commands():', len(list(b.get_all_application_commands())))
|
||||
print(' _application_commands_to_add:', b._application_commands_to_add)
|
||||
print()
|
||||
# Manual add
|
||||
for cog_name, cog in b.cogs.items():
|
||||
for cmd in getattr(cog, 'application_commands', []):
|
||||
cmd.guild_ids_to_rollout.add(1502477191093420135)
|
||||
b.add_application_command(cmd, use_rollout=True)
|
||||
print('After manual add:')
|
||||
print(' get_application_commands():', len(b.get_application_commands()))
|
||||
print(' get_all_application_commands():', len(list(b.get_all_application_commands())))
|
||||
print(' _application_commands_to_add:', len(b._application_commands_to_add) if b._application_commands_to_add else 0)
|
||||
print()
|
||||
print('Commands details:')
|
||||
for c in b.get_all_application_commands():
|
||||
print(' -', c.name, 'guild_ids_to_rollout:', c.guild_ids_to_rollout, 'is_global:', c.is_global)
|
||||
"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd ~/vulnforge
|
||||
docker compose up -d --build 2>&1 | tail -4
|
||||
sleep 18
|
||||
echo "=== bot logs ==="
|
||||
docker logs vulnforge 2>&1 | grep -E 'vulnforge .' | head -30
|
||||
echo
|
||||
echo "=== diag ==="
|
||||
bash /tmp/diag.sh | tail -40
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Diagnose why VulnForge slash commands aren't showing in Discord.
|
||||
cd ~/vulnforge
|
||||
# Strip Windows CRLF before sourcing .env (otherwise vars carry trailing \r).
|
||||
sed -i 's/\r$//' .env
|
||||
set -a; . ./.env; set +a
|
||||
AUTH="Authorization: Bot $DISCORD_TOKEN"
|
||||
APP_ID=$(curl -s -H "$AUTH" https://discord.com/api/v10/oauth2/applications/@me | python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
||||
echo "=== APP_ID=$APP_ID GUILD_ID=$GUILD_ID ==="
|
||||
echo
|
||||
echo "=== BOT GUILDS ==="
|
||||
curl -s -H "$AUTH" https://discord.com/api/v10/users/@me/guilds | python3 -m json.tool
|
||||
echo
|
||||
echo "=== GLOBAL COMMANDS ==="
|
||||
curl -s -H "$AUTH" "https://discord.com/api/v10/applications/$APP_ID/commands" | python3 -m json.tool
|
||||
echo
|
||||
echo "=== GUILD COMMANDS for $GUILD_ID ==="
|
||||
curl -s -H "$AUTH" "https://discord.com/api/v10/applications/$APP_ID/guilds/$GUILD_ID/commands" | python3 -m json.tool
|
||||
echo
|
||||
echo "=== BOT MEMBER IN GUILD ==="
|
||||
curl -s -H "$AUTH" "https://discord.com/api/v10/guilds/$GUILD_ID/members/$APP_ID" | python3 -m json.tool
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VulnForge — docker compose
|
||||
# Usage:
|
||||
# 1. copy .env.example .env (and fill in DISCORD_TOKEN)
|
||||
# 2. docker compose up -d --build
|
||||
# 3. docker compose logs -f
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
services:
|
||||
vulnforge:
|
||||
build: .
|
||||
image: vulnforge:latest
|
||||
container_name: vulnforge
|
||||
restart: unless-stopped
|
||||
# Loads DISCORD_TOKEN, NVD_API_KEY, GUILD_ID, etc. from .env
|
||||
env_file:
|
||||
- .env
|
||||
# Persist SQLite DB + cached API responses across container rebuilds.
|
||||
volumes:
|
||||
- vulnforge_data:/app/data
|
||||
# No inbound ports needed — the bot only makes outbound calls
|
||||
# (Discord gateway, NVD, CISA).
|
||||
|
||||
volumes:
|
||||
vulnforge_data:
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Inspect what slash commands nextcord actually knows about.
|
||||
docker exec vulnforge python -c "
|
||||
import sys; sys.path.insert(0, '/app')
|
||||
from main import VulnForge
|
||||
bot = VulnForge()
|
||||
# Manually load the extensions so we can see the result.
|
||||
bot.load_extension('cogs.cve')
|
||||
bot.load_extension('cogs.weekly')
|
||||
print('=== get_application_commands ===')
|
||||
for c in bot.get_application_commands():
|
||||
print(' -', type(c).__name__, repr(getattr(c, 'name', None)), 'guild_ids=', getattr(c, 'guild_ids', None))
|
||||
print()
|
||||
print('=== cogs ===')
|
||||
for name, cog in bot.cogs.items():
|
||||
print(' -', name, 'application_commands:', list(getattr(cog, 'application_commands', [])))
|
||||
print()
|
||||
print('=== _application_commands_to_add (internal) ===')
|
||||
internal = getattr(bot, '_application_commands_to_add', None) or getattr(bot, '_pending_application_commands', None)
|
||||
print(' ', internal)
|
||||
print()
|
||||
print('=== all attributes containing \"command\" ===')
|
||||
for a in dir(bot):
|
||||
if 'command' in a.lower() and not a.startswith('__'):
|
||||
print(' -', a)
|
||||
"
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
main.py — VulnForge entrypoint.
|
||||
|
||||
Boots the Nextcord bot, wires up the shared SecurityAPI client + SQLite store,
|
||||
loads cogs, configures logging, and handles graceful startup/shutdown.
|
||||
|
||||
Run:
|
||||
python main.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
|
||||
import nextcord
|
||||
from nextcord.ext import commands
|
||||
|
||||
from config import settings
|
||||
from utils.api import SecurityAPI
|
||||
from utils.helpers import Store
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Logging
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level, logging.INFO),
|
||||
format="%(asctime)s │ %(levelname)-8s │ %(name)s │ %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
log = logging.getLogger("vulnforge")
|
||||
|
||||
|
||||
class VulnForge(commands.Bot):
|
||||
"""The VulnForge bot — owns the shared API client and data store."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
intents = nextcord.Intents.default()
|
||||
# When GUILD_ID is set we scope ALL commands to that guild so they appear
|
||||
# instantly (and we don't depend on Nextcord auto-discovering guild_ids
|
||||
# from individual decorators, which was failing to push the guild sync).
|
||||
kwargs: dict = {"intents": intents}
|
||||
if settings.guild_id:
|
||||
kwargs["default_guild_ids"] = [settings.guild_id]
|
||||
super().__init__(**kwargs)
|
||||
self.api = SecurityAPI()
|
||||
self.store = Store(settings.db_path)
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
log.info("Logged in as %s (id: %s)", self.user, self.user.id)
|
||||
log.info("Connected to %s guild(s)", len(self.guilds))
|
||||
# Force a fresh sync so command renames/additions show up immediately.
|
||||
try:
|
||||
if settings.guild_id:
|
||||
await self.sync_application_commands(guild_id=settings.guild_id)
|
||||
log.info("Slash commands synced to guild %s", settings.guild_id)
|
||||
else:
|
||||
await self.sync_application_commands()
|
||||
log.info("Slash commands synced globally")
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("sync_application_commands failed")
|
||||
await self.change_presence(
|
||||
activity=nextcord.Activity(
|
||||
type=nextcord.ActivityType.watching,
|
||||
name="the CVE & KEV feeds ☣",
|
||||
)
|
||||
)
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Load extensions and register cog commands BEFORE we connect.
|
||||
|
||||
We can't use Nextcord's `setup_hook` here — in 2.6 it isn't invoked by
|
||||
bot.start() in this configuration, so cogs never got loaded and the
|
||||
bot's command list stayed empty (sync was a no-op). Call this manually
|
||||
from the runner before bot.start().
|
||||
"""
|
||||
settings.ensure_dirs()
|
||||
await self.store.init()
|
||||
await self.api.start()
|
||||
|
||||
for ext in ("cogs.cve", "cogs.weekly", "cogs.enrich"):
|
||||
try:
|
||||
self.load_extension(ext)
|
||||
log.info("Loaded extension: %s", ext)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("Failed to load extension %s", ext)
|
||||
|
||||
# Nextcord 2.6 quirk: add_cog() attaches slash commands to the cog
|
||||
# object (cog.application_commands) but does NOT push them into the
|
||||
# bot's registration list. add_all_cog_commands() also no-ops here.
|
||||
# Manually push each cog command onto the bot so sync sees them.
|
||||
for cog_name, cog in self.cogs.items():
|
||||
count = 0
|
||||
for cmd in getattr(cog, "application_commands", []):
|
||||
if settings.guild_id and not cmd.guild_ids_to_rollout:
|
||||
cmd.guild_ids_to_rollout.add(settings.guild_id)
|
||||
self.add_application_command(cmd, use_rollout=True)
|
||||
count += 1
|
||||
log.info("Registered %d command(s) from cog %s", count, cog_name)
|
||||
# get_all_application_commands() returns ALL commands (guild + global);
|
||||
# get_application_commands() only returns globals so it's misleading here.
|
||||
log.info(
|
||||
"Total application commands on bot: %d",
|
||||
len(list(self.get_all_application_commands())),
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
log.info("Shutting down — closing API session…")
|
||||
await self.api.close()
|
||||
await super().close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not settings.discord_token:
|
||||
log.error("DISCORD_TOKEN is not set. Copy .env.example to .env and fill it in.")
|
||||
sys.exit(1)
|
||||
|
||||
async def runner() -> None:
|
||||
# IMPORTANT: construct the bot *inside* the running loop. Nextcord binds
|
||||
# its gateway/heartbeat to the active event loop; building it before the
|
||||
# loop exists causes a loop mismatch where the heartbeat is scheduled on
|
||||
# a dead loop ("heartbeat blocked for N seconds" while the loop sits idle
|
||||
# in selector.poll). Creating it here guarantees a single, correct loop.
|
||||
bot = VulnForge()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _handle_signal(*_args: object) -> None:
|
||||
log.info("Signal received — stopping bot…")
|
||||
loop.create_task(bot.close())
|
||||
|
||||
# Graceful Ctrl+C / SIGTERM (e.g. `docker stop`) handling.
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, _handle_signal)
|
||||
except NotImplementedError:
|
||||
# add_signal_handler is unavailable on Windows; KeyboardInterrupt covers SIGINT.
|
||||
pass
|
||||
|
||||
# Nextcord's Bot is not an async context manager (unlike discord.py 2.x),
|
||||
# so start it directly and ensure a clean close on exit.
|
||||
try:
|
||||
# Load extensions + register cog commands BEFORE connecting. Doing
|
||||
# this in setup_hook didn't run in this Nextcord version.
|
||||
await bot.setup()
|
||||
await bot.start(settings.discord_token)
|
||||
finally:
|
||||
if not bot.is_closed():
|
||||
await bot.close()
|
||||
|
||||
try:
|
||||
asyncio.run(runner())
|
||||
except KeyboardInterrupt:
|
||||
log.info("KeyboardInterrupt — exiting.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# VulnForge — Python dependencies
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
nextcord>=2.6.0 # Modern Discord API wrapper (modals, slash, views)
|
||||
aiohttp>=3.9.0 # Async HTTP client for NVD / CISA feeds
|
||||
aiosqlite>=0.20.0 # Async SQLite for config + user notes
|
||||
python-dotenv>=1.0.1 # Load secrets from .env
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
"""scripts/ — VulnForge utility scripts (CLI wrappers)."""
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
scripts/enrich_report.py — CLI wrapper for the markdown enricher.
|
||||
|
||||
Usage:
|
||||
python -m scripts.enrich_report <input.md> [-o <output.md>]
|
||||
|
||||
If `-o` is omitted, writes alongside the input as `<name>.enriched.md`.
|
||||
Prints a one-line summary of artifact counts to stdout on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow running both as `python -m scripts.enrich_report` and as a plain
|
||||
# script from the vulnforge/ folder.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from utils.enricher import enrich_markdown # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="Enrich a markdown report with clickable artifact links.")
|
||||
p.add_argument("input", type=Path, help="Path to the input markdown file.")
|
||||
p.add_argument(
|
||||
"-o", "--output", type=Path, default=None,
|
||||
help="Output path. Defaults to <input>.enriched.md alongside the input.",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
src: Path = args.input
|
||||
if not src.is_file():
|
||||
print(f"error: {src} does not exist or is not a file", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
dst: Path = args.output or src.with_suffix(".enriched.md")
|
||||
text = src.read_text(encoding="utf-8")
|
||||
enriched, stats = enrich_markdown(text)
|
||||
dst.write_text(enriched, encoding="utf-8")
|
||||
|
||||
summary = ", ".join(f"{k}={v}" for k, v in stats.as_summary().items() if v)
|
||||
print(f"wrote {dst} ({summary or 'no artifacts found'})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
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()]
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
echo "=== main.py contains add_all_cog_commands? ==="
|
||||
docker exec vulnforge grep -n add_all_cog_commands /app/main.py
|
||||
echo
|
||||
echo "=== relevant log entries ==="
|
||||
docker logs vulnforge 2>&1 | grep -iE 'loaded|sync|locally|extension|exception|error' | tail -40
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
echo "=== Full vulnforge INFO logs ==="
|
||||
docker logs vulnforge 2>&1 | grep -E 'vulnforge' | head -30
|
||||
echo
|
||||
echo "=== Check if 'setup_hook' string appears anywhere ==="
|
||||
docker logs vulnforge 2>&1 | grep -i setup_hook | head -10
|
||||
echo
|
||||
echo "=== Search for any 'Loaded' or 'Failed' ==="
|
||||
docker logs vulnforge 2>&1 | grep -iE 'loaded|failed' | head -20
|
||||
echo
|
||||
echo "=== First 30 nextcord-level log lines (chronological) ==="
|
||||
docker logs vulnforge 2>&1 | grep -E 'nextcord' | head -30
|
||||
Reference in New Issue
Block a user