Files
2026-06-27 21:24:57 -04:00

160 lines
6.1 KiB
Python
Executable File

"""
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))