Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ needed for creation or status changes.
- **`@core-team`** Slack user group — full admin access to all commands
- **Site organisers** — can view attendees and export data for their site(s)

## Security Advisory Digest

The bot polls GitHub once a day for new or changed security advisories
(including Triage/draft reports) across every nf-core repo and posts them to a
private Slack channel for the core team to review — see
[docs/security-advisories.md](docs/security-advisories.md).

## Development

Generally speaking, just commit and push code. Automation on GitHub actions
Expand All @@ -185,4 +192,5 @@ mypy src/ # Type check
```

See also: [Slack App Setup](docs/slack-app-setup.md) ·
[Command Reference](docs/commands.md) · [Deployment](docs/deployment.md)
[Command Reference](docs/commands.md) · [Deployment](docs/deployment.md) ·
[Security Advisory Digest](docs/security-advisories.md)
71 changes: 71 additions & 0 deletions docs/security-advisories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Daily security advisory digest

GitHub's `repository_advisory` webhook doesn't reliably fire for advisories
submitted via "Report a vulnerability" (state `triage`) — no public event fires
until a maintainer or researcher formally acts on the report. That gap has let
reports sit unnoticed (e.g. `nf-core/rnaseq`'s `GHSA-pq68-g636-8gwf`). Instead,
the bot polls GitHub once a day and posts anything new or changed to a private
Slack channel for the core team to review.

## How it works

```mermaid
flowchart TD
A["Daily poll:<br/>GET /orgs/{org}/security-advisories<br/>(every repo, one call)"] --> B{"Call succeeded?"}
B -- "no" --> C["Failure notice posted to Slack"]
B -- "yes" --> D["Diff each advisory's (state, updated_at)<br/>against DynamoDB (ADVISORY#{repo} / {ghsa_id})"]
D -- "new or changed" --> E["Digest posted to Slack<br/>(core team)"]
D -- "unchanged" --> F["Skipped"]
```

- A single org-wide API call covers every repo — no per-repo loop, so a repo
added to the org later is covered automatically.
- Each advisory's `(state, updated_at)` is compared against what was last seen
(stored in DynamoDB); only new or changed advisories are posted. Unchanged
advisories are silently skipped.
- If the API call itself fails (missing permission, revoked token, GitHub
outage), that failure is posted to the same channel too, not just logged — a
check that silently stops working is worse than a noisy failure.
- Advisory summaries are attacker-influenced (anyone can file a vulnerability
report against any public nf-core repo), so they're mrkdwn-escaped before
being posted, the same way externally-supplied proposal titles are escaped in
the on-call digest (`scheduler/oncall_jobs.py`'s `_slack_link`).

Runs as a background `asyncio.Task` launched from `app.py`, following the same
pattern as the on-call scheduler (`scheduler/oncall_jobs.py`).

## Configuration (env vars)

| Var | Default | Notes |
| ------------------ | --------------- | --------------------------------------------------- |
| `SECURITY_CHANNEL` | see `config.py` | Name of the private channel the digest is posted to |

## One-time setup

### GitHub — grant the token org-wide advisory access

`GITHUB_TOKEN` already needs `admin:org` for org-invite features, but reading
Triage/draft advisories org-wide additionally requires the account behind the
token to hold a specific org role: **Security manager**.

A Classic PAT needs `repository_advisories:write` scope.

Fine-grained PAT: the **"Repository security advisories"** repository permission
to **Write**, scoped to "All repositories" for the org.

If there's an issue the daily check posts an error message to Slack.

### Slack

The bot must already be a member of the private channel set via
`SECURITY_CHANNEL` (`/invite @nf-core-bot`) — it can only post to channels it
belongs to.

## Files

- `src/nf_core_bot/checks/github.py` — `list_all_org_advisories()`, the single
GitHub API call (cursor-paginated via the `Link` header)
- `src/nf_core_bot/db/advisories.py` — DynamoDB read/write of last-seen advisory
state (`ADVISORY#{repo}` / `{ghsa_id}`)
- `src/nf_core_bot/security/advisories.py` — the scheduler loop, diffing logic,
and Slack posting (digest + failure notices)
7 changes: 7 additions & 0 deletions src/nf_core_bot/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from nf_core_bot.proposals import spawn as spawn_proposal_task
from nf_core_bot.proposals.listener import handle_proposal_message, should_consider
from nf_core_bot.scheduler.oncall_jobs import run_oncall_scheduler
from nf_core_bot.security.advisories import run_security_scheduler

