#!/usr/bin/env python3 """Conservative SQLite index from CVEProject, NVD and OSV raw JSON. Do not mistake this index for a complete vulnerability matching engine. """ import argparse import hashlib import json import os import re import shutil import sqlite3 import subprocess import tempfile from datetime import datetime, timezone from pathlib import Path CVE = re.compile(r'^CVE-\d{4}-\d{4,}$', re.I) FILES = ('vulnerabilities','aliases','affected_packages','cpe_matches') def canonical(obj): return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':')) def sha(s): return hashlib.sha256(s.encode('utf-8')).hexdigest()[:32] def date(value): return value if isinstance(value, str) and value else None def get_git_revision(path): return subprocess.check_output(['git', '-C', str(path), 'rev-parse', 'HEAD'], text=True).strip() def input_files(repo): d = repo / 'data' if not d.exists(): return # Nur tatsächliche CVE-Datensätze einlesen if repo.name == 'vuln-cve': yield from sorted(d.rglob('CVE-*.json')) return # Andere Quellen unverändert verarbeiten yield from sorted(d.rglob('*.json')) def upsert_vuln(db, vid, published=None, modified=None, state=None, summary=None): if not vid or not (CVE.match(vid) or vid.startswith(('GHSA-','OSV-','PYSEC-','RUSTSEC-','GO-','DSA-','USN-'))): return db.execute('''INSERT INTO vulnerabilities(id,published,modified,state,summary) VALUES(?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET published=COALESCE(excluded.published,vulnerabilities.published), modified=COALESCE(excluded.modified,vulnerabilities.modified), state=COALESCE(excluded.state,vulnerabilities.state), summary=COALESCE(excluded.summary,vulnerabilities.summary)''', (vid, published, modified, state, summary[:500] if isinstance(summary,str) else None)) def process_cve(db, obj): if not isinstance(obj, dict): raise ValueError( f"Unexpected CVE JSON type: {type(obj).__name__}; " f"sample={str(obj)[:300]}" ) m = obj.get('cveMetadata') if not isinstance(m,dict): return False vid=m.get('cveId') if not isinstance(vid,str) or not CVE.match(vid): return False upsert_vuln(db,vid,m.get('datePublished'),m.get('dateUpdated'),m.get('state')) return True def walk_cpe(node, allow_simple=True): """Only emit independent leaf matches for simple non-negated OR nodes. Complex AND/NOT expressions must remain unsupported rather than cause false positives. """ if not isinstance(node,dict) or node.get('negate'): return if node.get('operator','OR').upper() != 'OR': return matches=node.get('cpeMatch') or [] if isinstance(matches,list): for match in matches: if isinstance(match,dict): yield match for child in node.get('children') or []: yield from walk_cpe(child) def process_nvd(db,obj): cve=obj.get('cve') if not isinstance(cve,dict): return False vid=cve.get('id') if not isinstance(vid,str) or not CVE.match(vid): return False upsert_vuln(db,vid,cve.get('published'),cve.get('lastModified'),cve.get('vulnStatus')) db.execute('DELETE FROM cpe_matches WHERE vulnerability_id=? AND source=?',(vid,'vuln-nvd')) for node in cve.get('configurations') or []: # NVD root configurations may contain nodes; a configuration with several # nodes can be an AND and is not safe to flatten. if not isinstance(node,dict) or node.get('operator','OR').upper() != 'OR' or node.get('negate'): continue for n in node.get('nodes') or []: for match in walk_cpe(n): criteria=match.get('criteria') if not isinstance(criteria,str): continue vals=(criteria, int(bool(match.get('vulnerable',False))), match.get('versionStartIncluding'), match.get('versionStartExcluding'), match.get('versionEndIncluding'),match.get('versionEndExcluding')) key=sha(canonical((vid,vals))) db.execute('''INSERT OR REPLACE INTO cpe_matches (id,vulnerability_id,criteria,vulnerable,version_start_including,version_start_excluding,version_end_including,version_end_excluding,source) VALUES(?,?,?,?,?,?,?,?,?)''',(key,vid,*vals,'vuln-nvd')) return True def process_osv(db,obj): vid=obj.get('id') if not isinstance(vid,str) or not vid: return False # Only capture OSV ids with a known prefix or valid CVE. if not (CVE.match(vid) or vid.startswith(('GHSA-','OSV-','PYSEC-','RUSTSEC-','GO-','DSA-','USN-'))): return False upsert_vuln(db,vid,date(obj.get('published')),date(obj.get('modified')), 'WITHDRAWN' if obj.get('withdrawn') else None,obj.get('summary')) db.execute('DELETE FROM affected_packages WHERE vulnerability_id=? AND source=?',(vid,'vuln-osv')) db.execute('DELETE FROM aliases WHERE vulnerability_id=?',(vid,)) for a in obj.get('aliases') or []: if isinstance(a,str) and a: db.execute('INSERT OR IGNORE INTO aliases(alias,vulnerability_id) VALUES(?,?)',(a,vid)) for i,a in enumerate(obj.get('affected') or []): if not isinstance(a,dict): continue pkg=a.get('package') or {} if not isinstance(pkg,dict): continue eco=pkg.get('ecosystem'); name=pkg.get('name') ranges=a.get('ranges') or [] versions=a.get('versions') or [] if not isinstance(ranges,list) or not isinstance(versions,list): continue if not isinstance(eco,str) or not isinstance(name,str): continue # Preserve version/range event semantics; no lexicographic comparisons. key=sha(canonical((vid,i,eco,name,ranges,versions))) db.execute('INSERT OR REPLACE INTO affected_packages VALUES(?,?,?,?,?,?,?)', (key,vid,eco,name,canonical(ranges),canonical(versions),'vuln-osv')) return True def stream_source(db,name,path): count=0; bad=0 for file in input_files(path): try: with file.open('r',encoding='utf-8') as f: obj=json.load(f) if name=='vuln-cve': accepted=process_cve(db,obj) elif name=='vuln-nvd': accepted=process_nvd(db,obj) elif name=='vuln-osv': accepted=process_osv(db,obj) else: accepted=False count+=int(accepted) except (ValueError,UnicodeError,TypeError,sqlite3.Error) as exc: bad+=1 if bad<=5: print(f'INVALID {name} {file}: {exc}',flush=True) if bad: raise RuntimeError(f'{name}: {bad} invalid records; database not published') return count def dump_table(db,table): # memory efficient cursor; a canonical row object for deltas cols=[r[1] for r in db.execute(f'PRAGMA table_info({table})')] for row in db.execute(f'SELECT * FROM {table} ORDER BY 1'): yield row[0],dict(zip(cols,row)) def make_delta(old_path,new_db,path): old=sqlite3.connect(old_path) with path.open('w',encoding='utf-8') as f: for table in FILES: prev=dump_table(old,table); curr=dump_table(new_db,table) a=next(prev,None); b=next(curr,None) while a is not None or b is not None: if a is not None and (b is None or a[0]