Advisory-RSS - GitHub Security Advisories - Local RSS
Security-first local service that lists all GitHub Security Advisories created/owned by the authenticated account and exposes them as an RSS 2.0 feed for Zen Browser's RSS Live Folder.
What the hell is 'Zen Browser's Live Folder?' == https://github.com/zen-browser/desktop/releases/tag/1.19b
GitHub API - authenticated PAT - fetch advisories owned/created by account - normalize + cache - RSS 2.0 on 127.0.0.1 - Zen Browser's Live Folder
Endpoints:
http://127.0.0.1:PORT/rss.xml
http://127.0.0.1:PORT/health
1. Prerequisites
- Python 3.12+
uvorpip+ venv- A GitHub account that owns/creates advisories (i.e., you are
authorof advisories) - A GitHub PAT (see below)
2. Installation
git clone https://github.com/Lunixizm0/advisory-rss
cd advisory-rss
# with uv (recommended)
uv sync
# or with pip
python -m venv .venv && source .venv/bin/activate
pip install -e .[dev]
# verify
app --help
pytest -q
3. GitHub PAT
The service uses official GitHub REST API
| 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.
4. First Sync
cp .env.example .env
# edit .env - set at least GITHUB_TOKEN
$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
# Paste PAT -> "+ Validated as @you" -> then set GITHUB_TOKEN in .env as instructed
# manual fetch - writes cache/advisories.db
app sync
# inspect
app status
# - Authenticated user: @octocat
# - Cached advisories: 12
# - RSS URL: http://127.0.0.1:8765/rss.xml
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 sync
FILTER_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)
Do not equate "advisories in repos I own" with "created by me" - they are different.
5. Starting the Server
app serve
# binds 127.0.0.1:8765, background refresh every REFRESH_INTERVAL (default 600s = 10 min)
# health: curl http://127.0.0.1:8765/health
# feed : curl http://127.0.0.1:8765/rss.xml | xmllint --noout -
6. RSS URL
Canonical for Zen Browser:
http://127.0.0.1:8765/rss.xml
Do not use HTTPS. Also, no auth on /rss.xml (Zen has no RSS auth)
7. Adding the Feed to Zen Browser RSS Dynamic Folder
- 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. - If Zen shows empty folder:
curl http://127.0.0.1:8765/health | jq .advisories_countshould be >0; if 0, re-runapp syncand checkapp statusfor rate-limit/auth errors.
8. Changing the Refresh Interval
# .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.
8b. Keep running after closing the terminal (--daemon)
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-rss
Background behaviour: RSS reads cache only; a background task polls GitHub every REFRESH_INTERVAL, uses If-None-Match/ETag conditional requests, and keeps stale cache on transient GitHub errors (requirement: feed stays available if GitHub is down).
9. Troubleshooting
| Symptom | Check |
|---|---|
401 Unauthorized at app sync |
PAT expired/revoked or empty. Re-run app auth login; 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 FILTER_MODE=author_or_publisher. Also ensure authenticated_user in app status equals your advisory author. |
pip-audit failure |
pip install pip-audit && pip-audit - fix by bumping pinned dep in pyproject.toml. |
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.
Architecture Explanation
GitHub REST API (api.github.com)
│
│ PAT (Authorization: Bearer, X-GitHub-Api-Version: 2022-11-28)
▼
get_user - list_user_orgs - list_user_repos (paginated, Link:max 100/page) [skipped if SKIP_FULL_SCAN=true]
│ │
│ └─► orgs/{org}/security-advisories?state=… (bulk where authorized)
└─► repos/{owner}/{repo}/security-advisories?state=…&after=… (per-repo, per-state)
▲ plus GITHUB_REPOS/GITHUB_ORG direct fetch (always scanned, even if SKIP_FULL_SCAN)
│ handle 401/403/404/422/429/5xx, ETag/If-None-Match, 304
▼
normalize (github/normalize.py - tolerant, never crashes on one bad advisory)
│
filter by author (github/filter.py - author | author_or_publisher | author_or_collaborator)
│
dedup by GHSA, sort updated_at DESC
│
upsert - cache/advisories.db (SQLite WAL, parameterized, ETag map, cache fallback)
│
background_refresh_loop (asyncio every REFRESH_INTERVAL, stale kept on GitHub outage)
│
RSS request (no GitHub call) ◄┘
│
▼
GET http://127.0.0.1:8765/rss.xml - builder (xml_util escaping, RFC822 pubDate, stable GHSA GUID, withdrawn kept)
│
▼
Zen Browser RSS Dynamic Folder
Key invariants: RSS reads cache only; GH enumeration is exhaustive; failures keep stale cache.
Required GitHub Scopes/Permissions (Recap)
- Fine-grained:
Repository security advisories: Read,Metadata: Read, (org bulk:Organization security advisories: Read) - Classic:
repo(orpublic_repofor public advisories only, but private/draft needrepo/repository_advisories:read)
Known GitHub API Limitations
- 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.
- 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. - Org bulk requires owner/security-manager - else 403/404 per org and we fall back to per-repo enumeration (expected).
- 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.
Development & Tests
pytest -q # all 60 tests
pytest tests/unit -q -v
pytest tests/integration -v # starts ephemeral server on 127.0.0.1:0, validates RSS & headers
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 (standard, line-based ignores only - no config-based ignore)
ruff check # All checks passed!
pyright # 0 errors, typeCheckingMode: standard, extraPaths: ["src"]
License
GNU
Metadata
Release files for advisory-rss 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| advisory_rss-1.0.0.tar.gz | 66.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| advisory_rss-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 124.5 kB
Release files / advisory_rss-1.0.0.tar.gz
| Download URL | advisory_rss-1.0.0.tar.gz |
|---|---|
| Size | 66.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
10abb5fead8ce77bc46b75eae3bff9e7e529bbc2118d1d2e0a1e06c0bc37f1b8
|
|
BLAKE2b-256 checksum How to use checksums |
469ddbf3b5392f9cbebc46b550b7c9eaa4f2637ce11f20fa106932938fc0dc3d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency logRelease files / advisory_rss-1.0.0-py3-none-any.whl
| Download URL | advisory_rss-1.0.0-py3-none-any.whl |
|---|---|
| Size | 58.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f68258f8c7731a1b4811015b4d33e4d484f9c180413e163e5d463a835c1c13d2
|
|
BLAKE2b-256 checksum How to use checksums |
a979e4c193154d8720a35fb44326c94cd17a68ff1db6092054e3770834ba5c34
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 20, 2026.
Transparency log