Initial Alpine security mirror
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
name: Alpine Security Database Mirror
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "07 */6 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: vuln-runner
|
||||||
|
timeout-minutes: 180
|
||||||
|
steps:
|
||||||
|
- name: Alpine secdb synchronisieren
|
||||||
|
env:
|
||||||
|
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
CACHE_ROOT: /var/lib/act_runner/cache/vuln-alpine
|
||||||
|
REPO_URL: https://git.apex-project.de/VULN/vuln-alpine.git
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
mkdir -p "$CACHE_ROOT"
|
||||||
|
# Bootstrap is embedded to avoid a second checkout just to read a shell script.
|
||||||
|
if [[ ! -d "$CACHE_ROOT/repo/.git" ]]; then
|
||||||
|
: "${GITEA_JOB_TOKEN:?GITEA_TOKEN fehlt}"
|
||||||
|
fi
|
||||||
|
AUTH_HEADER="Authorization: Basic $(printf 'oauth2:%s' "$GITEA_JOB_TOKEN" | base64 | tr -d '\n')"
|
||||||
|
export GIT_CONFIG_COUNT=1
|
||||||
|
export GIT_CONFIG_KEY_0=http.extraheader
|
||||||
|
export GIT_CONFIG_VALUE_0="$AUTH_HEADER"
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
if [[ ! -d "$CACHE_ROOT/repo/.git" ]]; then
|
||||||
|
git clone --depth 1 --branch main "$REPO_URL" "$CACHE_ROOT/repo"
|
||||||
|
fi
|
||||||
|
bash "$CACHE_ROOT/repo/scripts/run-cached.sh"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.tmp
|
||||||
|
.pytest_cache/
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# vuln-alpine – Gitea mirror
|
||||||
|
|
||||||
|
Vollständiger Mirror der offiziellen Alpine Security Database im unveränderten JSON-Format.
|
||||||
|
|
||||||
|
## Offizielle Quelle
|
||||||
|
|
||||||
|
https://secdb.alpinelinux.org/ – das ältere `alpinelinux/alpine-secdb` GitHub-Repository ist **deprecated**.
|
||||||
|
|
||||||
|
Der Downloader entdeckt Verzeichnisse `v3.5` und neuer sowie `edge` automatisch und speichert – wenn im offiziellen Verzeichnis vorhanden – `main.json` und `community.json` unter `data/secdb/<release>/`. Der Start ab `v3.5` bewahrt historische Daten rund um 2017.
|
||||||
|
|
||||||
|
## Installation in Gitea
|
||||||
|
|
||||||
|
1. Leeres Repository `VULN/vuln-alpine` auf https://git.apex-project.de erstellen (Branch `main`).
|
||||||
|
2. Diese Dateien in dein neues Repository committen und pushen.
|
||||||
|
3. Auf der Gitea-VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y git python3 util-linux ca-certificates coreutils
|
||||||
|
sudo install -d -o act_runner -g act_runner -m 700 /var/lib/act_runner/cache/vuln-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Sicherstellen, dass der Runner das Label `vuln-runner` besitzt und `GITEA_TOKEN` Schreibzugriff auf `VULN/vuln-alpine` hat.
|
||||||
|
5. Action **Alpine Security Database Mirror** starten. Danach automatischer Zeitplan alle sechs Stunden zur Minute 07 (Gitea-Zeitzone/CRON-Konfiguration beachten).
|
||||||
|
|
||||||
|
## Verhalten
|
||||||
|
|
||||||
|
- Kein `actions/checkout`, sondern persistenter Checkout in `/var/lib/act_runner/cache/vuln-alpine/repo`.
|
||||||
|
- HTTP ETag und Last-Modified im lokalen `http-cache.json` (nicht in Git).
|
||||||
|
- Bei jeder Synchronisierung wird der offizielle Verzeichnisindex abgerufen. Neue Versionen werden automatisch entdeckt.
|
||||||
|
- JSON wird geparst und strukturell validiert; Originalbytes werden unverändert gespeichert und per SHA256 im `state/manifest.json` dokumentiert.
|
||||||
|
- Bei HTTP-/Validierungsfehlern wird kein Git-Commit durchgeführt. Der nächste Lauf setzt erneut an.
|
||||||
|
- Historische Daten werden bewusst **nicht** gelöscht, wenn eine alte Version aus dem Index verschwindet. Datenkorrekturen aus vorhandenen Quellen werden synchronisiert.
|
||||||
|
- Bei unverändertem Inhalt entsteht kein Commit.
|
||||||
|
- Git-Zugangsdaten werden nur als kurzlebige Environment-Konfiguration an Git-Unterprozesse übergeben.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
bash -n scripts/run-cached.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
|
||||||
|
Runner läuft im `host`-Modus unter `act_runner`. Damit können Workflows beliebige Programme mit den Rechten dieses Benutzers auf dem Gitea-Host ausführen. Nur vertrauenswürdige Workflows zulassen.
|
||||||
|
|
||||||
|
Lizenz/Weiterverteilung: Die Bedingungen der Datenquelle vor einer öffentlichen Veröffentlichung prüfen.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
: "${GITEA_JOB_TOKEN:?GITEA_JOB_TOKEN fehlt}"
|
||||||
|
CACHE_ROOT="${CACHE_ROOT:-/var/lib/act_runner/cache/vuln-alpine}"
|
||||||
|
REMOTE="${REPO_URL:-https://git.apex-project.de/VULN/vuln-alpine.git}"
|
||||||
|
mkdir -p "$CACHE_ROOT"
|
||||||
|
exec 9>"$CACHE_ROOT/.sync.lock"
|
||||||
|
flock -w 3600 9 || { echo 'Another Alpine mirror job still running' >&2; exit 1; }
|
||||||
|
# Authentication only in the environment of git subprocesses, never in remote URLs or .git/config.
|
||||||
|
AUTH_HEADER="Authorization: Basic $(printf 'oauth2:%s' "$GITEA_JOB_TOKEN" | base64 | tr -d '\n')"
|
||||||
|
export GIT_CONFIG_COUNT=1
|
||||||
|
export GIT_CONFIG_KEY_0=http.extraheader
|
||||||
|
export GIT_CONFIG_VALUE_0="$AUTH_HEADER"
|
||||||
|
export GIT_TERMINAL_PROMPT=0
|
||||||
|
REPO="$CACHE_ROOT/repo"
|
||||||
|
if [[ ! -d "$REPO/.git" ]]; then
|
||||||
|
git clone --depth 1 --branch main "$REMOTE" "$REPO"
|
||||||
|
else
|
||||||
|
git -C "$REPO" remote set-url origin "$REMOTE"
|
||||||
|
git -C "$REPO" fetch --depth 1 origin main
|
||||||
|
git -C "$REPO" reset --hard FETCH_HEAD
|
||||||
|
fi
|
||||||
|
cd "$REPO"
|
||||||
|
python3 scripts/sync.py --repo "$REPO" --http-cache "$CACHE_ROOT/http-cache.json" --min-release 5
|
||||||
|
python3 scripts/validate.py
|
||||||
|
git config user.name 'Vulnerability Bot'
|
||||||
|
git config user.email 'vuln-bot@users.noreply.local'
|
||||||
|
git add -A data/secdb state/manifest.json
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo 'No Alpine changes; nothing to push'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "Update Alpine secdb $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
# If another actor changed main during this run, do not force-push.
|
||||||
|
git push origin HEAD:main
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Mirror upstream Alpine secdb JSON files, preserving the upstream bytes."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
BASE='https://secdb.alpinelinux.org/'
|
||||||
|
RELEASE=re.compile(r'^v3\.(\d+)/$')
|
||||||
|
BRANCH_FILES={'main.json','community.json'}
|
||||||
|
|
||||||
|
class Links(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(); self.hrefs=[]
|
||||||
|
def handle_starttag(self,tag,attrs):
|
||||||
|
if tag.lower()=='a':
|
||||||
|
for k,v in attrs:
|
||||||
|
if k.lower()=='href' and v is not None: self.hrefs.append(v)
|
||||||
|
|
||||||
|
def fetch(url, cache=None, *, attempts=5):
|
||||||
|
"""Returns (bytes | None for HTTP 304, headers)."""
|
||||||
|
if not url.startswith(BASE): raise ValueError('Untrusted origin: '+url)
|
||||||
|
headers={'User-Agent':'vuln-alpine-gitea-mirror/1.0','Accept':'application/json, text/html;q=0.8, */*;q=0.5'}
|
||||||
|
if cache:
|
||||||
|
if cache.get('etag'): headers['If-None-Match']=cache['etag']
|
||||||
|
if cache.get('last_modified'): headers['If-Modified-Since']=cache['last_modified']
|
||||||
|
for i in range(attempts):
|
||||||
|
try:
|
||||||
|
with urlopen(Request(url,headers=headers),timeout=75) as r:
|
||||||
|
data=r.read(15*1024*1024+1)
|
||||||
|
if len(data)>15*1024*1024: raise ValueError('Source file too large: '+url)
|
||||||
|
return data, {'etag':r.headers.get('ETag',''), 'last_modified':r.headers.get('Last-Modified','')}
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code==304: return None,cache or {}
|
||||||
|
if e.code not in (408,429,500,502,503,504) or i==attempts-1: raise
|
||||||
|
retry=e.headers.get('Retry-After','')
|
||||||
|
delay=min(int(retry),120) if retry.isdecimal() else min(2**i*3,60)
|
||||||
|
except (URLError, TimeoutError, ConnectionError):
|
||||||
|
if i==attempts-1: raise
|
||||||
|
delay=min(2**i*3,60)
|
||||||
|
time.sleep(delay)
|
||||||
|
raise RuntimeError('Unreachable')
|
||||||
|
|
||||||
|
def links(blob):
|
||||||
|
parser=Links(); parser.feed(blob.decode('utf-8')); return parser.hrefs
|
||||||
|
|
||||||
|
def release_links(root_html,min_release):
|
||||||
|
found=[]
|
||||||
|
for href in links(root_html):
|
||||||
|
m=RELEASE.fullmatch(href)
|
||||||
|
if m and int(m.group(1))>=min_release: found.append(href[:-1])
|
||||||
|
elif href=='edge/': found.append('edge')
|
||||||
|
return sorted(set(found),key=lambda x:(x=='edge',int(x.split('.')[1]) if x.startswith('v3.') else 999))
|
||||||
|
|
||||||
|
def available_files(branch_html):
|
||||||
|
return sorted(set(h for h in links(branch_html) if h in BRANCH_FILES))
|
||||||
|
|
||||||
|
def validate_json(blob):
|
||||||
|
obj=json.loads(blob)
|
||||||
|
if not isinstance(obj,dict) or not isinstance(obj.get('packages'),list):
|
||||||
|
raise ValueError('Unexpected Alpine secdb JSON schema: expected packages array')
|
||||||
|
return len(obj['packages'])
|
||||||
|
|
||||||
|
def atomic_write(path:Path,data:bytes):
|
||||||
|
path.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
fd,tmp=tempfile.mkstemp(prefix='.incoming-',dir=str(path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd,'wb') as out:
|
||||||
|
out.write(data);out.flush();os.fsync(out.fileno())
|
||||||
|
os.replace(tmp,path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp): os.unlink(tmp)
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
repo=Path(args.repo).resolve()
|
||||||
|
data_root=repo/'data'/'secdb'
|
||||||
|
state_dir=repo/'state';state_dir.mkdir(parents=True,exist_ok=True)
|
||||||
|
cachefile=Path(args.http_cache).resolve();cachefile.parent.mkdir(parents=True,exist_ok=True)
|
||||||
|
oldcache=json.loads(cachefile.read_text()) if cachefile.exists() else {}
|
||||||
|
newcache=dict(oldcache)
|
||||||
|
root_html,_=fetch(BASE)
|
||||||
|
branches=release_links(root_html,args.min_release)
|
||||||
|
if not branches: raise RuntimeError('No Alpine release branches discovered')
|
||||||
|
oldmanifest=state_dir/'manifest.json'
|
||||||
|
old=json.loads(oldmanifest.read_text()) if oldmanifest.exists() else {}
|
||||||
|
oldfiles=old.get('files',{})
|
||||||
|
records={}; updates=0; skipped=0
|
||||||
|
for branch in branches:
|
||||||
|
branch_url=urljoin(BASE,branch+'/')
|
||||||
|
branch_html,_=fetch(branch_url)
|
||||||
|
fnames=available_files(branch_html)
|
||||||
|
if not fnames:
|
||||||
|
raise RuntimeError('No secdb JSON files for '+branch)
|
||||||
|
for filename in fnames:
|
||||||
|
rel=f'{branch}/{filename}'
|
||||||
|
url=urljoin(branch_url,filename)
|
||||||
|
target=data_root/rel
|
||||||
|
cache=oldcache.get(rel) if target.is_file() else None
|
||||||
|
blob,meta=fetch(url,cache)
|
||||||
|
if blob is None:
|
||||||
|
if not target.is_file(): raise RuntimeError('304 without local file: '+rel)
|
||||||
|
digest=hashlib.sha256(target.read_bytes()).hexdigest()
|
||||||
|
count=oldfiles.get(rel,{}).get('packages')
|
||||||
|
if count is None: count=validate_json(target.read_bytes())
|
||||||
|
skipped+=1
|
||||||
|
else:
|
||||||
|
count=validate_json(blob)
|
||||||
|
digest=hashlib.sha256(blob).hexdigest()
|
||||||
|
if not target.is_file() or hashlib.sha256(target.read_bytes()).hexdigest()!=digest:
|
||||||
|
atomic_write(target,blob);updates+=1
|
||||||
|
else: skipped+=1
|
||||||
|
records[rel]={'sha256':digest,'packages':count,'source':url}
|
||||||
|
newcache[rel]=meta
|
||||||
|
print(f'{rel}: {"downloaded/changed" if blob is not None else "not modified"}; packages={count}',flush=True)
|
||||||
|
# Historical branches are retained, even if Alpine stops listing them.
|
||||||
|
# This prevents a temporary index regression from silently deleting data.
|
||||||
|
for rel,entry in oldfiles.items():
|
||||||
|
if rel not in records and (data_root/rel).is_file(): records[rel]=entry
|
||||||
|
newmanifest={'source':BASE,'min_release':args.min_release,'files':dict(sorted(records.items()))}
|
||||||
|
encoded=(json.dumps(newmanifest,indent=2,sort_keys=True,ensure_ascii=False)+'\n').encode()
|
||||||
|
if not oldmanifest.exists() or oldmanifest.read_bytes()!=encoded: atomic_write(oldmanifest,encoded)
|
||||||
|
cached=json.dumps(newcache,indent=2,sort_keys=True).encode()
|
||||||
|
atomic_write(cachefile,cached)
|
||||||
|
print(f'OK: {len(records)} files, {updates} changed, {skipped} unchanged',flush=True)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap=argparse.ArgumentParser()
|
||||||
|
ap.add_argument('--repo',default='.')
|
||||||
|
ap.add_argument('--http-cache',required=True,help='Persistent path outside Git')
|
||||||
|
ap.add_argument('--min-release',type=int,default=5,help='v3.5 onwards cover 2017 historical data')
|
||||||
|
args=ap.parse_args();run(args)
|
||||||
|
if __name__=='__main__': main()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate the complete Alpine mirror against the tracked manifest."""
|
||||||
|
import hashlib,json,sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root=Path(__file__).resolve().parents[1]
|
||||||
|
manifest=json.loads((root/'state/manifest.json').read_text())
|
||||||
|
files=manifest.get('files',{})
|
||||||
|
if not files: raise ValueError('No manifest entries')
|
||||||
|
for rel,entry in files.items():
|
||||||
|
p=(root/'data/secdb'/rel).resolve()
|
||||||
|
if not p.is_relative_to((root/'data/secdb').resolve()): raise ValueError('Unsafe path')
|
||||||
|
b=p.read_bytes()
|
||||||
|
if hashlib.sha256(b).hexdigest()!=entry['sha256']: raise ValueError('Hash mismatch: '+rel)
|
||||||
|
obj=json.loads(b)
|
||||||
|
if not isinstance(obj,dict) or not isinstance(obj.get('packages'),list): raise ValueError('Invalid secdb data: '+rel)
|
||||||
|
if len(obj['packages'])!=entry['packages']: raise ValueError('Package count mismatch: '+rel)
|
||||||
|
print(f'Validated {len(files)} Alpine security database files')
|
||||||
|
if __name__=='__main__':
|
||||||
|
try: main()
|
||||||
|
except Exception as e: print(f'Validation failed: {e}',file=sys.stderr);sys.exit(1)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import json,unittest,sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts'))
|
||||||
|
import sync
|
||||||
|
|
||||||
|
class MirrorTests(unittest.TestCase):
|
||||||
|
def test_release_discovery(self):
|
||||||
|
html=b'<a href="v3.4/">old</a><a href="v3.5/">v3.5</a><a href="v3.24/">new</a><a href="edge/">edge</a>'
|
||||||
|
self.assertEqual(sync.release_links(html,5),['v3.5','v3.24','edge'])
|
||||||
|
def test_file_names(self):
|
||||||
|
h=b'<a href="main.json">main</a><a href="community.json">community</a><a href="main.yaml">main yaml</a>'
|
||||||
|
self.assertEqual(sync.available_files(h),['community.json','main.json'])
|
||||||
|
def test_validate(self):
|
||||||
|
self.assertEqual(sync.validate_json(json.dumps({'packages':[{'pkg':{}}]}).encode()),1)
|
||||||
|
with self.assertRaises(ValueError): sync.validate_json(b'{}')
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
Reference in New Issue
Block a user