160 lines
6.1 KiB
Python
Executable File
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))
|