From 5b39c41498b61b2f208dbb167aac4fa76007a3a0 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 18:10:32 +0200 Subject: [PATCH 01/18] Add Chrome Releases blog integration to security-audit skill Integrate the Chrome Releases blog (chromereleases.googleblog.com) as an additional data source for the security-audit skill. This provides: - Earlier detection of Skia/ANGLE CVEs (blog publishes before NVD) - Explicit component tagging (Skia, ANGLE, Canvas, GPU, Fonts) - Direct milestone-to-CVE mapping from Chrome version numbers - Cross-validation of NVD findings Uses a two-pass approach for robustness: - Pass 1: Deterministic regex extraction from the known blog format - Pass 2: Raw text included for AI review (catches format changes) New files: - scripts/query-chrome-releases.py: RSS fetcher with keyword filtering, regex CVE extraction, and caching (no external dependencies) - references/chrome-releases.md: Reference doc for AI review instructions Updated files: - SKILL.md: Added Step 1.5 (Chrome Releases query), renumbered steps - references/skia-cve-resolution.md: Cross-reference instructions - references/third-party-deps.md: ANGLE coverage note - references/report-schema.md: chromeReleases section + source field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/SKILL.md | 81 +++- .../references/chrome-releases.md | 150 +++++++ .../references/report-schema.md | 64 +++ .../references/skia-cve-resolution.md | 16 + .../references/third-party-deps.md | 13 + .../scripts/query-chrome-releases.py | 423 ++++++++++++++++++ 6 files changed, 729 insertions(+), 18 deletions(-) create mode 100644 .agents/skills/security-audit/references/chrome-releases.md create mode 100755 .agents/skills/security-audit/scripts/query-chrome-releases.py diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md index a5a69cfdd0b..f9f31df713d 100644 --- a/.agents/skills/security-audit/SKILL.md +++ b/.agents/skills/security-audit/SKILL.md @@ -32,6 +32,7 @@ combined into a single unified report. ## Key References +- **[references/chrome-releases.md](references/chrome-releases.md)** — Chrome Releases blog: RSS query, two-pass extraction (regex + AI review), cross-referencing with NVD - **[references/skia-cve-resolution.md](references/skia-cve-resolution.md)** — Skia core CVE pipeline (NVD → Bug ID → Commit → Branch → Cherry-pick → Reachability). **The Skia process is fine-grained — read this before auditing Skia.** - **[references/third-party-deps.md](references/third-party-deps.md)** — Third-party CVE process (libpng, freetype, harfbuzz, etc.): version verification, fix-commit ancestry, known false positives - **[references/cg-alerts.md](references/cg-alerts.md)** — Component Governance alerts: ADO pipeline queries, Docker container CVEs, fix locations @@ -46,15 +47,16 @@ combined into a single unified report. ## Workflow 1. Search GitHub issues/PRs (all deps including Skia) -2. Verify dependency versions from submodule/DEPS/headers (NOT cgmanifest.json) -3. Audit Skia core CVEs — see [Skia CVE Resolution](references/skia-cve-resolution.md) -4. Audit third-party dependency CVEs — see [Third-Party Deps](references/third-party-deps.md) -5. Query Component Governance alerts — see [CG Alerts](references/cg-alerts.md) -6. Check false positives -7. Assemble structured JSON report -8. Validate report (`validate-security-audit.py`) -9. Render HTML (`render-security-audit.py`) -10. Present markdown summary to user +2. Query Chrome Releases blog (`query-chrome-releases.py`) — see [Chrome Releases](references/chrome-releases.md) +3. Verify dependency versions from submodule/DEPS/headers (NOT cgmanifest.json) +4. Audit Skia core CVEs — see [Skia CVE Resolution](references/skia-cve-resolution.md) +5. Audit third-party dependency CVEs — see [Third-Party Deps](references/third-party-deps.md) +6. Query Component Governance alerts — see [CG Alerts](references/cg-alerts.md) +7. Check false positives +8. Assemble structured JSON report +9. Validate report (`validate-security-audit.py`) +10. Render HTML (`render-security-audit.py`) +11. Present markdown summary to user --- @@ -70,7 +72,50 @@ Search PRs in both `mono/SkiaSharp` and `mono/skia` for dependency updates alrea --- -### Step 2: Verify Dependency Versions +### Step 1.5: Query Chrome Releases Blog + +> 🔍 The Chrome Releases blog often discloses Skia CVEs **before NVD** processes them. +> This step provides early detection and cross-validation. + +**See [references/chrome-releases.md](references/chrome-releases.md)** for full details on the +data source, script usage, and AI review instructions. + +#### Run the script + +```bash +python3 .agents/skills/security-audit/scripts/query-chrome-releases.py \ + --verbose --output output/ai/chrome-releases-cache.json +``` + +This takes ~10-30 seconds (fetches RSS feed pages). Cache is reused if < 24 hours old. + +#### Two-pass review + +1. **Deterministic (regex):** Read `structured_cves[]` from the JSON output. These are + high-confidence CVEs extracted from the known blog format. Each has a CVE ID, severity, + component, bug ID, and milestone already parsed. + +2. **AI review (broad):** Scan `posts[].text_content` for anything the regex missed: + - CVE mentions not captured by regex (format variations, line breaks) + - Indirect Skia references ("type confusion in Rendering") + - Wild exploitation notices (highest priority!) + - Related component CVEs (GPU, Compositing) that may involve Skia code + +#### Cross-reference with NVD (Step 3) + +After the NVD query in Step 3, compare results: + +| Chrome Releases | NVD | Interpretation | +|-----------------|-----|----------------| +| ✅ Found | ✅ Found | Normal — use NVD CVSS, Chrome Releases for milestone | +| ✅ Found | ❌ Not found | **Early disclosure** — NVD may be delayed. Use Chrome severity. | +| ❌ Not found | ✅ Found | Vendor bulletin CVE (Android/Huawei) — not in Chrome stable | + +Set the `source` field on each CVE object: `"both"`, `"chrome_releases"`, or `"nvd"`. + +--- + +### Step 3: Verify Dependency Versions > ⚠️ **CRITICAL: Never trust `cgmanifest.json` blindly.** Always verify versions against the > actual submodule, DEPS file, and source headers. cgmanifest.json is manually maintained @@ -156,7 +201,7 @@ Report mismatches as findings. --- -### Step 3: Audit Skia Core CVEs +### Step 4: Audit Skia Core CVEs > 🛑 Skia is the product, not just a dependency. Every Skia CVE must be resolved to a > specific fix commit, branch, cherry-pick test, and reachability assessment. Classification @@ -176,7 +221,7 @@ process**, including: --- -### Step 4: Audit Third-Party Dependency CVEs +### Step 5: Audit Third-Party Dependency CVEs For libpng, freetype, harfbuzz, libexpat, brotli, zlib, libjpeg-turbo, libwebp, ANGLE submodules, etc. @@ -191,7 +236,7 @@ submodules, etc. --- -### Step 5: Query Component Governance Alerts +### Step 6: Query Component Governance Alerts CG scans Docker container images and build-time deps from both ADO pipelines. CG alerts are invisible to GitHub Issues and NVD searches alone. @@ -212,7 +257,7 @@ invisible to GitHub Issues and NVD searches alone. --- -### Step 6: Check False Positives +### Step 7: Check False Positives Before flagging anything, verify the CVE actually affects SkiaSharp. @@ -231,7 +276,7 @@ Before flagging anything, verify the CVE actually affects SkiaSharp. --- -### Step 7: Assemble Structured JSON Report +### Step 8: Assemble Structured JSON Report > 🛑 **MANDATORY:** The audit MUST produce a JSON file conforming to > [references/report-schema.md](references/report-schema.md). This is the machine-readable @@ -263,7 +308,7 @@ Save as `output/ai/security-audit-{date}.json`. --- -### Step 8: Validate Report +### Step 9: Validate Report > 🛑 **MANDATORY:** Always validate before rendering. Fix any errors reported. @@ -277,7 +322,7 @@ Warnings are informational — errors must be fixed before proceeding. --- -### Step 9: Render HTML Report +### Step 10: Render HTML Report > 🛑 **MANDATORY:** Always generate the HTML report. The human needs a readable dashboard. @@ -305,7 +350,7 @@ Present the output path to the user: --- -### Step 10: Present Markdown Summary +### Step 11: Present Markdown Summary After generating JSON and HTML, present a concise markdown summary to the user in the conversation (using the report-template.md format). This is in ADDITION to the JSON+HTML diff --git a/.agents/skills/security-audit/references/chrome-releases.md b/.agents/skills/security-audit/references/chrome-releases.md new file mode 100644 index 00000000000..2340b33844b --- /dev/null +++ b/.agents/skills/security-audit/references/chrome-releases.md @@ -0,0 +1,150 @@ +# Chrome Releases Blog + +The [Chrome Releases blog](https://chromereleases.googleblog.com/) is Google's official +announcement channel for Chrome stable, beta, and dev channel updates. Each "Stable Channel +Update for Desktop" post lists **all security fixes** included in the release, with: + +- Severity (Critical / High / Medium / Low) +- CVE ID +- Component affected (e.g., "Skia", "ANGLE", "Canvas", "GPU") +- Bug tracker ID (links to `issues.chromium.org`) +- Reporter and date reported +- Bounty amount (if applicable) + +## Why This Matters for SkiaSharp + +1. **Earlier than NVD:** Chrome discloses CVEs in the blog *immediately* on release. NVD may + take days or weeks to populate the full entry (CVSS score, references, CPE data). + +2. **Explicit component tagging:** The blog names the affected component directly ("Heap buffer + overflow in **Skia**"). NVD descriptions are often generic ("Google Chrome before X allows..."). + +3. **Milestone precision:** The blog post's Chrome version (e.g., `147.0.7727.137`) immediately + tells us the fix milestone (147). This is more reliable than NVD's `versionEndExcluding`. + +4. **ANGLE coverage:** ANGLE CVEs appear in the same format, supplementing NVD searches. + +## Data Access + +- **Atom/RSS feed:** `https://chromereleases.googleblog.com/feeds/posts/default` +- **Pagination:** 25 entries per page, `?start-index=26&max-results=25` for page 2, etc. +- **No authentication required** +- **No documented rate limit** (be polite: 0.5s between pages) + +## Script Usage + +```bash +# Standard: query last 6 months, cache results +python3 .agents/skills/security-audit/scripts/query-chrome-releases.py \ + --output output/ai/chrome-releases-cache.json + +# Verbose: also print extracted CVEs +python3 .agents/skills/security-audit/scripts/query-chrome-releases.py \ + --verbose --output output/ai/chrome-releases-cache.json + +# Force re-fetch (ignores 24h cache) +python3 .agents/skills/security-audit/scripts/query-chrome-releases.py \ + --force --output output/ai/chrome-releases-cache.json +``` + +## Output Structure + +The script produces two types of data: + +### `structured_cves[]` — Deterministic regex extraction + +High-confidence CVE entries extracted from the known blog format. Each has: + +| Field | Description | +|-------|-------------| +| `cve_id` | CVE identifier (e.g., `CVE-2026-7353`) | +| `severity` | Critical / High / Medium / Low | +| `component` | Affected component (Skia, ANGLE, Canvas, etc.) | +| `description` | Brief vulnerability description | +| `bug_id` | Chromium issue tracker ID | +| `bug_url` | Full URL to `issues.chromium.org` | +| `chrome_version` | Chrome version that includes the fix | +| `milestone` | Chrome milestone number (e.g., 147) | +| `bounty` | Bug bounty amount (null if N/A) | +| `reporter` | Who reported the vulnerability | +| `date_reported` | When it was reported (YYYY-MM-DD) | +| `blog_post_url` | Link to the blog post | +| `extraction` | Always `"regex"` | + +### `posts[]` — Raw text for AI review + +Full text content of each keyword-matching post. The AI agent reviews these to find: + +- CVEs the regex missed (format variations, line breaks, unusual formatting) +- Indirect Skia references (e.g., "type confusion in Rendering" that actually involves Skia) +- Context about severity and exploitation status ("Google is aware that an exploit exists in the wild") +- Related component CVEs (GPU, Compositing) that may involve Skia code paths + +## AI Review Instructions + +When the security-audit skill runs this step, the AI agent should: + +1. **Read `structured_cves[]` first** — these are the confirmed, high-confidence results. + +2. **Scan each post's `text_content`** looking for: + - Any CVE IDs not already in `structured_cves[]` + - Mentions of Skia, ANGLE, Canvas, Fonts, GPU that might indicate relevance + - Wild exploitation notices ("exploit exists in the wild") — these are HIGH PRIORITY + - Version/milestone information for context + +3. **For each additional CVE found by AI review**, add it with `"extraction": "ai_review"`. + +4. **Cross-reference with NVD results:** + - CVE in both Chrome Releases AND NVD → `source: "both"` (most common) + - CVE in Chrome Releases but NOT NVD → `source: "chrome_releases"` (early disclosure, NVD may be delayed) + - CVE in NVD but NOT Chrome Releases → `source: "nvd"` (likely vendor bulletin / Android CVE) + +5. **Priority boost:** CVEs marked as exploited in the wild get severity boost regardless of CVSS. + +## Relevant Components + +When filtering for SkiaSharp relevance, these components are important: + +| Component | Relevance | +|-----------|-----------| +| **Skia** | Direct — part of our submodule | +| **ANGLE** | Direct — separate native component for WinUI | +| **Canvas** | High — HTML Canvas uses Skia under the hood | +| **Fonts** | High — font rendering uses Skia/FreeType/HarfBuzz | +| **GPU** | Medium — GPU compositing may involve Skia paths | +| **Compositing** | Medium — may involve Skia rendering | +| **WebGL** | Low — WebGL uses ANGLE, potentially relevant | +| **Vulkan** | Low — Vulkan backend in Skia (compiled out in most SkiaSharp builds) | + +## Typical Blog Post Format + +For AI context, here's the typical structure (may evolve over time): + +``` +The Stable channel has been updated to VERSION for Windows/Mac and VERSION for Linux... + +Security Fixes and Rewards + +Note: Access to bug details and links may be kept restricted until a majority of users +are updated with a fix. + +This update includes N security fixes. + +[$7000][494352590] Critical CVE-2026-7363: Use after free in Canvas. Reported by heapracer on 2026-03-19 +[N/A][493221953] Critical CVE-2026-7361: Use after free in iOS. Reported by Google on 2026-03-16 +[$16000][493955227] High CVE-2026-7333: Use after free in GPU. Reported by c6eed09fc8b174b0f3eebedcceb1e792 on 2026-03-19 +... + +We would also like to thank all security researchers that worked with us during the +development cycle to prevent security bugs from ever reaching the stable channel. + +As usual, our ongoing internal security work was responsible for a wide range of fixes... +``` + +**Format notes:** +- Bounty may be `N/A`, `$AMOUNT`, or `[TBD]` +- Bug ID is always numeric (links to issues.chromium.org) +- Severity is always one of: Critical, High, Medium, Low +- Component appears after the colon in the description +- "Reported by Google" means internally found (no bounty) +- Some posts mention "exploit exists in the wild" — flag these immediately diff --git a/.agents/skills/security-audit/references/report-schema.md b/.agents/skills/security-audit/references/report-schema.md index 8ef6122c4b9..08977914b78 100644 --- a/.agents/skills/security-audit/references/report-schema.md +++ b/.agents/skills/security-audit/references/report-schema.md @@ -204,3 +204,67 @@ Array of action objects: | `dependency` | string | Yes | Which dependency | | `reason` | string | Yes | Why (cite CVE count/severity) | | `command` | string | No | CLI command if applicable (e.g., `bump libpng to 1.6.56`) | + +## `chromeReleases` — Chrome Releases Blog Data (Optional) + +When the Chrome Releases blog was queried (Step 1.5), include this section for traceability. +This section is optional — omit it only if the blog query was skipped or failed. + +```json +{ + "queriedAt": "2026-04-10T14:30:00Z", + "monthsQueried": 6, + "postsReviewed": 12, + "totalCvesExtracted": 45, + "skiaRelevantCves": 8, + "earlyDisclosures": [ + { + "cveId": "CVE-2026-8510", + "severity": "Critical", + "component": "Skia", + "description": "Integer overflow in Skia", + "bugId": "502636904", + "milestone": 148, + "blogPostUrl": "https://chromereleases.googleblog.com/...", + "inNvd": false, + "extraction": "regex" + } + ], + "cacheFile": "output/ai/chrome-releases-cache.json" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `queriedAt` | string | Yes | ISO timestamp of the query | +| `monthsQueried` | integer | Yes | How many months back were queried | +| `postsReviewed` | integer | Yes | Number of posts that matched keywords | +| `totalCvesExtracted` | integer | Yes | Total CVEs found across all posts (all components) | +| `skiaRelevantCves` | integer | Yes | CVEs in Skia-relevant components | +| `earlyDisclosures` | array | Yes | CVEs found in blog but NOT in NVD (may be empty) | +| `cacheFile` | string | Yes | Path to the cached JSON from the script | + +### CVE `source` Field (Updated) + +The `source` field on individual CVE objects in `findings[].cves[]` should indicate provenance: + +| Value | Meaning | +|-------|---------| +| `"NVD (Chrome CPE)"` | Found via NVD query with Chrome CPE match | +| `"NVD web search"` | Found via NVD keyword search | +| `"Chrome Releases blog"` | Found in Chrome Releases blog only (early disclosure) | +| `"NVD + Chrome Releases"` | Found in both sources (most common) | +| `"Android Security Bulletin"` | Vendor bulletin — Android | +| `"Huawei HarmonyOS Bulletin"` | Vendor bulletin — Huawei | +| `"Chromium severity rating (CVSS pending)"` | Severity from Chromium, NVD CVSS not yet published | + +### CVE `extraction` Field (New, Optional) + +For CVEs that came from Chrome Releases data, indicate how they were extracted: + +| Value | Meaning | +|-------|---------| +| `"regex"` | Deterministically extracted by the script's regex parser | +| `"ai_review"` | Found by AI reviewing the raw post text (format variation) | + +This helps track reliability and identify if the regex needs updating. diff --git a/.agents/skills/security-audit/references/skia-cve-resolution.md b/.agents/skills/security-audit/references/skia-cve-resolution.md index c3783d299ec..167035a2b12 100644 --- a/.agents/skills/security-audit/references/skia-cve-resolution.md +++ b/.agents/skills/security-audit/references/skia-cve-resolution.md @@ -53,6 +53,22 @@ curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=Skia&res > historical and already addressed. This typically yields 15-25 CVEs. If you find fewer than 10 > recent Skia CVEs, something is wrong — re-check your query and pagination. +### Cross-reference with Chrome Releases blog + +After the NVD query, compare results against the Chrome Releases data (from Step 1.5 of the +main workflow, cached in `output/ai/chrome-releases-cache.json`): + +- **CVEs in both NVD and Chrome Releases:** Normal. Use NVD for CVSS score, Chrome Releases + for the fix milestone and bug ID (may be more reliable than NVD references). +- **CVEs in Chrome Releases but NOT in NVD:** Early disclosures. NVD may lag by days/weeks. + Use Chrome's severity rating (Critical → ~9.8, High → ~8.8, Medium → ~6.5, Low → ~3.3). +- **CVEs in NVD but NOT in Chrome Releases:** Likely vendor-bulletin CVEs (Android, Huawei). + These are filed against Skia code paths in vendor forks — see [Non-Chrome Skia CVEs](#non-chrome-skia-cves-android--vendor-bulletins). + +The Chrome Releases blog provides the bug ID (`issues.chromium.org/issues/NNNNN`) directly +in the CVE listing. This is the same bug ID needed for Step 4 (commit resolution). Using it +from Chrome Releases can save the NVD reference URL parsing step when available. + > 🛑 **Every processed CVE must appear in the final JSON report** — as `affected`, > `already_fixed`, `false_positive`, or `needs_review`. Do NOT silently drop CVEs that are > "likely fixed" by milestone ordering. They must be verified and reported. diff --git a/.agents/skills/security-audit/references/third-party-deps.md b/.agents/skills/security-audit/references/third-party-deps.md index ae4d27bbf89..a3aa0220104 100644 --- a/.agents/skills/security-audit/references/third-party-deps.md +++ b/.agents/skills/security-audit/references/third-party-deps.md @@ -106,6 +106,19 @@ ANGLE has its own submodules that must also be tracked: Check that all of these are in `cgmanifest.json`. If missing, flag as a coverage gap. +### ANGLE in Chrome Releases Blog + +ANGLE CVEs also appear in the Chrome Releases blog with the component name "ANGLE" (e.g., +"Use after free in ANGLE"). The `query-chrome-releases.py` script captures these alongside +Skia CVEs. + +When cross-referencing, ANGLE CVEs from Chrome Releases provide: +- The exact Chrome milestone where the fix shipped +- The bug ID for commit resolution +- Severity rating before NVD publishes CVSS + +This supplements the NVD + web search approach for ANGLE vulnerabilities. + ## Reporting Third-Party Dep Findings For each third-party CVE, include: diff --git a/.agents/skills/security-audit/scripts/query-chrome-releases.py b/.agents/skills/security-audit/scripts/query-chrome-releases.py new file mode 100755 index 00000000000..47c918b65a6 --- /dev/null +++ b/.agents/skills/security-audit/scripts/query-chrome-releases.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" +Query the Chrome Releases blog for security-relevant posts mentioning Skia, ANGLE, +or other SkiaSharp-relevant components. + +Two-pass extraction: + Pass 1 (deterministic): Regex extracts structured CVE entries from the known blog format. + Pass 2 (AI review): Full post text is included for AI to find anything regex missed. + +Prerequisites: + - Python 3.8+ (uses urllib, no external dependencies) + +Usage: + # Standard: query last 6 months, write JSON cache + python3 query-chrome-releases.py --output output/ai/chrome-releases-cache.json + + # Query last 12 months + python3 query-chrome-releases.py --months 12 --output output/ai/chrome-releases-cache.json + + # Force re-fetch even if cache is fresh + python3 query-chrome-releases.py --force --output output/ai/chrome-releases-cache.json + + # Custom keywords + python3 query-chrome-releases.py --keywords CVE,Skia,ANGLE --output output/ai/chrome-releases-cache.json + +Output: + JSON written to --output file with: + - structured_cves[]: deterministically extracted CVEs (high confidence) + - posts[]: full text content of matching posts (for AI review) + Progress messages print to stdout. +""" + +import argparse +import html +import json +import os +import re +import sys +import time +import urllib.request +import urllib.error +from datetime import datetime, timedelta, timezone +from html.parser import HTMLParser +from xml.etree import ElementTree + + +# --- Constants --- + +FEED_URL = "https://chromereleases.googleblog.com/feeds/posts/default" +ATOM_NS = "{http://www.w3.org/2005/Atom}" + +DEFAULT_KEYWORDS = [ + "CVE", "security", "Skia", "ANGLE", "Canvas", "GPU", "Fonts", + "vulnerability", "heap buffer", "out of bounds", "use after free", + "integer overflow", "type confusion", +] + +# Components that are relevant to SkiaSharp +RELEVANT_COMPONENTS = [ + "Skia", "ANGLE", "Canvas", "Fonts", "GPU", "Graphics", + "Compositing", "WebGL", "Vulkan", +] + +# Regex for the known Chrome Releases CVE format: +# [$bounty][bug_id] Severity CVE-YYYY-NNNN: Description. Reported by X on YYYY-MM-DD +CVE_ENTRY_REGEX = re.compile( + r"\[([^\]]*)\]\s*\[(\d+)\]\s*" # [$bounty][bug_id] + r"(Critical|High|Medium|Low)\s+" # Severity + r"(CVE-\d{4}-\d{4,})\s*:\s*" # CVE ID + r"(.+?)\.\s*" # Description + r"Reported\s+by\s+(.+?)\s+on\s+" # Reporter + r"(\d{4}-\d{2}-\d{2})", # Date + re.IGNORECASE +) + +# Regex to extract Chrome version from post content +CHROME_VERSION_REGEX = re.compile( + r"(\d{2,3}\.\d+\.\d+\.\d+)" +) + +CACHE_MAX_AGE_HOURS = 24 + + +# --- HTML stripping --- + +class HTMLTextExtractor(HTMLParser): + """Strip HTML tags, decode entities, produce plain text.""" + + def __init__(self): + super().__init__() + self._text_parts = [] + self._skip = False + + def handle_starttag(self, tag, attrs): + if tag in ("script", "style", "template"): + self._skip = True + + def handle_endtag(self, tag): + if tag in ("script", "style", "template"): + self._skip = False + if tag in ("p", "br", "div", "li", "tr"): + self._text_parts.append("\n") + + def handle_data(self, data): + if not self._skip: + self._text_parts.append(data) + + def handle_entityref(self, name): + char = html.unescape(f"&{name};") + self._text_parts.append(char) + + def handle_charref(self, name): + char = html.unescape(f"&#{name};") + self._text_parts.append(char) + + def get_text(self): + return "".join(self._text_parts).strip() + + +def strip_html(html_content: str) -> str: + """Convert HTML to plain text.""" + extractor = HTMLTextExtractor() + try: + extractor.feed(html_content) + except Exception: + # Fallback: crude regex strip + text = re.sub(r"<[^>]+>", " ", html_content) + return html.unescape(text).strip() + return extractor.get_text() + + +# --- Feed fetching --- + +def fetch_feed_page(url: str, retries: int = 3) -> ElementTree.Element: + """Fetch a single Atom feed page and return the parsed XML root.""" + for attempt in range(retries): + try: + req = urllib.request.Request(url, headers={"User-Agent": "SkiaSharp-SecurityAudit/1.0"}) + with urllib.request.urlopen(req, timeout=30) as resp: + data = resp.read() + return ElementTree.fromstring(data) + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e: + if attempt < retries - 1: + wait = 2 ** attempt + print(f" ⚠️ Fetch failed ({e}), retrying in {wait}s...") + time.sleep(wait) + else: + raise + + +def fetch_all_posts(months: int) -> list: + """Fetch blog posts from the last N months via Atom feed pagination.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=months * 30) + posts = [] + url = FEED_URL + page = 0 + + while url: + page += 1 + print(f" Fetching feed page {page}...") + root = fetch_feed_page(url) + + entries = root.findall(f"{ATOM_NS}entry") + if not entries: + break + + reached_cutoff = False + for entry in entries: + published_el = entry.find(f"{ATOM_NS}published") + if published_el is None: + continue + + # Parse date (format: 2026-05-28T20:00:00.000-07:00) + pub_str = published_el.text.strip() + try: + # Handle various timezone formats + pub_date = datetime.fromisoformat(pub_str.replace("Z", "+00:00")) + except ValueError: + # Fallback: try without timezone + try: + pub_date = datetime.fromisoformat(pub_str[:19]).replace(tzinfo=timezone.utc) + except ValueError: + continue + + if pub_date < cutoff: + reached_cutoff = True + break + + # Extract post data + title_el = entry.find(f"{ATOM_NS}title") + content_el = entry.find(f"{ATOM_NS}content") + link_el = entry.find(f"{ATOM_NS}link[@rel='alternate']") + categories = entry.findall(f"{ATOM_NS}category") + + title = title_el.text if title_el is not None and title_el.text else "" + content_html = content_el.text if content_el is not None and content_el.text else "" + link = link_el.get("href", "") if link_el is not None else "" + labels = [c.get("term", "") for c in categories if c.get("term")] + + posts.append({ + "title": title, + "url": link, + "published": pub_date.strftime("%Y-%m-%d"), + "labels": labels, + "content_html": content_html, + }) + + if reached_cutoff: + break + + # Find next page link + next_link = root.find(f"{ATOM_NS}link[@rel='next']") + if next_link is not None: + url = next_link.get("href", "") + if not url: + break + # Brief pause to be polite + time.sleep(0.5) + else: + break + + print(f" Fetched {len(posts)} posts from {page} page(s)") + return posts + + +# --- Extraction --- + +def matches_keywords(text: str, keywords: list) -> list: + """Return list of keywords found in text (case-insensitive).""" + text_lower = text.lower() + return [kw for kw in keywords if kw.lower() in text_lower] + + +def extract_chrome_version(text: str) -> tuple: + """Extract Chrome version and milestone from post text.""" + # Look for version string near the top of the post + match = CHROME_VERSION_REGEX.search(text[:500]) + if match: + version = match.group(1) + milestone = int(version.split(".")[0]) + return version, milestone + return None, None + + +def is_relevant_component(description: str) -> bool: + """Check if a CVE description mentions a relevant component.""" + desc_lower = description.lower() + return any(comp.lower() in desc_lower for comp in RELEVANT_COMPONENTS) + + +def extract_structured_cves(text: str, post_url: str, chrome_version: str, milestone: int) -> list: + """Apply regex to extract structured CVE entries from post text.""" + cves = [] + for match in CVE_ENTRY_REGEX.finditer(text): + bounty = match.group(1).strip() + bug_id = match.group(2).strip() + severity = match.group(3).strip() + cve_id = match.group(4).strip() + description = match.group(5).strip() + reporter = match.group(6).strip() + date_reported = match.group(7).strip() + + # Determine component from description + component = "Unknown" + for comp in RELEVANT_COMPONENTS: + if comp.lower() in description.lower(): + component = comp + break + + cves.append({ + "cve_id": cve_id, + "severity": severity.capitalize(), + "component": component, + "description": description, + "bug_id": bug_id, + "bug_url": f"https://issues.chromium.org/issues/{bug_id}", + "chrome_version": chrome_version, + "milestone": milestone, + "bounty": bounty if bounty != "N/A" else None, + "reporter": reporter, + "date_reported": date_reported, + "blog_post_url": post_url, + "extraction": "regex", + }) + + return cves + + +# --- Main --- + +def process_posts(posts: list, keywords: list) -> dict: + """Process fetched posts: filter by keywords, extract CVEs, build output.""" + all_structured_cves = [] + matching_posts = [] + skia_relevant_cves = [] + + for post in posts: + text = strip_html(post["content_html"]) + matched = matches_keywords(text, keywords) + + if not matched: + continue + + chrome_version, milestone = extract_chrome_version(text) + + # Pass 1: Regex extraction + structured = extract_structured_cves( + text, post["url"], chrome_version, milestone + ) + all_structured_cves.extend(structured) + + # Filter for Skia-relevant CVEs + relevant = [c for c in structured if is_relevant_component(c["description"])] + skia_relevant_cves.extend(relevant) + + matching_posts.append({ + "title": post["title"], + "url": post["url"], + "published": post["published"], + "labels": post["labels"], + "chrome_version": chrome_version, + "milestone": milestone, + "text_content": text, + "matched_keywords": matched, + "regex_cve_count": len(structured), + "regex_relevant_cve_count": len(relevant), + }) + + return { + "fetched_at": datetime.now(timezone.utc).isoformat(), + "keywords_used": keywords, + "total_posts_scanned": len(posts), + "matching_posts_count": len(matching_posts), + "total_regex_cves": len(all_structured_cves), + "skia_relevant_regex_cves": len(skia_relevant_cves), + "structured_cves": skia_relevant_cves, + "all_structured_cves": all_structured_cves, + "posts": matching_posts, + } + + +def main(): + parser = argparse.ArgumentParser( + description="Query Chrome Releases blog for Skia/ANGLE security CVEs" + ) + parser.add_argument( + "--output", required=True, + help="Output JSON file path" + ) + parser.add_argument( + "--months", type=int, default=6, + help="How many months back to query (default: 6)" + ) + parser.add_argument( + "--keywords", type=str, default=None, + help="Comma-separated keywords to filter posts (default: built-in list)" + ) + parser.add_argument( + "--force", action="store_true", + help="Force re-fetch even if cache is fresh" + ) + parser.add_argument( + "--verbose", action="store_true", + help="Print extra progress information" + ) + args = parser.parse_args() + + # Check cache freshness + if not args.force and os.path.exists(args.output): + mtime = os.path.getmtime(args.output) + age_hours = (time.time() - mtime) / 3600 + if age_hours < CACHE_MAX_AGE_HOURS: + print(f"✅ Cache is fresh ({age_hours:.1f}h old, max {CACHE_MAX_AGE_HOURS}h). Use --force to re-fetch.") + # Load and print summary + with open(args.output) as f: + data = json.load(f) + print(f" {data.get('skia_relevant_regex_cves', 0)} Skia-relevant CVEs from {data.get('matching_posts_count', 0)} posts") + sys.exit(0) + + # Parse keywords + keywords = DEFAULT_KEYWORDS + if args.keywords: + keywords = [k.strip() for k in args.keywords.split(",") if k.strip()] + + print(f"🔍 Querying Chrome Releases blog (last {args.months} months)...") + print(f" Keywords: {', '.join(keywords[:8])}{'...' if len(keywords) > 8 else ''}") + + # Fetch posts + posts = fetch_all_posts(args.months) + + if not posts: + print("⚠️ No posts fetched. Check network connectivity.") + sys.exit(1) + + # Process + print(f"🔬 Processing {len(posts)} posts...") + result = process_posts(posts, keywords) + result["months_queried"] = args.months + + # Write output + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.output, "w") as f: + json.dump(result, f, indent=2) + + # Summary + print(f"\n✅ Chrome Releases query complete:") + print(f" Posts scanned: {result['total_posts_scanned']}") + print(f" Posts matching keywords: {result['matching_posts_count']}") + print(f" Total CVEs extracted (regex): {result['total_regex_cves']}") + print(f" Skia-relevant CVEs (regex): {result['skia_relevant_regex_cves']}") + print(f" Output: {args.output}") + + if args.verbose and result["structured_cves"]: + print(f"\n Skia-relevant CVEs found:") + for cve in result["structured_cves"]: + print(f" • {cve['cve_id']} ({cve['severity']}) — {cve['description']} [m{cve['milestone']}]") + + print(f"\n ℹ️ AI should also review the {result['matching_posts_count']} post(s) text_content") + print(f" for any CVEs the regex didn't catch (format variations, indirect mentions).") + + +if __name__ == "__main__": + main() From 60cd397c95260ea4681f050173205b0d5b4053b8 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:19:24 +0200 Subject: [PATCH 02/18] Add Chrome Releases expectations to security-audit evals - Added 6 Chrome Releases assertions to eval #1 (full audit) - Added new eval #4 specifically for Chrome Releases blog integration - Total: 4 evals with 56 expectations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/security-audit/evals/evals.json | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.agents/skills/security-audit/evals/evals.json b/.agents/skills/security-audit/evals/evals.json index f3f41b87589..f763db2a245 100644 --- a/.agents/skills/security-audit/evals/evals.json +++ b/.agents/skills/security-audit/evals/evals.json @@ -28,7 +28,13 @@ "The final markdown lists findings sorted by priority: user-reported+no-PR first, then undiscovered CVEs, then false positives", "The final markdown includes a Next Steps section with specific actionable recommendations", "For Skia CVEs, the report includes a Chrome-versioned CVEs table showing CVE ID, Fixed-In milestone, Severity, and Description", - "The report correctly identifies which Skia CVEs are 'potentially affected' (fix milestone > our verified milestone) vs 'already fixed' (fix milestone <= our verified milestone)" + "The report correctly identifies which Skia CVEs are 'potentially affected' (fix milestone > our verified milestone) vs 'already fixed' (fix milestone <= our verified milestone)", + "The audit runs query-chrome-releases.py to fetch Chrome Releases blog data", + "The JSON report contains a top-level 'chromeReleases' key with queriedAt, monthsQueried, postsReviewed, totalCvesExtracted, skiaRelevantCves, and structuredCves fields", + "The chromeReleases.postsReviewed is greater than 0 (blog was actually queried)", + "The chromeReleases.structuredCves array contains CVE objects with cveId, severity, component, bugId, and milestone fields", + "The chromeReleases.skiaRelevantCves count is greater than 0 (Skia-relevant CVEs were found)", + "The audit cross-references Chrome Releases CVEs with NVD results, using the source field ('chrome_releases', 'nvd', or 'both') on CVE objects in findings" ] }, { @@ -64,6 +70,24 @@ "The report uses the verified milestone (not cgmanifest.json) when classifying which Skia CVEs are potentially affected vs already fixed", "The report ends with prioritized, actionable next steps" ] + }, + { + "id": 4, + "prompt": "check the Chrome Releases blog for any Skia security issues we should know about", + "expected_output": "A report showing Skia-relevant CVEs extracted from the Chrome Releases blog, with structured data including CVE IDs, severities, components, bug IDs, and milestones. Should cross-reference with our current milestone to identify what's fixed vs what needs attention.", + "files": [], + "expectations": [ + "The audit runs query-chrome-releases.py (or equivalent RSS fetch) to get Chrome Releases blog data", + "The output includes CVEs with 'Skia' as the component (direct Skia CVEs)", + "The output includes CVEs from related components (ANGLE, GPU, Canvas, Fonts, WebGL)", + "Each structured CVE has a severity level (Critical, High, Medium, or Low)", + "Each structured CVE has a milestone number indicating which Chrome version fixed it", + "The audit identifies which CVEs are beyond our current milestone (m147) and thus potentially unpatched", + "The audit identifies CVEs already covered by our milestone as 'already fixed'", + "The output mentions the date range queried (approximately 6 months)", + "The audit uses both regex extraction (Pass 1) and AI review of raw text (Pass 2)", + "If any CVEs mention 'wild exploitation' or 'actively exploited', they are flagged as highest priority" + ] } ] } From 71346c933d8b984e44a4ab5c29b33fdd3ad85a0a Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:22:33 +0200 Subject: [PATCH 03/18] Add Chrome Releases section to HTML security report viewer Renders the chromeReleases data in a new card section with: - Severity breakdown cards (Critical/High/Medium/Low + above-milestone count) - CVE table grouped by component (only shows CVEs above current milestone) - Meta line with query stats (months, posts, totals) - Badge in header showing relevant CVE count Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/security-audit/scripts/viewer.html | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index 6399bfc6a01..fd49136c0db 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -98,6 +98,20 @@
Findings
+ + +
Next Steps
@@ -418,6 +432,75 @@
Findings
`; } +function renderChromeReleases(cr, milestone) { + if (!cr || !cr.skiaRelevantCves) return; + const section = document.getElementById('cr-section'); + section.style.display = ''; + + // Badge + const badge = document.getElementById('cr-badge'); + const aboveMilestone = (cr.structuredCves || []).filter(c => c.milestone > milestone).length; + badge.innerHTML = `${cr.skiaRelevantCves} relevant CVEs`; + + // Summary cards + const cards = document.getElementById('cr-summary-cards'); + const sevCounts = { Critical: 0, High: 0, Medium: 0, Low: 0 }; + (cr.structuredCves || []).forEach(c => { sevCounts[c.severity] = (sevCounts[c.severity] || 0) + 1; }); + const cardEntries = [ + ['Critical', sevCounts.Critical, 'danger'], + ['High', sevCounts.High, 'warning'], + ['Medium', sevCounts.Medium, 'info'], + ['Low', sevCounts.Low, 'secondary'], + ['Above m' + milestone, aboveMilestone, aboveMilestone > 0 ? 'danger' : 'success'], + ['Posts Reviewed', cr.postsReviewed, 'primary'], + ]; + cards.innerHTML = cardEntries.filter(([,count]) => count > 0).map(([label, count, color]) => ` +
+
+
${count}
+
${label}
+
+
`).join(''); + + // CVE table (group by component, show only those above our milestone) + const cves = (cr.structuredCves || []).filter(c => c.milestone > milestone); + if (cves.length > 0) { + const byComp = {}; + cves.forEach(c => { + if (!byComp[c.component]) byComp[c.component] = []; + byComp[c.component].push(c); + }); + const sevOrder = { Critical: 0, High: 1, Medium: 2, Low: 3 }; + const sorted = Object.entries(byComp).sort((a, b) => { + const aMin = Math.min(...a[1].map(c => sevOrder[c.severity] ?? 9)); + const bMin = Math.min(...b[1].map(c => sevOrder[c.severity] ?? 9)); + return aMin - bMin; + }); + + const table = document.getElementById('cr-cve-table'); + table.innerHTML = `
CVEs Above Current Milestone (m${milestone})
` + + sorted.map(([comp, compCves]) => { + const rows = compCves.sort((a, b) => (sevOrder[a.severity] ?? 9) - (sevOrder[b.severity] ?? 9)) + .map(c => { + const bugUrl = c.bugId ? `https://issues.chromium.org/issues/${esc(c.bugId)}` : '#'; + return ` + ${esc(c.severity)} + ${esc(c.cveId)} + m${c.milestone} + ${esc(c.bugId || 'N/A')} + `; + }).join(''); + return `
${esc(comp)} ${compCves.length} + + ${rows}
SeverityCVEFixed InBug
`; + }).join(''); + } + + // Meta + const meta = document.getElementById('cr-meta'); + meta.textContent = `Queried ${cr.monthsQueried} months (${cr.postsReviewed} posts) • ${cr.totalCvesExtracted} total CVEs extracted • ${cr.skiaRelevantCves} Skia-relevant`; +} + function renderCgAlerts(cg) { if (!cg || !cg.totalAlerts) return; const section = document.getElementById('cg-section'); @@ -516,8 +599,10 @@
Findings
if (typeof DATA === 'undefined') { document.body.innerHTML = '

No DATA found. JSON not injected.

'; return; } const d = DATA; const cgTotal = d.cgAlerts ? (d.cgAlerts.totalAlerts || 0) : 0; + const crTotal = d.chromeReleases ? (d.chromeReleases.skiaRelevantCves || 0) : 0; const subtitleParts = [`${d.meta.date}`, `Skia m${d.meta.skiaMilestone}`, `${d.summary.totalCves} CVEs`]; if (cgTotal > 0) subtitleParts.push(`${cgTotal} CG alerts`); + if (crTotal > 0) subtitleParts.push(`${crTotal} Chrome blog CVEs`); document.getElementById('header-subtitle').textContent = subtitleParts.join(' · '); document.getElementById('header-badges').innerHTML = `${d.summary.highestSeverity}`; @@ -527,6 +612,7 @@
Findings
container.innerHTML = d.findings.map((f, i) => renderFinding(f, i)).join(''); renderNextSteps(d.nextSteps); if (d.cgAlerts) renderCgAlerts(d.cgAlerts); + if (d.chromeReleases) renderChromeReleases(d.chromeReleases, d.meta.skiaMilestone); renderUpstream(d.meta); }); From 049215e1c472afd02cce176e4dbdeaa48593b7b7 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:24:56 +0200 Subject: [PATCH 04/18] Add blog post links to Chrome Releases table in HTML viewer Each CVE row now has a 'Blog' column with a link icon pointing to the Chrome Releases blog post where it was disclosed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/security-audit/scripts/viewer.html | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index fd49136c0db..b4279eccd28 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -483,16 +483,18 @@
Findings
const rows = compCves.sort((a, b) => (sevOrder[a.severity] ?? 9) - (sevOrder[b.severity] ?? 9)) .map(c => { const bugUrl = c.bugId ? `https://issues.chromium.org/issues/${esc(c.bugId)}` : '#'; - return ` - ${esc(c.severity)} - ${esc(c.cveId)} - m${c.milestone} - ${esc(c.bugId || 'N/A')} - `; - }).join(''); + const blogLink = c.blogPostUrl ? `` : ''; + return ` + ${esc(c.severity)} + ${esc(c.cveId)} + m${c.milestone} + ${esc(c.bugId || 'N/A')} + ${blogLink} + `; + }).join(''); return `
${esc(comp)} ${compCves.length} - - ${rows}
SeverityCVEFixed InBug
`; + + ${rows}
SeverityCVEFixed InBugBlog
`; }).join(''); } From fd652e76af5c4d60f4dd3ef766178a460c380bb2 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:25:20 +0200 Subject: [PATCH 05/18] Sort Chrome Releases CVEs: Skia first, then ANGLE, Canvas, etc. Component priority order: Skia > ANGLE > Canvas > Fonts > GPU > Compositing > WebGL > others. Within same priority, sort by severity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/scripts/viewer.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index b4279eccd28..1a906094c8e 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -471,7 +471,11 @@
Findings
byComp[c.component].push(c); }); const sevOrder = { Critical: 0, High: 1, Medium: 2, Low: 3 }; + const compPriority = { 'Skia': 0, 'ANGLE': 1, 'Canvas': 2, 'Fonts': 3, 'GPU': 4, 'Compositing': 5, 'WebGL': 6 }; const sorted = Object.entries(byComp).sort((a, b) => { + const aPri = compPriority[a[0]] ?? 99; + const bPri = compPriority[b[0]] ?? 99; + if (aPri !== bPri) return aPri - bPri; const aMin = Math.min(...a[1].map(c => sevOrder[c.severity] ?? 9)); const bMin = Math.min(...b[1].map(c => sevOrder[c.severity] ?? 9)); return aMin - bMin; From 34076fc72023b4cd61e159e19ac56638e6d0a545 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:26:27 +0200 Subject: [PATCH 06/18] Add descriptive text to summary cards in HTML viewer Each top card now shows a small description below the label: - Needs Attention: 'CVEs requiring action or patches' - CG Alerts: 'Build pipeline container vulnerabilities' - Undiscovered: 'Proactively found, no issue filed yet' - False Positive: 'Not applicable to SkiaSharp' - Clean: 'Dependencies with no open CVEs' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/scripts/viewer.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index 1a906094c8e..c03abc9e9a1 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -158,19 +158,20 @@
Findings
const cgCount = cgAlerts ? (cgAlerts.totalAlerts || 0) : 0; const cgHigh = cgAlerts ? ((cgAlerts.bySeverity || {}).High || 0) + ((cgAlerts.bySeverity || {}).Critical || 0) : 0; const cards = [ - ['Needs Attention', s.needsAttention + cgHigh, 'danger', 'exclamation-triangle'], - ['CG Alerts', cgCount, cgHigh > 0 ? 'danger' : 'warning', 'shield-exclamation'], - ['Undiscovered', s.undiscovered, 'primary', 'search'], - ['False Positive', s.falsePositive, 'secondary', 'x-circle'], - ['Clean', s.clean, 'success', 'check-circle'], + ['Needs Attention', s.needsAttention + cgHigh, 'danger', 'exclamation-triangle', 'CVEs requiring action or patches'], + ['CG Alerts', cgCount, cgHigh > 0 ? 'danger' : 'warning', 'shield-exclamation', 'Build pipeline container vulnerabilities'], + ['Undiscovered', s.undiscovered, 'primary', 'search', 'Proactively found, no issue filed yet'], + ['False Positive', s.falsePositive, 'secondary', 'x-circle', 'Not applicable to SkiaSharp'], + ['Clean', s.clean, 'success', 'check-circle', 'Dependencies with no open CVEs'], ]; const el = document.getElementById('summary-cards'); - el.innerHTML = cards.map(([label, count, color, icon]) => ` + el.innerHTML = cards.map(([label, count, color, icon, desc]) => `
${count}
${label}
+
${desc}
`).join(''); } From eaa639b15af6e916d307714249af1d91b179fb43 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:28:18 +0200 Subject: [PATCH 07/18] Compact CVE table: truncated descriptions with click-to-expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CVE IDs and severity now use white-space:nowrap to prevent wrapping - Descriptions truncated to 60 chars with '…' indicator - Click description to expand a hidden row with full text - Chevron icon hints at expandability - Keeps table compact and scannable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/security-audit/scripts/viewer.html | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index c03abc9e9a1..523168f951e 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -309,23 +309,32 @@
Findings
function renderCveTable(cves, columns) { if (!cves || cves.length === 0) return ''; - const hdrs = columns.map(c => `${c.label}`).join(''); - const rows = cves.map(cve => { - const cells = columns.map(c => { + // Filter out description from main columns — it goes in the expand row + const mainCols = columns.filter(c => c.key !== 'description'); + const hasDesc = columns.some(c => c.key === 'description'); + const hdrs = mainCols.map(c => `${c.label}`).join('') + (hasDesc ? 'Description' : ''); + const rows = cves.map((cve, i) => { + const cells = mainCols.map(c => { const val = cve[c.key] || ''; if (c.key === 'severity' || c.key === 'cvss') { - return `${esc(String(val))}`; + return `${esc(String(val))}`; } if (c.key === 'id') { const url = val.startsWith('GHSA-') ? `https://github.com/advisories/${esc(val)}` : `https://nvd.nist.gov/vuln/detail/${esc(val)}`; - return `${esc(val)}`; + return `${esc(val)}`; } if (c.key === 'assessment') { return `${assessmentBadge(String(val))}`; } - return `${esc(String(val))}`; + return `${esc(String(val))}`; }).join(''); - return `${cells}`; + const desc = cve.description || ''; + const shortDesc = desc.length > 60 ? desc.substring(0, 60) + '…' : desc; + const descCell = hasDesc ? `${esc(shortDesc)} ${desc.length > 60 ? '' : ''}` : ''; + const expandRow = hasDesc && desc.length > 60 + ? `${esc(desc)}` + : ``; + return `${cells}${descCell}${expandRow}`; }).join(''); return `${hdrs}${rows}
`; } From db69f0a7b16786bd9092dd5ee76b2bcc7a062adb Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:30:39 +0200 Subject: [PATCH 08/18] Make description column fill remaining width with CSS ellipsis - Table uses table-layout:fixed so columns respect widths - CVE, Severity, CVSS, Fixed In, Status get fixed narrow widths - Description gets all remaining space with text-overflow:ellipsis - Click row to expand full description below - No more wrapping on CVE IDs or severity text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/security-audit/scripts/viewer.html | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index 523168f951e..061b78274e4 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -317,26 +317,31 @@
Findings
const cells = mainCols.map(c => { const val = cve[c.key] || ''; if (c.key === 'severity' || c.key === 'cvss') { - return `${esc(String(val))}`; + return `${esc(String(val))}`; } if (c.key === 'id') { const url = val.startsWith('GHSA-') ? `https://github.com/advisories/${esc(val)}` : `https://nvd.nist.gov/vuln/detail/${esc(val)}`; - return `${esc(val)}`; + return `${esc(val)}`; } if (c.key === 'assessment') { - return `${assessmentBadge(String(val))}`; + return `${assessmentBadge(String(val))}`; + } + if (c.key === 'fixedIn') { + return `${esc(String(val))}`; + } + if (c.key === 'source') { + return `${esc(String(val))}`; } return `${esc(String(val))}`; }).join(''); const desc = cve.description || ''; - const shortDesc = desc.length > 60 ? desc.substring(0, 60) + '…' : desc; - const descCell = hasDesc ? `${esc(shortDesc)} ${desc.length > 60 ? '' : ''}` : ''; - const expandRow = hasDesc && desc.length > 60 - ? `${esc(desc)}` + const descCell = hasDesc ? `${esc(desc)} ${desc.length > 30 ? '' : ''}` : ''; + const expandRow = hasDesc && desc.length > 30 + ? `${esc(desc)}` : ``; return `${cells}${descCell}${expandRow}`; }).join(''); - return `${hdrs}${rows}
`; + return `${hdrs}${rows}
`; } function renderFinding(f, idx) { From 70abf1e40ec62557629bda013f580aa605669f95 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:31:50 +0200 Subject: [PATCH 09/18] Give description column 50% width in CVE tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/scripts/viewer.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index 061b78274e4..5de06f5916e 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -312,7 +312,7 @@
Findings
// Filter out description from main columns — it goes in the expand row const mainCols = columns.filter(c => c.key !== 'description'); const hasDesc = columns.some(c => c.key === 'description'); - const hdrs = mainCols.map(c => `${c.label}`).join('') + (hasDesc ? 'Description' : ''); + const hdrs = mainCols.map(c => `${c.label}`).join('') + (hasDesc ? 'Description' : ''); const rows = cves.map((cve, i) => { const cells = mainCols.map(c => { const val = cve[c.key] || ''; @@ -335,7 +335,7 @@
Findings
return `${esc(String(val))}`; }).join(''); const desc = cve.description || ''; - const descCell = hasDesc ? `${esc(desc)} ${desc.length > 30 ? '' : ''}` : ''; + const descCell = hasDesc ? `${esc(desc)} ${desc.length > 30 ? '' : ''}` : ''; const expandRow = hasDesc && desc.length > 30 ? `${esc(desc)}` : ``; From 89e74ab71efc5ec5d8af528e44f759a1cd168a2d Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:32:51 +0200 Subject: [PATCH 10/18] Widen source column (150px) and shrink description (35%) for non-Chrome CVE tables Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/scripts/viewer.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index 5de06f5916e..a25a22ff6d6 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -312,7 +312,9 @@
Findings
// Filter out description from main columns — it goes in the expand row const mainCols = columns.filter(c => c.key !== 'description'); const hasDesc = columns.some(c => c.key === 'description'); - const hdrs = mainCols.map(c => `${c.label}`).join('') + (hasDesc ? 'Description' : ''); + const hasSource = columns.some(c => c.key === 'source'); + const descWidth = hasSource ? '35%' : '50%'; + const hdrs = mainCols.map(c => `${c.label}`).join('') + (hasDesc ? `Description` : ''); const rows = cves.map((cve, i) => { const cells = mainCols.map(c => { const val = cve[c.key] || ''; @@ -330,7 +332,7 @@
Findings
return `${esc(String(val))}`; } if (c.key === 'source') { - return `${esc(String(val))}`; + return `${esc(String(val))}`; } return `${esc(String(val))}`; }).join(''); From ca4ee84b86cd2c0fdc27b897ddb70fc33c04168f Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:35:31 +0200 Subject: [PATCH 11/18] Fix Fixed In column: populate from fixMilestone, hide when empty - Skia CVEs: maps fixMilestone -> fixedIn for display - Non-Skia deps: hides the 'Fixed In' column entirely when no data - Avoids showing a blank column that wastes space Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/scripts/viewer.html | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index a25a22ff6d6..0044ea98f16 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -374,12 +374,15 @@
Findings
// Chrome CVEs if (f.cves && f.cves.length > 0) { + // Normalize: populate fixedIn from fixMilestone if missing + f.cves.forEach(c => { if (!c.fixedIn && c.fixMilestone) c.fixedIn = c.fixMilestone; }); + const hasFixedIn = f.cves.some(c => c.fixedIn); body += `
CVEs
`; - body += renderCveTable(f.cves, [ - {key:'id', label:'CVE'}, {key:'fixedIn', label:'Fixed In'}, - {key:'severity', label:'Severity'}, {key:'cvss', label:'CVSS'}, - {key:'description', label:'Description'}, {key:'assessment', label:'Status'} - ]); + const cveCols = [{key:'id', label:'CVE'}]; + if (hasFixedIn) cveCols.push({key:'fixedIn', label:'Fixed In'}); + cveCols.push({key:'severity', label:'Severity'}, {key:'cvss', label:'CVSS'}, + {key:'description', label:'Description'}, {key:'assessment', label:'Status'}); + body += renderCveTable(f.cves, cveCols); } // Non-Chrome CVEs From 151a0027b1def57211350de85b3912a2645689c6 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:36:23 +0200 Subject: [PATCH 12/18] Sort CVE table rows: affected first, then by severity within each group Sort order: potentially_affected/affected > undiscovered > already_fixed > false_positive. Within each group, Critical > High > Medium > Low. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/scripts/viewer.html | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.agents/skills/security-audit/scripts/viewer.html b/.agents/skills/security-audit/scripts/viewer.html index 0044ea98f16..8a3516f329d 100644 --- a/.agents/skills/security-audit/scripts/viewer.html +++ b/.agents/skills/security-audit/scripts/viewer.html @@ -309,13 +309,24 @@
Findings
function renderCveTable(cves, columns) { if (!cves || cves.length === 0) return ''; + // Sort: affected/potentially_affected first, then by severity desc + const assessOrder = { potentially_affected: 0, affected: 0, needs_attention: 0, undiscovered: 1, already_fixed: 2, false_positive: 3 }; + const sevOrder2 = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; + const sorted = [...cves].sort((a, b) => { + const aAssess = assessOrder[(a.assessment || '').toLowerCase()] ?? 1; + const bAssess = assessOrder[(b.assessment || '').toLowerCase()] ?? 1; + if (aAssess !== bAssess) return aAssess - bAssess; + const aSev = sevOrder2[(a.severity || '').toUpperCase()] ?? 9; + const bSev = sevOrder2[(b.severity || '').toUpperCase()] ?? 9; + return aSev - bSev; + }); // Filter out description from main columns — it goes in the expand row const mainCols = columns.filter(c => c.key !== 'description'); const hasDesc = columns.some(c => c.key === 'description'); const hasSource = columns.some(c => c.key === 'source'); const descWidth = hasSource ? '35%' : '50%'; const hdrs = mainCols.map(c => `${c.label}`).join('') + (hasDesc ? `Description` : ''); - const rows = cves.map((cve, i) => { + const rows = sorted.map((cve, i) => { const cells = mainCols.map(c => { const val = cve[c.key] || ''; if (c.key === 'severity' || c.key === 'cvss') { From e29edbb817d939ee2cafa82f1bcdc2ff88cd7039 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:43:14 +0200 Subject: [PATCH 13/18] Add Markdown report renderer for AI consumption New script: render-security-audit-md.py generates a comprehensive MD report from the JSON, including all sections: - Summary table with status counts - Chrome Releases blog (CVEs above milestone, by-component breakdown) - Findings sorted by assessment priority, then severity - CG alerts with Critical/High detail table - Version verification table - Next steps with commands Updated SKILL.md to reference new script and update Step 10 to render both HTML + MD. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/SKILL.md | 14 +- .../references/security-audit-schema.json | 46 +++ .../scripts/render-security-audit-md.py | 328 ++++++++++++++++++ 3 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 .agents/skills/security-audit/scripts/render-security-audit-md.py diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md index f9f31df713d..645e3e7428d 100644 --- a/.agents/skills/security-audit/SKILL.md +++ b/.agents/skills/security-audit/SKILL.md @@ -42,6 +42,7 @@ combined into a single unified report. - **[references/security-audit-schema.json](references/security-audit-schema.json)** — Machine-readable JSON Schema (Draft 2020-12) - **[scripts/validate-security-audit.py](scripts/validate-security-audit.py)** — Validates report JSON against schema + semantic checks - **[scripts/render-security-audit.py](scripts/render-security-audit.py)** — Renders JSON → standalone HTML +- **[scripts/render-security-audit-md.py](scripts/render-security-audit-md.py)** — Renders JSON → Markdown (for AI consumption) - **[scripts/viewer.html](scripts/viewer.html)** — HTML template (Bootstrap 5) ## Workflow @@ -322,20 +323,27 @@ Warnings are informational — errors must be fixed before proceeding. --- -### Step 10: Render HTML Report +### Step 10: Render HTML + Markdown Reports -> 🛑 **MANDATORY:** Always generate the HTML report. The human needs a readable dashboard. +> 🛑 **MANDATORY:** Always generate both reports. ```bash python3 .agents/skills/security-audit/scripts/render-security-audit.py \ output/ai/security-audit-{date}.json + +python3 .agents/skills/security-audit/scripts/render-security-audit-md.py \ + output/ai/security-audit-{date}.json ``` -This produces a self-contained HTML file (Bootstrap 5, CDN CSS only) alongside the JSON. +This produces: +- **HTML** — Self-contained dashboard (Bootstrap 5) for human review +- **Markdown** — Comprehensive report for AI consumption and action suggestions + The HTML renders: - Summary cards with status counts - Collapsible findings with CVE tables, severity badges, NVD links +- Chrome Releases blog section with above-milestone CVEs by component - Version verification table with match/mismatch indicators - Skia upstream verification details with commit links - Prioritized next steps with severity-coded borders diff --git a/.agents/skills/security-audit/references/security-audit-schema.json b/.agents/skills/security-audit/references/security-audit-schema.json index af0856a98c0..5188fcc0156 100644 --- a/.agents/skills/security-audit/references/security-audit-schema.json +++ b/.agents/skills/security-audit/references/security-audit-schema.json @@ -461,6 +461,52 @@ }, "additionalProperties": false }, + "chromeReleases": { + "type": "object", + "required": [ + "queriedAt", + "postsReviewed", + "totalCvesExtracted", + "skiaRelevantCves", + "earlyDisclosures", + "cacheFile" + ], + "properties": { + "queriedAt": { + "type": ["string", "null"] + }, + "monthsQueried": { + "type": ["integer", "null"] + }, + "postsReviewed": { + "type": "integer" + }, + "totalCvesExtracted": { + "type": "integer" + }, + "skiaRelevantCves": { + "type": "integer" + }, + "structuredCves": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "earlyDisclosures": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "cacheFile": { + "type": "string" + } + }, + "additionalProperties": true + }, "nextSteps": { "type": "array", "items": { diff --git a/.agents/skills/security-audit/scripts/render-security-audit-md.py b/.agents/skills/security-audit/scripts/render-security-audit-md.py new file mode 100644 index 00000000000..dfdc49de943 --- /dev/null +++ b/.agents/skills/security-audit/scripts/render-security-audit-md.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Render a security-audit JSON report as a Markdown file. + +Usage: + python3 render-security-audit-md.py [output.md] + +Produces a comprehensive markdown report suitable for AI consumption, +including all findings, Chrome Releases data, CG alerts, and next steps. +""" +import json +import sys +from pathlib import Path + + +def sev_emoji(sev): + s = (sev or "").upper() + if s == "CRITICAL": + return "🔴" + elif s == "HIGH": + return "🟠" + elif s == "MEDIUM": + return "🟡" + return "⚪" + + +def status_emoji(status): + m = { + "needs_attention": "🔴", + "in_progress": "🟡", + "ready_to_merge": "✅", + "undiscovered": "🆕", + "false_positive": "⚪", + "clean": "✅", + "cg_tracked": "🛡️", + } + return m.get(status, "❓") + + +def status_label(status): + m = { + "needs_attention": "Needs attention", + "in_progress": "In progress", + "ready_to_merge": "Ready to merge", + "undiscovered": "Undiscovered", + "false_positive": "False positive", + "clean": "Clean", + "cg_tracked": "CG Tracked", + } + return m.get(status, status or "Unknown") + + +def render_md(data): + meta = data["meta"] + summary = data["summary"] + findings = data["findings"] + next_steps = data.get("nextSteps", []) + ver = data.get("versionVerification", []) + cg = data.get("cgAlerts", {}) + cr = data.get("chromeReleases", {}) + + lines = [] + + # Header + lines.append(f"# Security Audit Report") + lines.append("") + lines.append(f"**Date:** {meta.get('date', 'N/A')}") + lines.append(f"**Skia Milestone:** m{meta.get('skiaMilestone', '?')}") + lines.append(f"**Fork Commit:** `{meta.get('skiaSubmoduleCommit', 'N/A')[:12]}`") + lines.append(f"**Upstream Verified:** {'Yes' if meta.get('upstreamVerified') else 'No'}") + lines.append("") + + # Summary + lines.append("## Summary") + lines.append("") + lines.append("| Status | Count |") + lines.append("|--------|-------|") + lines.append(f"| 🔴 Needs Attention | {summary.get('needsAttention', 0)} |") + lines.append(f"| 🆕 Undiscovered | {summary.get('undiscovered', 0)} |") + lines.append(f"| ⚪ False Positive | {summary.get('falsePositive', 0)} |") + lines.append(f"| ✅ Clean | {summary.get('clean', 0)} |") + lines.append(f"| **Total CVEs** | **{summary.get('totalCves', 0)}** |") + lines.append(f"| **Highest Severity** | **{summary.get('highestSeverity', 'N/A')}** |") + lines.append("") + + # Chrome Releases + if cr and cr.get("skiaRelevantCves"): + lines.append("## Chrome Releases Blog") + lines.append("") + lines.append(f"**Queried:** {cr.get('monthsQueried', '?')} months ({cr.get('postsReviewed', '?')} posts reviewed)") + lines.append(f"**Total CVEs extracted:** {cr.get('totalCvesExtracted', '?')}") + lines.append(f"**Skia-relevant CVEs:** {cr.get('skiaRelevantCves', '?')}") + lines.append("") + + structured = cr.get("structuredCves", []) + milestone = meta.get("skiaMilestone", 0) + above = [c for c in structured if (c.get("milestone") or 0) > milestone] + + if above: + lines.append(f"### CVEs Above Current Milestone (m{milestone})") + lines.append("") + lines.append("| Severity | CVE | Component | Fixed In | Bug | Blog |") + lines.append("|----------|-----|-----------|----------|-----|------|") + + # Sort: Skia first, then by severity + comp_order = {"Skia": 0, "ANGLE": 1, "Canvas": 2, "Fonts": 3, "GPU": 4, "Compositing": 5, "WebGL": 6} + sev_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} + above.sort(key=lambda c: (comp_order.get(c.get("component", ""), 99), sev_order.get(c.get("severity", ""), 9))) + + for c in above: + sev = c.get("severity", "?") + cve_id = c.get("cveId", "?") + comp = c.get("component", "?") + ms = c.get("milestone", "?") + bug = c.get("bugId", "") + bug_link = f"[{bug}](https://issues.chromium.org/issues/{bug})" if bug else "N/A" + blog = c.get("blogPostUrl", "") + blog_link = f"[Post]({blog})" if blog else "" + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {comp} | m{ms} | {bug_link} | {blog_link} |") + lines.append("") + + # Summary by component + comp_counts = {} + for c in structured: + comp = c.get("component", "Other") + comp_counts[comp] = comp_counts.get(comp, 0) + 1 + lines.append("### By Component (all milestones)") + lines.append("") + lines.append("| Component | CVEs |") + lines.append("|-----------|------|") + for comp, count in sorted(comp_counts.items(), key=lambda x: -x[1]): + lines.append(f"| {comp} | {count} |") + lines.append("") + + # Findings + lines.append("## Findings") + lines.append("") + + # Sort findings: needs_attention/in_progress first + status_order = {"needs_attention": 0, "in_progress": 1, "undiscovered": 2, "ready_to_merge": 3, "false_positive": 4, "clean": 5} + sorted_findings = sorted(findings, key=lambda f: status_order.get(f.get("status", ""), 9)) + + for i, f in enumerate(sorted_findings, 1): + dep = f.get("dependency", "?") + status = f.get("status", "?") + emoji = status_emoji(status) + label = status_label(status) + cves = f.get("cves", []) + non_chrome = f.get("nonChromeCves", []) + + lines.append(f"### {i}. {emoji} {dep} — {label}") + lines.append("") + + # Info table + lines.append("| Field | Value |") + lines.append("|-------|-------|") + if f.get("currentVersion"): + lines.append(f"| Current | {f['currentVersion']} |") + if f.get("fixVersion"): + lines.append(f"| Min Fix | {f['fixVersion']} |") + if f.get("latestVersion"): + lines.append(f"| Latest | {f['latestVersion']} |") + lines.append(f"| CVEs | {len(cves) + len(non_chrome)} |") + if f.get("issues"): + issues_str = ", ".join( + f"#{i['number'] if isinstance(i, dict) else i}" for i in f["issues"] + ) + lines.append(f"| Issues | {issues_str} |") + if f.get("prs"): + prs_str = ", ".join( + f"#{p['number'] if isinstance(p, dict) else p}" for p in f["prs"] + ) + lines.append(f"| PRs | {prs_str} |") + lines.append("") + + # CVE table + if cves: + # Sort: affected first, then by severity + assess_order = {"potentially_affected": 0, "affected": 0, "needs_attention": 0, "undiscovered": 1, "already_fixed": 2, "false_positive": 3} + sev_order_map = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} + cves_sorted = sorted(cves, key=lambda c: ( + assess_order.get((c.get("assessment") or "").lower(), 1), + sev_order_map.get((c.get("severity") or "").upper(), 9) + )) + + has_fixed_in = any(c.get("fixedIn") or c.get("fixMilestone") for c in cves_sorted) + if has_fixed_in: + lines.append("| Severity | CVE | Fixed In | Assessment | Description |") + lines.append("|----------|-----|----------|------------|-------------|") + else: + lines.append("| Severity | CVE | Assessment | Description |") + lines.append("|----------|-----|------------|-------------|") + + for c in cves_sorted: + sev = c.get("severity", "?") + cve_id = c.get("id", "?") + fixed = c.get("fixedIn") or c.get("fixMilestone") or "" + assess = c.get("assessment", "?") + desc = c.get("description", "") + # Truncate description for table readability + short_desc = desc[:80] + "…" if len(desc) > 80 else desc + if has_fixed_in: + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {fixed} | {assess} | {short_desc} |") + else: + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {assess} | {short_desc} |") + lines.append("") + + # Non-Chrome CVEs + if non_chrome: + lines.append("**Non-Chrome CVEs (Manual Review):**") + lines.append("") + lines.append("| Severity | CVE | Source | Assessment | Description |") + lines.append("|----------|-----|--------|------------|-------------|") + for c in non_chrome: + sev = c.get("severity", "?") + cve_id = c.get("id", "?") + source = c.get("source", "?") + assess = c.get("assessment", "?") + desc = c.get("description", "") + short_desc = desc[:80] + "…" if len(desc) > 80 else desc + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {source} | {assess} | {short_desc} |") + lines.append("") + + # Action + if f.get("action"): + lines.append(f"**Action:** {f['action']}") + lines.append("") + + # Notes + if f.get("notes"): + lines.append(f"> {f['notes']}") + lines.append("") + + lines.append("---") + lines.append("") + + # CG Alerts + if cg and cg.get("totalAlerts"): + lines.append("## Component Governance Alerts") + lines.append("") + lines.append(f"**Total Alerts:** {cg.get('totalAlerts', 0)}") + sev_data = cg.get("bySeverity", {}) + lines.append(f"**Breakdown:** Critical={sev_data.get('Critical', 0)}, High={sev_data.get('High', 0)}, Medium={sev_data.get('Medium', 0)}, Low={sev_data.get('Low', 0)}") + lines.append("") + + alerts = cg.get("alerts", []) + if alerts: + # Group by severity, show top ones + crit_high = [a for a in alerts if a.get("severity") in ("Critical", "High")] + if crit_high: + lines.append("### Critical & High Severity") + lines.append("") + lines.append("| Severity | CVE/Advisory | Component |") + lines.append("|----------|--------------|-----------|") + for a in crit_high[:30]: + lines.append(f"| {sev_emoji(a.get('severity',''))} {a.get('severity','')} | {a.get('id','')} | {a.get('component','')} |") + if len(crit_high) > 30: + lines.append(f"| ... | +{len(crit_high)-30} more | |") + lines.append("") + + pipelines = cg.get("pipelines", []) + if pipelines: + lines.append(f"**Pipelines scanned:** {', '.join(p.get('name','') for p in pipelines)}") + lines.append("") + + # Version Verification + if ver: + lines.append("## Version Verification") + lines.append("") + lines.append("| Dependency | Source | Verified Version | cgmanifest | Match |") + lines.append("|------------|--------|------------------|------------|-------|") + for v in ver: + dep = v.get("dependency", "?") + source = v.get("source", "?") + verified = v.get("verifiedVersion", "?") + cgver = v.get("cgmanifestVersion") or "N/A" + match = "✅" if v.get("match") else "❌" + lines.append(f"| {dep} | {source} | {verified} | {cgver} | {match} |") + lines.append("") + + # Next Steps + if next_steps: + lines.append("## Next Steps") + lines.append("") + for i, step in enumerate(next_steps, 1): + sev = step.get("severity", "") + action = step.get("action", "") + reason = step.get("reason", "") + cmd = step.get("command", "") + lines.append(f"{i}. **{action}** ({sev})") + if reason: + lines.append(f" - {reason}") + if cmd: + lines.append(f" - `{cmd}`") + lines.append("") + + return "\n".join(lines) + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 render-security-audit-md.py [output.md]") + sys.exit(1) + + json_path = Path(sys.argv[1]) + if not json_path.exists(): + print(f"❌ JSON file not found: {json_path}") + sys.exit(1) + + if len(sys.argv) >= 3: + output_path = Path(sys.argv[2]) + else: + output_path = json_path.with_suffix(".md") + + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + + md = render_md(data) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(md) + + print(f"✅ {output_path.name} ({len(md) / 1024:.1f} KB)") + print(f" Output: {output_path}") + + +if __name__ == "__main__": + main() From 6a71aeb3ff9b5860f9086889e21d072d38ce99c1 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:45:02 +0200 Subject: [PATCH 14/18] Simplify Step 11: use generated MD instead of manual in-chat summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The render-security-audit-md.py script now produces the full MD report automatically. Step 11 no longer asks the AI to manually compose a markdown summary — just point to the 3 generated files and highlight top actionable items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/SKILL.md | 33 ++++++++++++++++---------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md index 645e3e7428d..201bb208a32 100644 --- a/.agents/skills/security-audit/SKILL.md +++ b/.agents/skills/security-audit/SKILL.md @@ -37,7 +37,7 @@ combined into a single unified report. - **[references/third-party-deps.md](references/third-party-deps.md)** — Third-party CVE process (libpng, freetype, harfbuzz, etc.): version verification, fix-commit ancestry, known false positives - **[references/cg-alerts.md](references/cg-alerts.md)** — Component Governance alerts: ADO pipeline queries, Docker container CVEs, fix locations - **[documentation/dev/dependencies.md](../../../documentation/dev/dependencies.md)** — Dependency list, cgmanifest format, Skia-specific tracking notes -- **[references/report-template.md](references/report-template.md)** — Markdown report format +- **[references/report-template.md](references/report-template.md)** — Markdown format guide (used by `render-security-audit-md.py`) - **[references/report-schema.md](references/report-schema.md)** — JSON schema for structured output - **[references/security-audit-schema.json](references/security-audit-schema.json)** — Machine-readable JSON Schema (Draft 2020-12) - **[scripts/validate-security-audit.py](scripts/validate-security-audit.py)** — Validates report JSON against schema + semantic checks @@ -358,24 +358,31 @@ Present the output path to the user: --- -### Step 11: Present Markdown Summary +### Step 11: Present Summary to User -After generating JSON and HTML, present a concise markdown summary to the user in the -conversation (using the report-template.md format). This is in ADDITION to the JSON+HTML -files, not instead of them. +The Markdown report was already generated in Step 10. Present a brief summary in the +conversation pointing to the generated files: -**Priority order** (applies equally to Skia core and third-party deps): - -1. 🔴 User-reported + no PR -2. ✅ User-reported + PR ready -3. 🟡 User-reported + PR needs work -4. 🆕 Undiscovered CVEs (proactively found, no user-filed issue) -5. ⚪ False positives +``` +✅ Reports generated: + • output/ai/security-audit-{date}.json (structured data) + • output/ai/security-audit-{date}.html (interactive dashboard) + • output/ai/security-audit-{date}.md (full markdown for AI review) + + m147 • 2026-05-29 • 102 CVEs • Highest: CRITICAL + 🔴 0 attention · 🆕 0 undiscovered · ⚪ 1 FP · ✅ 6 clean + 📰 Chrome Releases: 146 Skia-relevant CVEs (16 above current milestone) +``` -Within each priority level, sort by severity (CRITICAL > HIGH > MEDIUM > LOW). +Then highlight the **top actionable items** from the report: +- Any `needs_attention` or `undiscovered` findings +- Chrome Releases CVEs above our current milestone (especially Skia/ANGLE) +- Critical/High CG alerts #### Report quality rules +These rules apply to the JSON assembly (Step 8) and are enforced by the renderers: + 1. **Skia bump recommendations must target the highest-severity CVE**, not the lowest. If there are HIGH CVEs at m146 and a MEDIUM at m133, recommend m146 as the target. 2. **Don't include already-closed GitHub issues** unless directly relevant to an open From 247640bf44c3b01910803fd96c9035b4206eb897 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:50:33 +0200 Subject: [PATCH 15/18] Remove description truncation from MD renderer The markdown report is primarily for AI consumption, so full descriptions are needed for proper analysis. Removed 80-char truncation from both CVE tables and Non-Chrome CVE tables. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../security-audit/scripts/render-security-audit-md.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.agents/skills/security-audit/scripts/render-security-audit-md.py b/.agents/skills/security-audit/scripts/render-security-audit-md.py index dfdc49de943..d166b99e93e 100644 --- a/.agents/skills/security-audit/scripts/render-security-audit-md.py +++ b/.agents/skills/security-audit/scripts/render-security-audit-md.py @@ -196,12 +196,10 @@ def render_md(data): fixed = c.get("fixedIn") or c.get("fixMilestone") or "" assess = c.get("assessment", "?") desc = c.get("description", "") - # Truncate description for table readability - short_desc = desc[:80] + "…" if len(desc) > 80 else desc if has_fixed_in: - lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {fixed} | {assess} | {short_desc} |") + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {fixed} | {assess} | {desc} |") else: - lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {assess} | {short_desc} |") + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {assess} | {desc} |") lines.append("") # Non-Chrome CVEs @@ -216,8 +214,7 @@ def render_md(data): source = c.get("source", "?") assess = c.get("assessment", "?") desc = c.get("description", "") - short_desc = desc[:80] + "…" if len(desc) > 80 else desc - lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {source} | {assess} | {short_desc} |") + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {source} | {assess} | {desc} |") lines.append("") # Action From 5d22ed24c7c54db9362a9719c5e0c8fd9e855d11 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:56:39 +0200 Subject: [PATCH 16/18] Fix MD tables: sanitize newlines/pipes, move Chrome Releases after CG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added sanitize_cell() to replace newlines with spaces and escape pipe characters in descriptions — fixes broken table rendering - Moved Chrome Releases section after CG Alerts (order: Summary → Findings → CG → Chrome Releases → Version Verification → Next Steps) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/render-security-audit-md.py | 113 ++++++++++-------- 1 file changed, 62 insertions(+), 51 deletions(-) diff --git a/.agents/skills/security-audit/scripts/render-security-audit-md.py b/.agents/skills/security-audit/scripts/render-security-audit-md.py index d166b99e93e..91cfdb52de7 100644 --- a/.agents/skills/security-audit/scripts/render-security-audit-md.py +++ b/.agents/skills/security-audit/scripts/render-security-audit-md.py @@ -49,6 +49,17 @@ def status_label(status): return m.get(status, status or "Unknown") +def sanitize_cell(text): + """Sanitize text for use in a markdown table cell.""" + if not text: + return "" + # Replace newlines with spaces + text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ") + # Escape pipe characters + text = text.replace("|", "\\|") + return text + + def render_md(data): meta = data["meta"] summary = data["summary"] @@ -82,55 +93,6 @@ def render_md(data): lines.append(f"| **Highest Severity** | **{summary.get('highestSeverity', 'N/A')}** |") lines.append("") - # Chrome Releases - if cr and cr.get("skiaRelevantCves"): - lines.append("## Chrome Releases Blog") - lines.append("") - lines.append(f"**Queried:** {cr.get('monthsQueried', '?')} months ({cr.get('postsReviewed', '?')} posts reviewed)") - lines.append(f"**Total CVEs extracted:** {cr.get('totalCvesExtracted', '?')}") - lines.append(f"**Skia-relevant CVEs:** {cr.get('skiaRelevantCves', '?')}") - lines.append("") - - structured = cr.get("structuredCves", []) - milestone = meta.get("skiaMilestone", 0) - above = [c for c in structured if (c.get("milestone") or 0) > milestone] - - if above: - lines.append(f"### CVEs Above Current Milestone (m{milestone})") - lines.append("") - lines.append("| Severity | CVE | Component | Fixed In | Bug | Blog |") - lines.append("|----------|-----|-----------|----------|-----|------|") - - # Sort: Skia first, then by severity - comp_order = {"Skia": 0, "ANGLE": 1, "Canvas": 2, "Fonts": 3, "GPU": 4, "Compositing": 5, "WebGL": 6} - sev_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} - above.sort(key=lambda c: (comp_order.get(c.get("component", ""), 99), sev_order.get(c.get("severity", ""), 9))) - - for c in above: - sev = c.get("severity", "?") - cve_id = c.get("cveId", "?") - comp = c.get("component", "?") - ms = c.get("milestone", "?") - bug = c.get("bugId", "") - bug_link = f"[{bug}](https://issues.chromium.org/issues/{bug})" if bug else "N/A" - blog = c.get("blogPostUrl", "") - blog_link = f"[Post]({blog})" if blog else "" - lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {comp} | m{ms} | {bug_link} | {blog_link} |") - lines.append("") - - # Summary by component - comp_counts = {} - for c in structured: - comp = c.get("component", "Other") - comp_counts[comp] = comp_counts.get(comp, 0) + 1 - lines.append("### By Component (all milestones)") - lines.append("") - lines.append("| Component | CVEs |") - lines.append("|-----------|------|") - for comp, count in sorted(comp_counts.items(), key=lambda x: -x[1]): - lines.append(f"| {comp} | {count} |") - lines.append("") - # Findings lines.append("## Findings") lines.append("") @@ -195,7 +157,7 @@ def render_md(data): cve_id = c.get("id", "?") fixed = c.get("fixedIn") or c.get("fixMilestone") or "" assess = c.get("assessment", "?") - desc = c.get("description", "") + desc = sanitize_cell(c.get("description", "")) if has_fixed_in: lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {fixed} | {assess} | {desc} |") else: @@ -213,7 +175,7 @@ def render_md(data): cve_id = c.get("id", "?") source = c.get("source", "?") assess = c.get("assessment", "?") - desc = c.get("description", "") + desc = sanitize_cell(c.get("description", "")) lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {source} | {assess} | {desc} |") lines.append("") @@ -259,6 +221,55 @@ def render_md(data): lines.append(f"**Pipelines scanned:** {', '.join(p.get('name','') for p in pipelines)}") lines.append("") + # Chrome Releases (after CG, before Version Verification) + if cr and cr.get("skiaRelevantCves"): + lines.append("## Chrome Releases Blog") + lines.append("") + lines.append(f"**Queried:** {cr.get('monthsQueried', '?')} months ({cr.get('postsReviewed', '?')} posts reviewed)") + lines.append(f"**Total CVEs extracted:** {cr.get('totalCvesExtracted', '?')}") + lines.append(f"**Skia-relevant CVEs:** {cr.get('skiaRelevantCves', '?')}") + lines.append("") + + structured = cr.get("structuredCves", []) + milestone = meta.get("skiaMilestone", 0) + above = [c for c in structured if (c.get("milestone") or 0) > milestone] + + if above: + lines.append(f"### CVEs Above Current Milestone (m{milestone})") + lines.append("") + lines.append("| Severity | CVE | Component | Fixed In | Bug | Blog |") + lines.append("|----------|-----|-----------|----------|-----|------|") + + # Sort: Skia first, then by severity + comp_order = {"Skia": 0, "ANGLE": 1, "Canvas": 2, "Fonts": 3, "GPU": 4, "Compositing": 5, "WebGL": 6} + sev_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} + above.sort(key=lambda c: (comp_order.get(c.get("component", ""), 99), sev_order.get(c.get("severity", ""), 9))) + + for c in above: + sev = c.get("severity", "?") + cve_id = c.get("cveId", "?") + comp = c.get("component", "?") + ms = c.get("milestone", "?") + bug = c.get("bugId", "") + bug_link = f"[{bug}](https://issues.chromium.org/issues/{bug})" if bug else "N/A" + blog = c.get("blogPostUrl", "") + blog_link = f"[Post]({blog})" if blog else "" + lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {comp} | m{ms} | {bug_link} | {blog_link} |") + lines.append("") + + # Summary by component + comp_counts = {} + for c in structured: + comp = c.get("component", "Other") + comp_counts[comp] = comp_counts.get(comp, 0) + 1 + lines.append("### By Component (all milestones)") + lines.append("") + lines.append("| Component | CVEs |") + lines.append("|-----------|------|") + for comp, count in sorted(comp_counts.items(), key=lambda x: -x[1]): + lines.append(f"| {comp} | {count} |") + lines.append("") + # Version Verification if ver: lines.append("## Version Verification") From 91685b7d78eba6e75d6f750de2f97141a1dea653 Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 19:58:16 +0200 Subject: [PATCH 17/18] Fix version verification table: use 'name' field for dependency column The JSON uses 'name' not 'dependency' for version verification entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/security-audit/scripts/render-security-audit-md.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/security-audit/scripts/render-security-audit-md.py b/.agents/skills/security-audit/scripts/render-security-audit-md.py index 91cfdb52de7..05636399629 100644 --- a/.agents/skills/security-audit/scripts/render-security-audit-md.py +++ b/.agents/skills/security-audit/scripts/render-security-audit-md.py @@ -277,7 +277,7 @@ def render_md(data): lines.append("| Dependency | Source | Verified Version | cgmanifest | Match |") lines.append("|------------|--------|------------------|------------|-------|") for v in ver: - dep = v.get("dependency", "?") + dep = v.get("name") or v.get("dependency") or "?" source = v.get("source", "?") verified = v.get("verifiedVersion", "?") cgver = v.get("cgmanifestVersion") or "N/A" From f1f0c6f181724cb31dd57b3de9c2eaa8a2b81dab Mon Sep 17 00:00:00 2001 From: Matthew Leibowitz Date: Fri, 29 May 2026 20:07:44 +0200 Subject: [PATCH 18/18] Fix blocking issues from Opus 4.8 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: Fix field-name contradiction between schema, docs, and renderers. - report-schema.md now documents structuredCves as primary array - security-audit-schema.json requires structuredCves (not earlyDisclosures) - Added snake_case→camelCase field mapping docs - Added blogPostUrl propagation instructions B2: Add chromeReleases as explicit key #6 in SKILL.md Step 8 with transformation instructions so the AI knows to emit it. N1: MD renderer now shows resolution details (fixCommit, reachability, inOurTree, cherryPicksCleanly) when present on CVEs. N2: MD header now includes upstream commit, verification note, and behind-by count when available. N3: Cache freshness check now compares months_queried — requesting a wider window than cached invalidates the cache automatically. N4: CVE_ENTRY_REGEX reporter/date tail is now optional, capturing embargoed entries that omit attribution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .agents/skills/security-audit/SKILL.md | 3 +- .../references/report-schema.md | 28 +++++++++++++++++-- .../references/security-audit-schema.json | 2 +- .../scripts/query-chrome-releases.py | 20 +++++++------ .../scripts/render-security-audit-md.py | 27 ++++++++++++++++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/.agents/skills/security-audit/SKILL.md b/.agents/skills/security-audit/SKILL.md index 201bb208a32..05e39d6b555 100644 --- a/.agents/skills/security-audit/SKILL.md +++ b/.agents/skills/security-audit/SKILL.md @@ -290,7 +290,8 @@ Build the JSON object with these top-level keys: 3. **`versionVerification`** — One entry per dependency with DEPS commit, verified version, cgmanifest version, match boolean 4. **`findings`** — Array of finding objects sorted by priority then severity. **ONE object per dependency** (e.g., one "skia" finding containing ALL Skia CVEs regardless of status). Each has `dependency`, `status`, `cves[]`, `nonChromeCves[]`, `action`, `notes`. The `status` reflects the WORST-case status among the CVEs. 5. **`cgAlerts`** — The complete raw JSON from `query-cg-alerts.py` (full `alerts` array, do not summarize) -6. **`nextSteps`** — Prioritized action items with severity, command, and reason +6. **`chromeReleases`** — Chrome Releases blog data. Transform the script's snake_case output (`cve_id`→`cveId`, `bug_id`→`bugId`, `blog_post_url`→`blogPostUrl`) into `structuredCves[]`. Also copy `blogPostUrl` onto matching CVEs in `findings[].cves[]`. See [report-schema.md](references/report-schema.md#chromereleases--chrome-releases-blog-data-optional) for the full field mapping. +7. **`nextSteps`** — Prioritized action items with severity, command, and reason > 🛑 **COMPLETENESS REQUIREMENT:** The `findings` array MUST include **every CVE returned > by the NVD query** (Step 3 of skia-cve-resolution.md). CVEs that are verified as already diff --git a/.agents/skills/security-audit/references/report-schema.md b/.agents/skills/security-audit/references/report-schema.md index 08977914b78..672ecd147e4 100644 --- a/.agents/skills/security-audit/references/report-schema.md +++ b/.agents/skills/security-audit/references/report-schema.md @@ -24,6 +24,8 @@ JSON schema for the security audit report. The AI generates this JSON as structu }, "versionVerification": [ ... ], "findings": [ ... ], + "cgAlerts": { ... }, + "chromeReleases": { ... }, "nextSteps": [ ... ] } ``` @@ -217,7 +219,7 @@ This section is optional — omit it only if the blog query was skipped or faile "postsReviewed": 12, "totalCvesExtracted": 45, "skiaRelevantCves": 8, - "earlyDisclosures": [ + "structuredCves": [ { "cveId": "CVE-2026-8510", "severity": "Critical", @@ -230,6 +232,7 @@ This section is optional — omit it only if the blog query was skipped or faile "extraction": "regex" } ], + "earlyDisclosures": [], "cacheFile": "output/ai/chrome-releases-cache.json" } ``` @@ -241,9 +244,30 @@ This section is optional — omit it only if the blog query was skipped or faile | `postsReviewed` | integer | Yes | Number of posts that matched keywords | | `totalCvesExtracted` | integer | Yes | Total CVEs found across all posts (all components) | | `skiaRelevantCves` | integer | Yes | CVEs in Skia-relevant components | -| `earlyDisclosures` | array | Yes | CVEs found in blog but NOT in NVD (may be empty) | +| `structuredCves` | array | Yes | **All** Skia-relevant CVEs from the blog (the primary rendered array). Each has camelCase fields: `cveId`, `severity`, `component`, `milestone`, `bugId`, `blogPostUrl` | +| `earlyDisclosures` | array | No | Subset: CVEs found in blog but NOT yet in NVD (may be empty). Same schema as `structuredCves` items. | | `cacheFile` | string | Yes | Path to the cached JSON from the script | +### Field Name Mapping (Script → Report JSON) + +The `query-chrome-releases.py` script outputs **snake_case** fields. When building the report +JSON, transform to **camelCase**: + +| Script field (`structured_cves[]`) | Report field (`structuredCves[]`) | +|------------------------------------|-----------------------------------| +| `cve_id` | `cveId` | +| `bug_id` | `bugId` | +| `blog_post_url` | `blogPostUrl` | +| `severity` | `severity` (unchanged) | +| `component` | `component` (unchanged) | +| `milestone` | `milestone` (unchanged) | + +### Setting `blogPostUrl` on Findings CVEs + +When a CVE in `findings[].cves[]` also appears in `structuredCves[]`, copy the `blogPostUrl` +onto the finding's CVE object. This enables the HTML viewer to link directly to the source +blog post. + ### CVE `source` Field (Updated) The `source` field on individual CVE objects in `findings[].cves[]` should indicate provenance: diff --git a/.agents/skills/security-audit/references/security-audit-schema.json b/.agents/skills/security-audit/references/security-audit-schema.json index 5188fcc0156..6c6550ef06c 100644 --- a/.agents/skills/security-audit/references/security-audit-schema.json +++ b/.agents/skills/security-audit/references/security-audit-schema.json @@ -468,7 +468,7 @@ "postsReviewed", "totalCvesExtracted", "skiaRelevantCves", - "earlyDisclosures", + "structuredCves", "cacheFile" ], "properties": { diff --git a/.agents/skills/security-audit/scripts/query-chrome-releases.py b/.agents/skills/security-audit/scripts/query-chrome-releases.py index 47c918b65a6..30851ba4220 100755 --- a/.agents/skills/security-audit/scripts/query-chrome-releases.py +++ b/.agents/skills/security-audit/scripts/query-chrome-releases.py @@ -68,8 +68,8 @@ r"(Critical|High|Medium|Low)\s+" # Severity r"(CVE-\d{4}-\d{4,})\s*:\s*" # CVE ID r"(.+?)\.\s*" # Description - r"Reported\s+by\s+(.+?)\s+on\s+" # Reporter - r"(\d{4}-\d{2}-\d{2})", # Date + r"(?:Reported\s+by\s+(.+?)\s+on\s+" # Reporter (optional) + r"(\d{4}-\d{2}-\d{2}))?", # Date (optional) re.IGNORECASE ) @@ -257,8 +257,8 @@ def extract_structured_cves(text: str, post_url: str, chrome_version: str, miles severity = match.group(3).strip() cve_id = match.group(4).strip() description = match.group(5).strip() - reporter = match.group(6).strip() - date_reported = match.group(7).strip() + reporter = (match.group(6) or "").strip() or None + date_reported = (match.group(7) or "").strip() or None # Determine component from description component = "Unknown" @@ -370,12 +370,16 @@ def main(): mtime = os.path.getmtime(args.output) age_hours = (time.time() - mtime) / 3600 if age_hours < CACHE_MAX_AGE_HOURS: - print(f"✅ Cache is fresh ({age_hours:.1f}h old, max {CACHE_MAX_AGE_HOURS}h). Use --force to re-fetch.") - # Load and print summary + # Validate cached params match requested params with open(args.output) as f: data = json.load(f) - print(f" {data.get('skia_relevant_regex_cves', 0)} Skia-relevant CVEs from {data.get('matching_posts_count', 0)} posts") - sys.exit(0) + cached_months = data.get("months_queried", 0) + if cached_months >= args.months: + print(f"✅ Cache is fresh ({age_hours:.1f}h old, max {CACHE_MAX_AGE_HOURS}h). Use --force to re-fetch.") + print(f" {data.get('skia_relevant_regex_cves', 0)} Skia-relevant CVEs from {data.get('matching_posts_count', 0)} posts") + sys.exit(0) + else: + print(f"⚠️ Cache covers {cached_months} months but {args.months} requested. Re-fetching...") # Parse keywords keywords = DEFAULT_KEYWORDS diff --git a/.agents/skills/security-audit/scripts/render-security-audit-md.py b/.agents/skills/security-audit/scripts/render-security-audit-md.py index 05636399629..0f7763ee751 100644 --- a/.agents/skills/security-audit/scripts/render-security-audit-md.py +++ b/.agents/skills/security-audit/scripts/render-security-audit-md.py @@ -77,7 +77,13 @@ def render_md(data): lines.append(f"**Date:** {meta.get('date', 'N/A')}") lines.append(f"**Skia Milestone:** m{meta.get('skiaMilestone', '?')}") lines.append(f"**Fork Commit:** `{meta.get('skiaSubmoduleCommit', 'N/A')[:12]}`") + if meta.get("skiaUpstreamCommit"): + lines.append(f"**Upstream Commit:** `{meta['skiaUpstreamCommit'][:12]}`") lines.append(f"**Upstream Verified:** {'Yes' if meta.get('upstreamVerified') else 'No'}") + if meta.get("upstreamVerificationNote"): + lines.append(f"**Verification Note:** {meta['upstreamVerificationNote']}") + if meta.get("upstreamBehindBy"): + lines.append(f"**Behind Upstream By:** {meta['upstreamBehindBy']} commits") lines.append("") # Summary @@ -164,6 +170,27 @@ def render_md(data): lines.append(f"| {sev_emoji(sev)} {sev} | {cve_id} | {assess} | {desc} |") lines.append("") + # Resolution details (for AI downstream consumption) + resolved_cves = [c for c in cves_sorted if c.get("fixCommit") or c.get("reachability") or c.get("inOurTree") is not None] + if resolved_cves: + lines.append("**Resolution Details:**") + lines.append("") + for c in resolved_cves: + cve_id = c.get("id", "?") + parts = [f"**{cve_id}**"] + if c.get("fixCommit"): + parts.append(f"fix: `{c['fixCommit'][:12]}`") + if c.get("fixCommitTitle"): + parts.append(f"({sanitize_cell(c['fixCommitTitle'])})") + if c.get("inOurTree") is not None: + parts.append(f"in-tree: {'✅' if c['inOurTree'] else '❌'}") + if c.get("reachability"): + parts.append(f"reachability: {c['reachability']}") + if c.get("cherryPicksCleanly") is not None: + parts.append(f"cherry-pick: {'✅' if c['cherryPicksCleanly'] else '❌'}") + lines.append(f"- {' | '.join(parts)}") + lines.append("") + # Non-Chrome CVEs if non_chrome: lines.append("**Non-Chrome CVEs (Manual Review):**")