mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 10:36:45 +09:00
New builtin job security-feed (daily) pulls the normalized advisory feed from serverkit.ai/api/security-feed.json (a cached proxy of the repo's published GitHub Security Advisories) and: - notifies admins (security.alert, critical) when the running panel version falls inside an advisory's affected range, once per advisory; - fires a one-time post-fix reminder after an upgrade crosses a fix boundary when the advisory declares a "## Post-upgrade actions" section (e.g. rotate JWT_SECRET_KEY after GHSA-rm3m-9mvw-68fh). Dedupe keys, the last-good feed copy, and the previously-seen version live in SystemSettings, so restarts never re-notify and outbound failures serve the cached feed. Opt out via the security_feed.enabled setting. Privacy page on serverkit.ai updated to disclose the daily call.
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
"""Security-advisory feed check.
|
|
|
|
Once a day (builtin job `security-feed`) the panel pulls the normalized GHSA
|
|
feed from https://serverkit.ai/api/security-feed.json (a cached proxy of the
|
|
repo's published GitHub Security Advisories) and:
|
|
|
|
1. notifies admins when the RUNNING version falls inside an advisory's
|
|
affected range ("you are vulnerable — update"), once per advisory;
|
|
2. after an upgrade crosses a fix boundary, fires a one-time post-fix
|
|
reminder when the advisory declares follow-up actions (e.g. rotating
|
|
JWT_SECRET_KEY after GHSA-rm3m-9mvw-68fh).
|
|
|
|
Dedupe + last-good feed + the previously-seen version live in SystemSettings,
|
|
so restarts never re-notify. Outbound failures are silent: a panel with no
|
|
internet access simply never notifies. Set the `security_feed.enabled`
|
|
setting to false to opt out of the outbound call entirely.
|
|
"""
|
|
import json
|
|
import logging
|
|
import urllib.request
|
|
|
|
from app import db
|
|
from app.models.system_settings import SystemSettings
|
|
from app.utils.version import compare_versions, get_panel_version
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FEED_URL = 'https://serverkit.ai/api/security-feed.json'
|
|
FEED_TIMEOUT = 8 # seconds
|
|
|
|
ENABLED_KEY = 'security_feed.enabled'
|
|
NOTIFIED_KEY = 'security_feed.notified' # json list of dedupe keys
|
|
LAST_VERSION_KEY = 'security_feed.last_version'
|
|
LAST_GOOD_KEY = 'security_feed.last_good' # json {"advisories": [...]}
|
|
|
|
_OPERATORS = {
|
|
'<': lambda c: c < 0,
|
|
'<=': lambda c: c <= 0,
|
|
'>': lambda c: c > 0,
|
|
'>=': lambda c: c >= 0,
|
|
'=': lambda c: c == 0,
|
|
'==': lambda c: c == 0,
|
|
}
|
|
|
|
|
|
def version_in_range(version, range_str):
|
|
"""Evaluate a GHSA-style version range (e.g. '< 1.7.68', '>= 1.2.0, < 1.5')
|
|
against `version`. Comma-separated clauses AND together. An empty or
|
|
unparseable range matches nothing (fail closed — never cry wolf)."""
|
|
if not version or not range_str:
|
|
return False
|
|
for clause in range_str.split(','):
|
|
clause = clause.strip()
|
|
if not clause:
|
|
continue
|
|
op, _, bound = clause.partition(' ')
|
|
pred = _OPERATORS.get(op.strip())
|
|
bound = bound.strip()
|
|
if pred is None or not bound:
|
|
return False
|
|
if not pred(compare_versions(version, bound)):
|
|
return False
|
|
return True
|
|
|
|
|
|
def is_enabled():
|
|
"""Opt-out switch for the outbound feed call. Default on."""
|
|
return SystemSettings.get(ENABLED_KEY, True) not in (False, 'false', '0')
|
|
|
|
|
|
def fetch_feed():
|
|
"""GET the advisory feed; fall back to the last-good copy on any failure.
|
|
|
|
Returns a list of advisory dicts (possibly empty). Never raises."""
|
|
try:
|
|
req = urllib.request.Request(
|
|
FEED_URL, headers={'User-Agent': 'ServerKit-SecurityFeed/1.0'})
|
|
with urllib.request.urlopen(req, timeout=FEED_TIMEOUT) as resp:
|
|
data = json.loads(resp.read().decode('utf-8'))
|
|
advisories = data.get('advisories') or []
|
|
SystemSettings.set(LAST_GOOD_KEY, {'advisories': advisories}, 'json')
|
|
db.session.commit()
|
|
return advisories
|
|
except Exception as e:
|
|
logger.debug(f'Security feed fetch failed (using last-good): {e}')
|
|
cached = SystemSettings.get(LAST_GOOD_KEY) or {}
|
|
return cached.get('advisories') or []
|
|
|
|
|
|
def _notify(alert_type, message):
|
|
from app.notifications.service import NotificationBusService
|
|
NotificationBusService.send(
|
|
'security.alert',
|
|
to='admins',
|
|
data={'alert_type': alert_type, 'message': message},
|
|
action_path='/settings/system',
|
|
action_label='Review update',
|
|
)
|
|
|
|
|
|
def check_security_feed(current_version=None):
|
|
"""Daily job entry point. Returns a small result dict for the job log."""
|
|
if not is_enabled():
|
|
return {'skipped': 'disabled'}
|
|
|
|
current = current_version or get_panel_version()
|
|
advisories = fetch_feed()
|
|
|
|
notified = set(SystemSettings.get(NOTIFIED_KEY, []) or [])
|
|
alerts = 0
|
|
|
|
# 1) Running version inside an affected range -> "update now" alert.
|
|
for adv in advisories:
|
|
ghsa = adv.get('ghsa')
|
|
affected = adv.get('affected')
|
|
if not ghsa or not affected:
|
|
continue
|
|
if f'{ghsa}:affected' in notified:
|
|
continue
|
|
if not version_in_range(current, affected):
|
|
continue
|
|
fixed = adv.get('fixed_in') or 'a newer release'
|
|
_notify(
|
|
f'Known vulnerability {ghsa}',
|
|
f'{adv.get("summary", "Security advisory")} — this panel runs '
|
|
f'{current}, which is in the affected range ({affected}). '
|
|
f'Fixed in {fixed}. Update with: sudo serverkit update. '
|
|
f'Details: {adv.get("url", "")}',
|
|
)
|
|
notified.add(f'{ghsa}:affected')
|
|
alerts += 1
|
|
|
|
# 2) Upgrade crossing: previously-seen version was affected, current is
|
|
# not -> one-time post-fix reminder (key rotation, etc.).
|
|
previous = SystemSettings.get(LAST_VERSION_KEY)
|
|
if previous and previous != current:
|
|
for adv in advisories:
|
|
ghsa = adv.get('ghsa')
|
|
affected = adv.get('affected')
|
|
post_fix = adv.get('post_fix_action')
|
|
if not ghsa or not affected or not post_fix:
|
|
continue
|
|
if f'{ghsa}:postfix' in notified:
|
|
continue
|
|
if version_in_range(previous, affected) and not version_in_range(current, affected):
|
|
_notify(
|
|
f'Post-update action required ({ghsa})',
|
|
f'This panel previously ran {previous}, which was affected '
|
|
f'by {ghsa} ({adv.get("summary", "security advisory")}). '
|
|
f'The update is installed — complete these follow-up steps: '
|
|
f'{post_fix}',
|
|
)
|
|
notified.add(f'{ghsa}:postfix')
|
|
alerts += 1
|
|
SystemSettings.set(LAST_VERSION_KEY, current)
|
|
SystemSettings.set(NOTIFIED_KEY, sorted(notified), 'json')
|
|
db.session.commit()
|
|
|
|
return {'advisories': len(advisories), 'alerts': alerts}
|