logging.basicConfig(
level=logging.INFO,
Expand Down Expand Up @@ -168,6 +169,12 @@ async def _start() -> None:
task.add_done_callback(_background_tasks.discard)
logger.info("On-call scheduler started.")

# Launch the security advisory scheduler as a background task.
security_task = asyncio.create_task(run_security_scheduler(app.client))
_background_tasks.add(security_task)
security_task.add_done_callback(_background_tasks.discard)
logger.info("Security advisory scheduler started.")

handler = AsyncSocketModeHandler(app, config.SLACK_APP_TOKEN)
await handler.start_async() # type: ignore[no-untyped-call]

Expand Down
67 changes: 66 additions & 1 deletion src/nf_core_bot/checks/github.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
"""GitHub API checks — verify organisation membership, invite users.

Uses the ``GITHUB_TOKEN`` env var, which must have ``admin:org`` scope
for invitation/team-management operations.
for invitation/team-management operations. Listing org-wide security
advisories (``list_all_org_advisories``) additionally requires the
account behind the token to be an org owner or security manager, plus
the ``repository_advisories:write`` scope (or, for a fine-grained PAT,
the "Repository security advisories" permission set to Write).
"""

from __future__ import annotations
Expand Down Expand Up @@ -335,3 +339,64 @@ async def get_issue_with_comments(number: int, repo: str = PROPOSALS_REPO) -> Is
body=issue.get("body", "") or "",
comments=comments,
)


# ── Security advisories (for security.advisories) ─────────────────────


@dataclass
class SecurityAdvisory:
"""A single repository security advisory, including Triage/draft state."""

repo: str
ghsa_id: str
summary: str
state: str # triage, draft, published, closed
severity: str | None
updated_at: str
url: str


# The org-wide advisories endpoint doesn't include a dedicated repository
# field, but each advisory's API `url` is `.../repos/{org}/{repo}/security-advisories/{id}`.
_ADVISORY_URL_REPO_RE = re.compile(r"/repos/[^/]+/([^/]+)/security-advisories/")


def _repo_from_advisory_url(api_url: str) -> str:
match = _ADVISORY_URL_REPO_RE.search(api_url)
return match.group(1) if match else "unknown"


async def list_all_org_advisories() -> list[SecurityAdvisory]:
"""Return every security advisory across every repo in the org, including Triage/draft.

``GET /orgs/{org}/security-advisories`` — a single org-wide call instead of
iterating every repo. Requires the account behind ``GITHUB_TOKEN`` to be an
org owner or security manager, plus either the ``repository_advisories:write``
scope (classic PAT) or the "Repository security advisories" permission set to
Write (fine-grained PAT).

Raises ``RuntimeError`` on a non-200 response so callers can distinguish "no
advisories" from "couldn't check" (e.g. missing security-manager role).
"""
org = config.GITHUB_ORG
advisories: list[SecurityAdvisory] = []
path: str | None = f"/orgs/{org}/security-advisories?per_page=100"
while path:
resp = await _github_get(path)
if resp.status_code != 200:
raise RuntimeError(f"Failed to list security advisories for org {org}: {resp.status_code} — {resp.text}")
for adv in resp.json():
advisories.append(
SecurityAdvisory(
repo=_repo_from_advisory_url(adv.get("url", "")),
ghsa_id=adv.get("ghsa_id", ""),
summary=adv.get("summary", "") or adv.get("ghsa_id", ""),
state=adv.get("state", ""),
severity=adv.get("severity"),
updated_at=adv.get("updated_at", ""),
url=adv.get("html_url", ""),
)
)
path = resp.links.get("next", {}).get("url")
return advisories
1 change: 1 addition & 0 deletions src/nf_core_bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"MAINTAINERS_USERGROUP_HANDLE": "maintainers-team",
# Channels
"ONCALL_CHANNEL": "core",
"SECURITY_CHANNEL": "security",
# Calendar (S3 + CloudFront)
"CALENDAR_S3_BUCKET": None,
"CALENDAR_CLOUDFRONT_DOMAIN": None,
Expand Down
59 changes: 59 additions & 0 deletions src/nf_core_bot/db/advisories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""DynamoDB operations for tracking previously-seen GitHub security advisories."""

from __future__ import annotations

import asyncio
import datetime
from typing import Any

from boto3.dynamodb.conditions import Key

from nf_core_bot.db.client import get_table

ADVISORY_PK_PREFIX = "ADVISORY#"


def _advisory_pk(repo: str) -> str:
"""PK for a repo's advisories: ``ADVISORY#rnaseq``."""
return f"{ADVISORY_PK_PREFIX}{repo}"


def _now_iso() -> str:
return datetime.datetime.now(datetime.UTC).isoformat()


async def get_known_advisories(repo: str) -> dict[str, dict[str, str]]:
"""Return ``{ghsa_id: {"state": ..., "updated_at": ...}}`` for *repo*."""
table = get_table()

def _query() -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
kwargs: dict[str, Any] = {"KeyConditionExpression": Key("PK").eq(_advisory_pk(repo))}
while True:
resp = table.query(**kwargs)
items.extend(resp.get("Items", []))
if "LastEvaluatedKey" not in resp:
break
kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"]
return items

items = await asyncio.to_thread(_query)
return {item["SK"]: {"state": item["state"], "updated_at": item["updated_at"]} for item in items}


async def save_advisory_state(repo: str, ghsa_id: str, state: str, updated_at: str) -> None:
"""Persist the last-seen state/updated_at for a single advisory (overwrites)."""
table = get_table()

