Security-first local service that lists GitHub Security Advisories created/owned by you and CERT-TR / T.C. Siber Güvenlik Başkanlığı advisories received via e-mail (Proton Bridge or Gmail) and exposes them as a single mixed RSS 2.0 feed for Zen Browser's RSS Live Folder. What is 'Zen Browser's Live Folder?' == https://github.com/zen-browser/desktop/releases/tag/1.19b
flowchart LR
GH["GitHub API<br/>(PAT)"]
PB["Proton Bridge IMAP<br/><i>siberguvenlik.gov.tr</i><br/>BODY.PEEK — never marks read"]
GM["Gmail IMAP<br/><i>imap.gmail.com</i><br/>App Password / XOAUTH2<br/>never marks read"]
GH --> NORM["normalize + cache<br/>(advisories.db)"]
PB --> NORM
GM --> NORM
NORM --> RSS["RSS 2.0<br/>on 127.0.0.1"]
RSS --> ZEN["Zen Live Folder"]
style GH fill:#24292f,stroke:#1f2328,color:#fff
style PB fill:#6d28d9,stroke:#4c1d95,color:#fff
style GM fill:#dc2626,stroke:#991b1b,color:#fff
style NORM fill:#f59e0b,stroke:#92400e,color:#000
style RSS fill:#0ea5e9,stroke:#0c4a6e,color:#fff
style ZEN fill:#059669,stroke:#064e3b,color:#fff
Endpoints:
http://127.0.0.1:PORT/rss.xml # mixed (GitHub + CERT-TR)
http://127.0.0.1:PORT/rss.xml?source=github
http://127.0.0.1:PORT/rss.xml?source=cert-tr
http://127.0.0.1:PORT/health # per-source counts, cert_tr diag
http://127.0.0.1:PORT/ # hint
- Python 3.12+
uvorpip+ venv- For GitHub source: a GitHub account that owns/creates advisories (you are
author) + PAT (see §3) - For CERT-TR source (optional,
ENABLE_CERT_TR=true):- Proton Mail with Bridge installed (paid plan required, you can patch the source though) or Gmail with IMAP enabled
- CERT-TR mails come from
cve@siberguvenlik.gov.tr/siberguvenlik.gov.tr- only CVE-assigned mails are ingested (likeCVE ID Talebi,Zafiyet Bildiriminiz Hakkındawithout CVE are skipped) - Proton: Bridge exposes localhost IMAP
127.0.0.1:1143STARTTLS(self-signed) - Gmail:
imap.gmail.com:993SSLvia App Password (16 chars, 2FA required) or OAuth 2.0 (google-auth+google-auth-oauthlib)
pip install advisory-rss
pipx install advisory-rssgit clone https://github.com/Lunixizm0/advisory-rss
cd advisory-rss
# with uv (recommended)
uv sync --extra dev
# or with pip
python -m venv .venv && source .venv/bin/activate
pip install -e .[dev]
# verify
app --help
app auth login --help
pytest -q # 78 tests
ruff check && pyright # All checks passed!The service uses official GitHub REST API for the GitHub source.
| PAT type | Where to create | Scopes / Permissions required | When needed |
|---|---|---|---|
| Fine-grained PAT (recommended) | https://github.com/settings/personal-access-tokens/new | Resource owner: your user + relevant orgs - Repository access: All repositories (or select repos) - Repository permissions - Security advisories: Read-only + Metadata: Read-only (implied). For org-wide bulk fetch: Organization permissions - Security advisories: Read-only (requires org owner/security-manager). | Preferred - short expiry (30–90 days), no read to code. |
| Classic PAT fallback | https://github.com/settings/tokens/new | repo or at minimum public_repo (but private/draft advisories require repo). Docs: repo or repository_advisories:read (docs.github.com/en/rest/security-advisories/repository-advisories). |
If fine-grained is unavailable. Note: repo grants read to code - broader than needed. |
No other scopes are needed. Do not grant admin:org, delete_repo, workflow, etc.
GitHub API endpoints used:
GET /user- resolves authenticatedloginGET /user/repos?affiliation=owner&visibility=all&per_page=100&sort=updated- paginated enumerates repos you ownGET /user/orgs- lists orgs you belong toGET /orgs/{org}/security-advisories?per_page=100&sort=updated&state={triage,draft,published,closed}- org-wide bulk (requires owner/security-manager, else 403/404 and we fall back to per-repo)GET /repos/{owner}/{repo}/security-advisories?per_page=100&sort=updated&direction=desc&state={triage,draft,published,closed}- primary fetch, paginated viaLink: <…after=…>; rel="next"until exhaustionGET /advisories/{ghsa_id}- optional enrichment for published global view (not used for discovery)
Why GraphQL is rejected for ownership: docs.github.com/en/graphql/reference/security-advisories lists securityAdvisories{ghsaId,summary,severity,…} with args classifications, epss*, first/last, identifier, orderBy, publishedSince - no author field and no author filter, so it cannot identify "created by me" reliably. We use REST author.login and filter client-side.
cp .env.example .env
$EDITOR .env
# e.g. GITHUB_TOKEN=github_pat_… or ghp_…
# leave BIND_ADDRESS=127.0.0.1 and PORT=8765 - do NOT change to 0.0.0.0
# validate PAT (only checks GET /user, never saves to file - .env is the only source)
app auth login --provider github
# Paste PAT -> "+ Validated as @you" -> choose to write to .env or set manually
# manual fetch - writes cache/advisories.db
app sync
app sync --source github # only GitHub
app status
# - GitHub: 1 CERT-TR: 2 (if CERT-TR enabled)Direct fetch for advisories in repos you don't own (you authored GHSA in other-org/other-repo but affiliation=owner doesn't list it - GitHub has no author filter, see Known Limitations):
# .env
GITHUB_REPOS=your-org/your-repo,other-org/other-repo # comma-separated, always scanned
GITHUB_ORG=your-org # expands via /orgs/{org}/repos
SKIP_FULL_SCAN=true # optional: skip scanning all owned repos, only scan GITHUB_REPOS/GITHUB_ORG (fast)
# PAT must have Repository access -> Selected repositories covering those repos (fine-grained)
LOG_LEVEL=INFO app syncFILTER_MODE semantics, .env.example:22:
author(default) - onlyadvisory.author.login == your_login(precise "created by me")author_or_publisher- also matchespublisher.loginauthor_or_collaborator- also matchescollaborating_users[](covers advisories where you are collaborator but not author)
Bridge must be installed, logged in, and running (protonmail-bridge --cli - info shows host/port/password).
cp .env.example .env
$EDITOR .env
# interactive login (validates via IMAP SELECT, does not mark read)
app auth login --provider proton --email mail@mail.com
# Paste Bridge password -> "+ Proton IMAP doğrulandı"
# Writes PROTON_BRIDGE_EMAIL/PASSWORD (or appends to PROTON_BRIDGE_EMAILS) to .env
app sync --source cert-tr # only CERT-TR
app sync --source all # GitHub + CERT-TR mixed
app status # shows CERT-TR accounts: [proton] mail@mail.com -> folder=INBOX host=127.0.0.1:1143Bridge's IMAP is 127.0.0.1:1143 STARTTLS with self-signed cert (CERT_NONE), BODY.PEEK + SELECT readonly=True (never sets \Seen), reconnect every 25 mails (Bridge drops).
App Password (simplest, 2FA required):
- Gmail - Google Hesabı - Güvenlik - 2 Adımlı Doğrulama - Uygulama şifreleri -
Posta-Diğer-Advisory-RSS- 16 haneli şifre (örn.abcd efgh ijkl mnop, boşluklar korunur). - Gmail - Ayarlar - Yönlendirme ve POP/IMAP - IMAP'ı etkinleştir.
app auth login --provider gmail --method app-password --email mail@mail.com
# Paste App Password -> "+ Gmail IMAP (App Password) doğrulandı"OAuth 2.0 (recommended for long-lived, no app password):
- https://console.cloud.google.com/ - Create Project - APIs & Services - Credentials - Create Credentials - OAuth Client ID - Application type: Desktop - Name:
Advisory-RSS- Create - copyClient ID/Client Secret. - Enable Gmail API: APIs & Services - Enable APIs - search
Gmail API- Enable. - The CLI will open browser for consent (
https://mail.google.com/scope,access_type=offlineprompt=consentto getrefresh_token).
app auth login --provider gmail --method oauth --email mail@mail.comapp serve
# binds 127.0.0.1:8765, background refresh every REFRESH_INTERVAL (default 600s = 10 min) for BOTH sources
# health: curl http://127.0.0.1:8765/health | jq
# feed : curl http://127.0.0.1:8765/rss.xml | xmllint --noout -
# filter: curl "http://127.0.0.1:8765/rss.xml?source=cert-tr" | xmllint --noout -Canonical for Zen Browser (mixed):
http://127.0.0.1:8765/rss.xml
http://127.0.0.1:8765/rss.xml?source=github
http://127.0.0.1:8765/rss.xml?source=cert-tr
Do not use HTTPS. Also, no auth on /rss.xml (Zen has no RSS auth). Mixed feed title: Security Advisories (GitHub + CERT-TR) - @you, per-source: [CERT-TR] pardus-etap-settings: CVE-2026-92083 (CWE-862).
- Start the feed:
app serveand verifycurl http://127.0.0.1:8765/rss.xml | headreturns<rss version="2.0">. - Open Zen Browser - Right Click - Live Folder - Add RSS Feed
- Paste
http://127.0.0.1:8765/rss.xmlas the feed source. Leave protocol HTTP (not HTTPS). - Save. Advisories appear sorted
updated_at DESC, newest first. Withdrawn advisories remain with[WITHDRAWN]prefix - not deleted. CERT-TR items have[CERT-TR]prefix, NVD link,source:cert-tr/cve:/cwe:categories. - If Zen shows empty folder:
curl http://127.0.0.1:8765/health | jq .advisories_count,.github_count,.cert_tr_countshould be >0; if 0, re-runapp syncand checkapp statusfor rate-limit/auth errors.
# .env
REFRESH_INTERVAL=300 # 5 minutes, min 60
# and optionally
MAX_ITEMS=500 # RSS truncation (cache still retains all to avoid missing advisories)Restart app serve to apply. The feed Cache-Control: private, max-age=300, must-revalidate and <ttl>5</ttl> update automatically. app status shows Next scheduled sync. background_refresh_loop src/advisory_rss/server/app.py:346 polls both GitHub (If-None-Match/ETag) and CERT-TR (IMAP BODY.PEEK) every interval, asyncio.to_thread for IMAP, stale kept on error.
Normal app serve dies when the terminal closes (SIGHUP). If you want it to keep running in the background:
# easiest - daemonize (double-fork + setsid, like nohup but with pid/log management)
app serve --daemon
# - pid: cache/advisory-rss.pid , log: cache/serve.log
# verification
cat cache/advisory-rss.pid
ps -p $(cat cache/advisory-rss.pid) -o pid,cmd
curl http://127.0.0.1:8765/health
tail -f cache/serve.log
app stop # SIGTERM + pid cleanup
app stop --pid-file cache/advisory-rss.pid
# alternatives (without daemon):
nohup app serve > cache/serve.log 2>&1 & disown
# or
tmux new -s rss "app serve"
# or systemd user service (persistent)
# ~/.config/systemd/user/advisory-rss.service:
# [Unit] Description=Advisory RSS localhost-only
# [Service] ExecStart=%h/Belgeler/Projeler/Advisory-RSS/.venv/bin/python -m advisory_rss.cli serve
# Restart=always
# Environment=GITHUB_TOKEN=ghp_...
# [Install] WantedBy=default.target
# systemctl --user daemon-reload && systemctl --user enable --now advisory-rssBackground behaviour: RSS reads cache only; a background task polls both sources every REFRESH_INTERVAL, uses If-None-Match/ETag for GitHub and BODY.PEEK/readonly=True for IMAP, and keeps stale cache on transient errors (requirement: feed stays available if GitHub/Gmail/Proton is down).
| Symptom | Check |
|---|---|
401 Unauthorized at app sync |
PAT expired/revoked or empty. Re-run app auth login --provider github; verify at https://github.com/settings/tokens. See Health last_error. |
403 Forbidden |
PAT scopes missing repository_advisories:read or org role insufficient (org bulk needs owner/security-manager - falls back to per-repo). Log shows scope hint. |
429 / rate limited |
health.rate_limited_until set, app status shows it. Wait or raise MAX_REPOS/reduce REFRESH_INTERVAL frequency. Logs include Retry-After/X-RateLimit-Reset. |
404 for a repo |
Repo not found or no advisories - skipped, not fatal. |
422 Validation |
GitHub rejected params (e.g., bad state) - logged and skipped. |
| Feed empty but advisories exist | Check FILTER_MODE (default author requires you are author. Try author_or_publisher). Also ensure authenticated_user in app status equals your advisory author. For CERT-TR: check ENABLE_CERT_TR=true and app status - CERT-TR accounts: N and IMAP search empty - folder name wrong? Try GMAIL_IMAP_FOLDERS=INBOX only, or check LOG_LEVEL=DEBUG app sync. |
IMAP login failed (Proton) |
Bridge running? 127.0.0.1:1143 reachable? protonmail-bridge --cli - info shows correct host/port/password. Password is Bridge password, not account password. Check PROTON_BRIDGE_HOST/PORT/SECURITY. |
IMAP login failed (Gmail App Password) |
2FA enabled? App Passwords - generate new 16-char (spaces preserved). Gmail - Settings - Forwarding and POP/IMAP - IMAP enabled. Check GMAIL_IMAP_HOST=imap.gmail.com:993 SSL. |
IMAP XOAUTH2 failed (Gmail OAuth) |
GMAIL_OAUTH_CLIENT_ID/SECRET correct? Refresh token expired/revoked? Re-run app auth login --provider gmail --method oauth. Check cache/gmail_oauth.json exists (chmod 600). Ensure Gmail API enabled in Google Cloud. Check LOG_LEVEL=DEBUG for fetch_access_token error. https://oauth2.googleapis.com/token must be reachable. |
UnicodeEncodeError: ascii ... Yazışmaları |
Fixed in src/advisory_rss/cert_tr/imap.py:24 via modified UTF-7 fallback + mail._encoding='utf-8' + ENABLE UTF8=ACCEPT. Ensure GMAIL_IMAP_FOLDERS uses , as separator (not ; + space split) and folder name exactly as in Gmail UI (case-sensitive). Try LOG_LEVEL=DEBUG app sync --source cert-tr to see IMAP select folder. |
CERT-TR sync: no advisories (raw 0) |
CERT_TR_SENDER_ALLOWLIST=siberguvenlik.gov.tr correct? Check IMAP search empty - folder empty or FROM mismatch. Try GMAIL_IMAP_FOLDERS=INBOX and check Gmail for mails from cve@siberguvenlik.gov.tr. CERT_TR_MAX_MAILS maybe too low? CERT_TR_SEARCH_DAYS maybe filtering? |
CERT-TR sync OK - 0 advisories ... parsed 0 but raw >0 |
All mails without CVE are now skipped (CVE-only). Check health - cert_tr_count should be 2 for your CVE-2026-92083/16245. Old CERT-TR-MSG-* rows auto-deleted on next CacheStore init src/advisory_rss/cache/store.py:76. |
pip-audit failure |
pip install pip-audit && pip-audit - fix by bumping pinned dep in pyproject.toml. |
google-auth-oauthlib missing |
uv sync --extra dev or pip install google-auth google-auth-oauthlib (added to pyproject.toml:9). |
View logs: LOG_LEVEL=DEBUG app serve for detail (still redacted).
Verify feed: xmllint --noout <(curl -s http://127.0.0.1:8765/rss.xml) - no output means valid. Check per-source: curl -s "http://127.0.0.1:8765/rss.xml?source=cert-tr" | grep CERT-TR.
flowchart TD
subgraph GH["GitHub REST API — api.github.com"]
direction TB
GH_AUTH["PAT Bearer<br/>2022-11-28"]
GH_A1["get_user<br/>list_user_orgs<br/>list_user_repos"]
GH_A2["orgs/{org}/security-advisories<br/>(bulk, owner/security-manager)"]
GH_A3["repos/{owner}/{repo}/security-advisories<br/>per_page=100, paginated via Link header<br/>+ GITHUB_REPOS / GITHUB_ORG"]
GH_A4["HTTP handling<br/>401/403/404/422/429/5xx<br/>ETag / If-None-Match → 304"]
GH_A5["normalize<br/>filter by author<br/>FILTER_MODE: author / author_or_publisher / collaborator"]
GH_A6["dedup by GHSA<br/>sort updated_at DESC"]
GH_AUTH --> GH_A1
GH_A1 --> GH_A2
GH_A1 --> GH_A3
GH_A2 --> GH_A4
GH_A3 --> GH_A4
GH_A4 --> GH_A5 --> GH_A6
end
subgraph CERT["CERT-TR IMAP<br/>Proton 127.0.0.1:1143 / Gmail imap.gmail.com:993"]
direction TB
CT_AUTH["OAuth XOAUTH2<br/>refresh_token → access_token<br/>or App Password / Bridge"]
CT_B1["connect_imap() per account<br/>{email, host, port, security, auth_method}"]
CT_B2["_ssl_context_for_host()<br/>CERT_NONE: Bridge / CERT_REQUIRED: Gmail"]
CT_B3["_select_folder_imap()<br/>UTF-8 + ENABLE UTF8=ACCEPT<br/>modified UTF-7 fallback — CVE Yazışmaları"]
CT_B4["IMAP SEARCH<br/>FROM siberguvenlik.gov.tr [SINCE ...]<br/>per folder — readonly + BODY.PEEK"]
CT_B5["fetch BODY.PEEK[]<br/>batch 25 + reconnect"]
CT_B6["parse_cert_tr_email()<br/>src/advisory_rss/cert_tr/parser.py:285<br/>strict CVE-only, CWE / product / NVD refs"]
CT_B7["is_cert_tr_sender()<br/>allowlist check"]
CT_B8["dedup by CERT-TR-CVE-...<br/>sort"]
CT_AUTH --> CT_B1 --> CT_B2 --> CT_B3 --> CT_B4 --> CT_B5 --> CT_B6 --> CT_B7 --> CT_B8
end
AGG["aggregator<br/>mixed — no collision GHSA-* vs CERT-TR-*"]
CACHE["upsert — cache/advisories.db<br/>SQLite WAL, source column, ETag map, MSG cleanup"]
LOOP["background_refresh_loop<br/>asyncio every REFRESH_INTERVAL<br/>stale kept on error"]
REQ["RSS request<br/>cache only — ?source= filter"]
FEED["GET http://127.0.0.1:8765/rss.xml<br/>builder: [CERT-TR] / [HIGH]<br/>source:cert-tr / github, cve: / cwe: categories"]
ZEN["Zen Browser<br/>RSS Dynamic Folder"]
GH_A6 --> AGG
CT_B8 --> AGG
AGG --> CACHE
CACHE --> LOOP
CACHE --> REQ
LOOP -. poll & upsert .-> CACHE
REQ --> FEED --> ZEN
style GH fill:#24292f,stroke:#1f2328,color:#fff
style CERT fill:#1e3a5f,stroke:#0f172a,color:#fff
style AGG fill:#f59e0b,stroke:#92400e,color:#000
style CACHE fill:#eab308,stroke:#713f12,color:#000
style LOOP fill:#6366f1,stroke:#312e81,color:#fff
style REQ fill:#06b6d4,stroke:#164e63,color:#fff
style FEED fill:#0ea5e9,stroke:#0c4a6e,color:#fff
style ZEN fill:#059669,stroke:#064e3b,color:#fff
Key invariants: RSS reads cache only; GH enumeration is exhaustive; IMAP never sets \Seen; failures keep stale cache; CERT-TR strict CVE-only (hash fallback removed).
- GitHub Fine-grained:
Repository security advisories: Read,Metadata: Read, (org bulk:Organization security advisories: Read) - GitHub Classic:
repo(orpublic_repofor public advisories only, but private/draft needrepo/repository_advisories:read) - Proton Bridge: Bridge must be running,
PROTON_BRIDGE_PASSWORDis Bridge-generated (Mailbox details), not account password, paid plan required - Gmail App Password: Google Account - 2FA - App Passwords -
GMAIL_APP_PASSWORD(16 chars, spaces preserved), IMAP enabled - Gmail OAuth: Google Cloud - OAuth Client ID (Desktop) -
GMAIL_OAUTH_CLIENT_ID/SECRET-app auth login --provider gmail --method oauth-refresh_tokenstored incache/gmail_oauth.json(600) +.env(GMAIL_OAUTH_REFRESH_TOKEN(S)), scopehttps://mail.google.com/, usesXOAUTH2src/advisory_rss/auth/gmail.py:1+src/advisory_rss/cert_tr/imap.py:65
- GitHub No server-side "created by me" filter - all filtering is client-side after listing advisories per repo/state. Costs API calls (~1 per repo × 4 states + org bulk) and is rate-limited; mitigated with ETag 304, pagination caps, and background polling rather than per-RSS-call.
- GitHub Enumeration requires listing all owned repos (
GET /user/repos) - large accounts (thousands of repos) need many pages;MAX_REPOS/MAX_PAGES_PER_REPOcap prevents runaway,app statusreports caps hit. - GitHub Org bulk requires owner/security-manager - else 403/404 per org and we fall back to per-repo enumeration (expected).
- GitHub Global
GET /advisoriescannot tell ownership - it is the public advisory database; ignored for discovery, used only as optional enrichment if needed. - GHSA != chronological - must sort by
updated_at, not ID. - GraphQL
SecurityAdvisoryhas noauthor- verified; cannot replace REST for this use case. - Global enrichment 404 for draft/private - expected; we treat missing as not-enriched.
- Withdrawn state differs between global (
withdrawn_at/is_withdrawn) and repo (state: closed/withdrawn+withdrawn_at) - normalized into singlewithdrawn_at/state. - CERT-TR IMAP
FROMsearch is substring -FROM "siberguvenlik.gov.tr"is server-side substring, but finalis_cert_tr_sender()src/advisory_rss/cert_tr/parser.py:429does strict domain check; non-CVE mails are now skipped entirely. - Gmail/Proton folders with non-ASCII (e.g.,
CVE Yazışmaları) requiremodified UTF-7fallbacksrc/advisory_rss/cert_tr/imap.py:24;GMAIL_IMAP_FOLDERSmust be comma-separated (spaces preserved), not whitespace-split. - Gmail OAuth
refresh_tokenonly on first consent - ifrun_gmail_oauth_flowsrc/advisory_rss/auth/gmail.py:210returns norefresh_token, revoke access at https://myaccount.google.com/permissions and retry withprompt=consent. - Proton Bridge self-signed cert -
CERT_NONE+STARTTLS(orSSL); Gmail usesCERT_REQUIRED. - Legacy
CERT-TR-MSG-*rows - auto-deleted onCacheStoreinitsrc/advisory_rss/cache/store.py:76(non-CVE false positives).
pytest -q # 78 tests (60 original + 14 CERT-TR + 4 Gmail)
pytest tests/unit -q -v
pytest tests/integration -v # starts ephemeral server on 127.0.0.1:0, validates RSS & headers
pytest tests/unit/test_cert_tr_settings.py tests/unit/test_cert_tr_imap.py -v # Gmail/Proton multi-folder, OAuth
pip-audit # audit scan
# Regenerate real fixtures from live GitHub API (uses GITHUB_TOKEN from .env, auto-detects GITHUB_REPOS/GITHUB_ORG/SKIP_FULL_SCAN)
python scripts/fixtures_generator.py
python scripts/fixtures_generator.py --ghsa GHSA-xxxx-xxxx-xxxx --repo bla/bla # single GHSA
# Lint / type-check (no noqa, truly fixed)
ruff check # All checks passed!
pyright # 0 errors, typeCheckingMode: standard, extraPaths: ["src"]GNU