Initial ThreatLens release
This commit is contained in:
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))
|
||||
Reference in New Issue
Block a user