def _put() -> None:
table.put_item(
Item={
"PK": _advisory_pk(repo),
"SK": ghsa_id,
"state": state,
"updated_at": updated_at,
"seen_at": _now_iso(),
}
)

await asyncio.to_thread(_put)
Empty file.
117 changes: 117 additions & 0 deletions src/nf_core_bot/security/advisories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Background job: poll GitHub security advisories across all nf-core repos.

Launched as an ``asyncio.Task`` from ``app.py`` at startup, alongside the
on-call scheduler (see ``scheduler/oncall_jobs.py``).

``repository_advisory`` webhooks don't reliably cover advisories submitted
via GitHub's "Report a vulnerability" form (state ``triage``) — no public
event fires until a maintainer/researcher formally acts on it. So instead
we poll once a day (a single org-wide API call, see
``checks.github.list_all_org_advisories``), diff against what was last
seen, and post anything new/changed to a private Slack channel.
"""

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING

from nf_core_bot import config
from nf_core_bot.checks.github import SecurityAdvisory, list_all_org_advisories
from nf_core_bot.db.advisories import get_known_advisories, save_advisory_state
from nf_core_bot.proposals.slack_utils import resolve_channel_id

if TYPE_CHECKING:
from slack_sdk.web.async_client import AsyncWebClient

logger = logging.getLogger(__name__)

# Once a day is plenty — only a handful of advisories fire per year.
CHECK_INTERVAL_SECONDS = 24 * 60 * 60


async def run_security_scheduler(client: AsyncWebClient) -> None:
"""Long-running loop: check GitHub security advisories once a day."""
logger.info("security advisory scheduler started")
while True:
try:
await check_security_advisories(client)
except Exception:
logger.exception("security advisory check crashed unexpectedly")
await _notify(client, "The security advisory check crashed unexpectedly — see bot logs.")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)


async def check_security_advisories(client: AsyncWebClient) -> None:
"""Fetch every org-wide security advisory, diff against last-seen state, and post a digest.

A single API call covers every repo (see ``list_all_org_advisories``). If it
fails — e.g. the account behind ``GITHUB_TOKEN`` isn't an org owner/security
manager, or lacks the advisories scope — that's reported to Slack, not just logged.
"""
try:
advisories = await list_all_org_advisories()
except Exception:
logger.exception("Failed to fetch org security advisories")
await _notify(
client,
"Could not fetch security advisories. Check that the account behind `GITHUB_TOKEN` "
"is an org owner/security manager and the token has the `repository_advisories:write` "
'scope (classic) or the "Repository security advisories" permission (fine-grained).',
)
return

flagged: list[SecurityAdvisory] = []
for adv in advisories:
known = await get_known_advisories(adv.repo)
prev = known.get(adv.ghsa_id)
if prev is None or prev["state"] != adv.state or prev["updated_at"] != adv.updated_at:
flagged.append(adv)
await save_advisory_state(adv.repo, adv.ghsa_id, adv.state, adv.updated_at)

if flagged:
await _post_digest(client, flagged)


def _escape_mrkdwn(text: str) -> str:
"""Escape Slack mrkdwn special characters in externally-supplied text.

``adv.summary``/``adv.repo`` come from GitHub's advisory API — free text
anyone can set by filing a vulnerability report against a public repo.
Without escaping, a stray ``<``/``>`` can close the ``<url|label>`` link
early and inject a second, attacker-controlled link (or an ``<!channel>``
ping) into the message. Mirrors ``scheduler/oncall_jobs.py``'s ``_slack_link``.
"""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


async def _post_digest(client: AsyncWebClient, advisories: list[SecurityAdvisory]) -> None:
"""Post one Slack message per new/changed advisory to the security channel."""
channel = await resolve_channel_id(client, config.SECURITY_CHANNEL or "security")
if channel is None:
logger.error("Cannot post security advisory digest — #%s channel not resolved", config.SECURITY_CHANNEL)
return
for adv in advisories:
severity = f" · {adv.severity}" if adv.severity else ""
summary = _escape_mrkdwn(adv.summary)
text = (
f":rotating_light: *{_escape_mrkdwn(adv.repo)}* — <{adv.url}|{summary}>\n"
f"State: `{adv.state}`{severity} · `{adv.ghsa_id}`"
)
try:
await client.chat_postMessage(channel=channel, text=text)
except Exception:
logger.exception("Failed to post security advisory %s to Slack", adv.ghsa_id)


async def _notify(client: AsyncWebClient, message: str) -> None:
"""Post a check-failure notice to the security channel — never fail silently."""
channel = await resolve_channel_id(client, config.SECURITY_CHANNEL or "security")
if channel is None:
logger.error("Cannot post security-check failure notice — #%s channel not resolved", config.SECURITY_CHANNEL)
return
try:
await client.chat_postMessage(channel=channel, text=f":warning: Security advisory check: {message}")
except Exception:
logger.exception("Failed to post security-check failure notice to Slack")
Loading
Loading