|
| 1 | +name: Fetch MSRC CVE Data |
| 2 | + |
| 3 | +on: |
| 4 | + schedule: |
| 5 | + - cron: '0 * * * *' # Hourly (at minute 0) |
| 6 | + workflow_dispatch: # Manual trigger from GitHub UI |
| 7 | + |
| 8 | +jobs: |
| 9 | + fetch: |
| 10 | + runs-on: ubuntu-latest |
| 11 | + permissions: |
| 12 | + contents: write |
| 13 | + |
| 14 | + steps: |
| 15 | + - name: Checkout |
| 16 | + uses: actions/checkout@v4 |
| 17 | + |
| 18 | + - name: Fetch and Parse 12 Months of MSRC Data |
| 19 | + run: | |
| 20 | + mkdir -p data |
| 21 | + python3 << 'EOF' |
| 22 | + import json |
| 23 | + import time |
| 24 | + import urllib.request |
| 25 | + import urllib.error |
| 26 | + from datetime import datetime, date, timezone |
| 27 | +
|
| 28 | + def fetch_with_retry(url, retries=3, delay=2): |
| 29 | + req = urllib.request.Request(url, headers={'Accept': 'application/json'}) |
| 30 | + for attempt in range(retries): |
| 31 | + try: |
| 32 | + with urllib.request.urlopen(req, timeout=15) as resp: |
| 33 | + return json.loads(resp.read().decode('utf-8')) |
| 34 | + except urllib.error.HTTPError as e: |
| 35 | + if e.code == 404: |
| 36 | + raise e |
| 37 | + print(f"-> HTTP Error {e.code} for {url}. Attempt {attempt + 1}/{retries}...") |
| 38 | + except Exception as e: |
| 39 | + print(f"-> Connection error: {e} for {url}. Attempt {attempt + 1}/{retries}...") |
| 40 | + |
| 41 | + if attempt < retries - 1: |
| 42 | + wait_time = delay * (attempt + 1) |
| 43 | + print(f"Waiting {wait_time}s before retry...") |
| 44 | + time.sleep(wait_time) |
| 45 | + |
| 46 | + raise Exception(f"Failed to fetch {url} after {retries} attempts") |
| 47 | +
|
| 48 | + # Generate last 12 months (oldest first, so newest overwrites in case of revisions) |
| 49 | + today = date.today() |
| 50 | + month_ids = [] |
| 51 | + for i in range(11, -1, -1): |
| 52 | + m = today.month - i |
| 53 | + y = today.year |
| 54 | + while m <= 0: |
| 55 | + m += 12 |
| 56 | + y -= 1 |
| 57 | + month_name = date(y, m, 1).strftime('%b') |
| 58 | + month_ids.append(f"{y}-{month_name}") |
| 59 | +
|
| 60 | + print("Generating data for months:", month_ids) |
| 61 | +
|
| 62 | + cves_map = {} |
| 63 | + |
| 64 | + for month_id in month_ids: |
| 65 | + url = f"https://api.msrc.microsoft.com/cvrf/v3.0/cvrf/{month_id}" |
| 66 | + print(f"Fetching: {url} ...") |
| 67 | + |
| 68 | + try: |
| 69 | + raw = fetch_with_retry(url) |
| 70 | + except urllib.error.HTTPError as e: |
| 71 | + if e.code == 404: |
| 72 | + print(f"-> Month {month_id} not published yet (404). Skipping.") |
| 73 | + continue |
| 74 | + except Exception as e: |
| 75 | + print(f"-> Error fetching {month_id}: {e}. Skipping.") |
| 76 | + continue |
| 77 | +
|
| 78 | + product_map = {} |
| 79 | + def walk(branch): |
| 80 | + if not branch: |
| 81 | + return |
| 82 | + for p in branch.get('FullProductName', []): |
| 83 | + product_map[p['ProductID']] = p['Value'] |
| 84 | + for b in branch.get('Branch', []): |
| 85 | + walk(b) |
| 86 | +
|
| 87 | + walk(raw.get('ProductTree', {})) |
| 88 | +
|
| 89 | + vulns = raw.get('Vulnerability', []) |
| 90 | + for v in vulns: |
| 91 | + cve_id = v.get('CVE', '') |
| 92 | + if not cve_id: |
| 93 | + continue |
| 94 | +
|
| 95 | + title = '' |
| 96 | + if v.get('Title') and v['Title'].get('Value'): |
| 97 | + title = v['Title']['Value'] |
| 98 | +
|
| 99 | + severity = '' |
| 100 | + for t in v.get('Threats', []): |
| 101 | + if t.get('Type') == 3: |
| 102 | + severity = (t.get('Description') or {}).get('Value', '') |
| 103 | + if severity: |
| 104 | + break |
| 105 | + if not severity: |
| 106 | + for t in v.get('Threats', []): |
| 107 | + if t.get('Type') == 0: |
| 108 | + severity = (t.get('Description') or {}).get('Value', '') |
| 109 | + if severity: |
| 110 | + break |
| 111 | +
|
| 112 | + cvss = None |
| 113 | + for s in v.get('CVSSScoreSets', []): |
| 114 | + if s.get('BaseScore') and ('3.1' in s.get('Vector','') or '3.0' in s.get('Vector','')): |
| 115 | + cvss = float(s['BaseScore']) |
| 116 | + break |
| 117 | + if cvss is None: |
| 118 | + for s in v.get('CVSSScoreSets', []): |
| 119 | + if s.get('BaseScore'): |
| 120 | + cvss = float(s['BaseScore']) |
| 121 | + break |
| 122 | +
|
| 123 | + products = [] |
| 124 | + for ps in v.get('ProductStatuses', []): |
| 125 | + for pid in ps.get('ProductID', []): |
| 126 | + pname = product_map.get(pid, '') |
| 127 | + if pname and pname not in products: |
| 128 | + products.append(pname) |
| 129 | +
|
| 130 | + product = products[0] if products else '' |
| 131 | +
|
| 132 | + date_str = '' |
| 133 | + revs = v.get('RevisionHistory', []) |
| 134 | + if revs: |
| 135 | + date_str = revs[0].get('Date', '')[:10] |
| 136 | +
|
| 137 | + exploited = False |
| 138 | + for t in v.get('Threats', []): |
| 139 | + if t.get('Type') == 1: |
| 140 | + desc = (t.get('Description') or {}).get('Value', '') |
| 141 | + if 'exploited:yes' in desc.lower().replace(' ', ''): |
| 142 | + exploited = True |
| 143 | + break |
| 144 | +
|
| 145 | + kb_articles = [] |
| 146 | + seen_kb = set() |
| 147 | + for r in v.get('Remediations', []): |
| 148 | + kb_num = (r.get('Description') or {}).get('Value', '').strip() |
| 149 | + if kb_num.upper().startswith('KB'): |
| 150 | + kb_num = kb_num[2:] |
| 151 | + url_str = r.get('URL', '').strip() |
| 152 | + fixed = r.get('FixedBuild', '').strip() |
| 153 | + subtype = r.get('SubType', '').strip() |
| 154 | + pids = r.get('ProductID', []) |
| 155 | + if not kb_num or not url_str or 'catalog' not in url_str.lower(): |
| 156 | + continue |
| 157 | + if kb_num in seen_kb: |
| 158 | + for existing in kb_articles: |
| 159 | + if existing['kb'] == kb_num: |
| 160 | + for pid in pids: |
| 161 | + pname = product_map.get(pid, '') |
| 162 | + if pname and pname not in existing['products']: |
| 163 | + existing['products'].append(pname) |
| 164 | + continue |
| 165 | + seen_kb.add(kb_num) |
| 166 | + kb_products = [product_map.get(pid,'') for pid in pids if product_map.get(pid,'')] |
| 167 | + kb_articles.append({ |
| 168 | + 'kb': kb_num, |
| 169 | + 'url': url_str, |
| 170 | + 'fixed_build': fixed, |
| 171 | + 'subtype': subtype, |
| 172 | + 'products': kb_products[:8], |
| 173 | + }) |
| 174 | + kb_articles.sort(key=lambda x: x['kb']) |
| 175 | +
|
| 176 | + cves_map[cve_id] = { |
| 177 | + 'cve_id': cve_id, |
| 178 | + 'title': title, |
| 179 | + 'severity': severity, |
| 180 | + 'cvss': cvss, |
| 181 | + 'product': product, |
| 182 | + 'products': products[:5], |
| 183 | + 'date': date_str, |
| 184 | + 'exploited': exploited, |
| 185 | + 'kb_articles': kb_articles, |
| 186 | + 'kb_count': len(kb_articles), |
| 187 | + } |
| 188 | +
|
| 189 | + parsed = list(cves_map.values()) |
| 190 | +
|
| 191 | + sev_order = {'Critical':0, 'Important':1, 'Moderate':2, 'Low':3} |
| 192 | + parsed.sort(key=lambda x: ( |
| 193 | + 0 if x['exploited'] else 1, |
| 194 | + sev_order.get(x['severity'], 4), |
| 195 | + -(x['cvss'] or 0), |
| 196 | + x['cve_id'] |
| 197 | + )) |
| 198 | +
|
| 199 | + now = datetime.now(timezone.utc) |
| 200 | + counts = { |
| 201 | + 'total': len(parsed), |
| 202 | + 'critical': sum(1 for x in parsed if x['severity'] == 'Critical'), |
| 203 | + 'important': sum(1 for x in parsed if x['severity'] == 'Important'), |
| 204 | + 'moderate': sum(1 for x in parsed if x['severity'] == 'Moderate'), |
| 205 | + 'low': sum(1 for x in parsed if x['severity'] == 'Low'), |
| 206 | + 'exploited': sum(1 for x in parsed if x['exploited']), |
| 207 | + 'patched': sum(1 for x in parsed if x['kb_count'] > 0), |
| 208 | + 'updated_at': now.strftime('%Y-%m-%d %H:%M UTC'), |
| 209 | + 'month': "Past 12 Months" |
| 210 | + } |
| 211 | +
|
| 212 | + output = {'meta': counts, 'cves': parsed} |
| 213 | +
|
| 214 | + with open('data/msrc_cves.json', 'w', encoding='utf-8') as f: |
| 215 | + json.dump(output, f, ensure_ascii=False, indent=2) |
| 216 | +
|
| 217 | + print(f"Success: {counts['total']} CVEs consolidated. Critical: {counts['critical']} | Exploited: {counts['exploited']} | Patched: {counts['patched']}") |
| 218 | + EOF |
| 219 | +
|
| 220 | + - name: Commit updated data |
| 221 | + run: | |
| 222 | + git config user.name "github-actions[bot]" |
| 223 | + git config user.email "github-actions[bot]@users.noreply.github.com" |
| 224 | + git add data/msrc_cves.json |
| 225 | + git diff --staged --quiet || git commit -m "chore: update rolling 12-month MSRC CVE data [$(date -u +'%Y-%m-%d %H:%M UTC')]" |
| 226 | + git push |
0 commit comments