diff --git a/node/maintenance.md b/node/maintenance.md index d0fbfbd..1ee815c 100644 --- a/node/maintenance.md +++ b/node/maintenance.md @@ -102,11 +102,15 @@ If initial or final audit evidence is unavailable or incomplete, the run fails and skips publication. The report identifies the cause and retains the separate frozen-install and project-check results. Its summary and report artifact remain available after failure; passing project checks do not make the audit complete. -The report leads with a summary and validation results. Confirmed security fixes -appear in a table; the full dependency version list is expandable. Routine-only -headlines omit unrelated security counts. Existing blocked findings with unchanged +The report leads with separate counts for platform security updates and confirmed +package security fixes. Platform and package findings have separate sections and +share the same validation results. Package tables keep severity separate from +action: a high-severity package can have a validated fix, while orange means a +specific decision needs review, such as a retained pin blocking a patch. +The full dependency version list is expandable. Existing blocked findings with unchanged audit evidence appear as neutral background, with severity and constraints in -expandable details. New findings and evidence that needs review remain visible. +expandable details during routine-only upgrades. New findings and evidence that +needs review remain visible. Security-fix rows link to one primary reported advisory, and count additional advisories with a pointer to the full audit details. The full audit details retain every advisory link, including the underlying advisory for an indirectly @@ -119,6 +123,32 @@ It does not flag that entry as a newly introduced vulnerability. Other newly reported findings remain visible for review; an audit difference alone does not prove that the proposal introduced a vulnerability. +Platform reports list each security release after an exact starting Node version +and up to the proposed target in the same major. Each row shows the highest +severity and number of named CVEs read from that version's official Node release +notes, with links to the CVE list and release notes. These are release-level +counts, not proof that every CVE affects the starting runtime, and are not summed +across releases as unique vulnerabilities. Bundled dependency updates may fix +additional vulnerabilities. A major-only declaration has no known starting patch, +so the report says so and shows only the target release. + +Release-note reads use fixed official sources, bounded responses, no redirects, +and no credentials. Missing or unreadable CVE metadata is shown as unavailable, +with the release-note link; it is never represented as zero CVEs. An unavailable +history lookup explicitly limits coverage to the target release. These display +lookups do not replace the required runtime security verification or change +proposal selection, validation, or merge behavior. + +For reproducible layout previews generated by the production report functions: + +```bash +python node/tests/security_report_examples.py --output-directory /tmp/node-security-previews +``` + +Add `--live-platform` to read the official release history and notes instead of +using offline fixtures. Package and validation evidence in these previews is +illustrative; the generated footer identifies that limitation. + Routine-upgrade proposals also show a table of selected direct upgrades that remain in the final proposal. Current and proposed values are the declared `package.json` requirements. Development and optional dependencies are marked. diff --git a/node/publish/publish.sh b/node/publish/publish.sh index 8a6084f..fcc6ad5 100644 --- a/node/publish/publish.sh +++ b/node/publish/publish.sh @@ -47,7 +47,33 @@ proposal_title() { local routine_pattern='^\*\*([0-9]+) routine dependency (update|updates) · Validation ' local update_pattern='^\*\*([0-9]+) dependency (update|updates) · ' local fix_pattern=' · ([0-9]+) security (fix|fixes) · ' + local package_fix_pattern=' · ([0-9]+) package security (fix|fixes) · ' + local dependency_pattern='([0-9]+) (routine )?dependency (update|updates)' summary=$(head -n 1 "$1") + if [[ "$summary" == *"platform security update"* ]]; then + updates=0 + update_word=updates + if [[ "$summary" =~ $dependency_pattern ]]; then + updates=${BASH_REMATCH[1]} + update_word=${BASH_REMATCH[3]} + if [ -n "${BASH_REMATCH[2]}" ]; then + printf 'Node: %s routine dependency %s, no security fixes\n' "$updates" "$update_word" + return + fi + fi + if [[ "$summary" =~ $package_fix_pattern ]]; then + fixes=${BASH_REMATCH[1]} + fix_word=${BASH_REMATCH[2]} + if [ "$fixes" = 0 ]; then + printf 'Node: %s dependency %s, no security fixes\n' "$updates" "$update_word" + else + printf 'Node: %s dependency %s, %s security %s\n' "$updates" "$update_word" "$fixes" "$fix_word" + fi + else + printf 'Node: %s dependency %s, security fixes unconfirmed\n' "$updates" "$update_word" + fi + return + fi if [[ "$summary" =~ $routine_pattern ]]; then printf 'Node: %s routine dependency %s, no security fixes\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" elif [[ "$summary" =~ $update_pattern ]]; then diff --git a/node/runtime/node_proposal.py b/node/runtime/node_proposal.py index cafe758..cb828e4 100644 --- a/node/runtime/node_proposal.py +++ b/node/runtime/node_proposal.py @@ -148,32 +148,6 @@ def verify_candidate(root, plan): raise ValueError("Project validation changed a proposed Node declaration") -def render(report, plan, passed): - if not plan["changes"]: - return report + ( - "\n## Node runtime security update\n\n" + plan["message"] + "\n" - if plan["message"] - else "" - ) - rows = [] - for change in plan["changes"]: - before = ( - json.loads(change["before"])["toolchain"]["node"] - if change["path"] == ".github/node-maintenance.json" - else change["before"].strip() - ) - rows.append(f"| `{change['path']}` | `{before}` | `{plan['version']}` |") - status = "passed" if passed else "failed" - return ( - f"**Node runtime security update to {plan['version']} · Validation {status}**\n\n" - + report - + "\n## Node runtime security update\n\n" - + "| Declaration | Before | Proposed |\n| --- | --- | --- |\n" - + "\n".join(rows) - + f"\n\nSecurity release source: {SECURITY['RELEASE_INDEX']}. Same-major security updates bypass the package release cooldown.\n" - ) - - def publish(root, plan): directory, _ = local_project(root) if plan["directory"] != directory.relative_to(root).as_posix(): diff --git a/node/runtime/node_releases.py b/node/runtime/node_releases.py new file mode 100644 index 0000000..d062963 --- /dev/null +++ b/node/runtime/node_releases.py @@ -0,0 +1,138 @@ +"""Collect release-level Node CVE evidence without inferring baseline exposure.""" + +import json +import re +import runpy +import time +from pathlib import Path + +SECURITY = runpy.run_path(str(Path(__file__).with_name("node_security.py"))) +NOTES_SOURCE = "https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/pages/en/blog/release/v{}.md" +SEVERITIES = ("critical", "high", "medium", "low", "info") +MAX_NOTES_BYTES = 262144 +MAX_RELEASES = 32 + + +def parse_notes(version, data): + SECURITY["version_tuple"](version) + text = data.decode("utf-8") if isinstance(data, bytes) else data + if not re.search(rf"^## .*\bVersion {re.escape(version)}\b", text, re.M): + raise ValueError("Release notes do not identify the requested version") + section = re.search( + r"^### Notable Changes\s*\n(.*?)(?=^#{1,3} |\Z)", text, re.M | re.S + ) + if section is None: + raise ValueError("Release notes have no notable changes section") + cves, severities = set(), set() + severity_complete = True + for line in section[1].splitlines(): + ids = re.findall(r"\bCVE-\d{4}-\d{4,}\b", line) + if not ids: + continue + cves.update(ids) + level = r"Critical|High|Medium|Moderate|Low|Info" + match = re.search(rf"(?:[–—-]\s*({level})|\(({level})\))\s*$", line, re.I) + if match: + severity = match[1] or match[2] + severities.add(severity.lower().replace("moderate", "medium")) + else: + severity_complete = False + if not cves: + raise ValueError("No named CVEs could be read from these release notes") + return { + "version": version, + "cves": sorted(cves), + "severity": next((level for level in SEVERITIES if level in severities), None) + if severity_complete + else None, + "message": "", + } + + +def previous_declaration(plan): + values = [] + for change in plan["changes"]: + value = ( + json.loads(change["before"])["toolchain"]["node"] + if change["path"] == ".github/node-maintenance.json" + else change["before"].strip() + ).removeprefix("v") + if re.fullmatch(r"\d+\.\d+\.\d+", value): + SECURITY["version_tuple"](value) + values.append(value) + if values: + if len(set(values)) != 1: + raise ValueError("Conflicting starting Node versions in proposal") + return values[0] + return None + + +def collect(plan, *, read_index=None, read_notes=None): + if not plan: + return None + if not plan["changes"]: + return ( + { + "previous": None, + "target": None, + "releases": [], + "message": plan["message"], + } + if plan["message"] + else None + ) + target = plan["version"] + end = SECURITY["version_tuple"](target) + previous = previous_declaration(plan) + start = SECURITY["version_tuple"](previous) if previous else None + if start and (start[0] != end[0] or start >= end): + raise ValueError("Invalid Node security report version interval") + result = {"previous": previous, "target": target, "releases": [], "message": ""} + versions = [target] + if start: + try: + versions = [ + version + for version in SECURITY["security_releases"](end[0], read=read_index) + if start < SECURITY["version_tuple"](version) <= end + ] + if target not in versions: + raise ValueError("Target is absent from the security release index") + except ValueError: + versions = [target] + result["message"] = ( + "Security release history unavailable; only the target release is shown." + ) + else: + result["message"] = ( + "The previous exact Node version was not pinned. Only the target release is shown." + ) + if len(versions) > MAX_RELEASES: + versions = versions[-MAX_RELEASES:] + result["message"] = ( + "Release history is truncated to the latest 32 security releases in this upgrade." + ) + deadline = time.monotonic() + 45 + for version in versions: + try: + if time.monotonic() >= deadline: + raise ValueError("Release-note lookup time budget exhausted") + data = ( + read_notes(version) + if read_notes is not None + else SECURITY["read_public"]( + NOTES_SOURCE.format(version), + max_bytes=MAX_NOTES_BYTES, + timeout=min(10, deadline - time.monotonic()), + ) + ) + release = parse_notes(version, data) + except (ValueError, UnicodeError, OSError): + release = { + "version": version, + "cves": None, + "severity": None, + "message": "CVE details unavailable; read the release notes.", + } + result["releases"].append(release) + return result diff --git a/node/runtime/node_security.py b/node/runtime/node_security.py index 3b2c3e7..43a5186 100644 --- a/node/runtime/node_security.py +++ b/node/runtime/node_security.py @@ -14,25 +14,29 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return None -def read_release_index(): +def read_public(url, *, max_bytes=MAX_BYTES, timeout=15): request = urllib.request.Request( - RELEASE_INDEX, - headers={"Accept": "application/json", "User-Agent": "node-audit-security"}, + url, + headers={"User-Agent": "node-audit-security"}, ) try: with urllib.request.build_opener(NoRedirect()).open( - request, timeout=15 + request, timeout=timeout ) as response: - data = response.read(MAX_BYTES + 1) + data = response.read(max_bytes + 1) except (OSError, urllib.error.URLError) as error: raise ValueError( - f"Cannot verify Node runtime security: {RELEASE_INDEX}: {error}" + f"Cannot verify Node runtime security: {url}: {error}" ) from error - if len(data) > MAX_BYTES: - raise ValueError("Node release index exceeds the security check size limit") + if len(data) > max_bytes: + raise ValueError("Node security evidence exceeds the security check size limit") return data +def read_release_index(): + return read_public(RELEASE_INDEX) + + def version_tuple(value): if not isinstance(value, str) or not re.fullmatch( r"v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", value @@ -41,7 +45,7 @@ def version_tuple(value): return tuple(map(int, value.removeprefix("v").split("."))) -def latest_security_release(major, *, read=None): +def security_releases(major, *, read=None): data = (read_release_index if read is None else read)() try: releases = json.loads(data) @@ -72,8 +76,11 @@ def latest_security_release(major, *, read=None): raise ValueError( f"Cannot verify Node runtime security: no security release for Node {major}" ) - minimum = max(security_releases) - return ".".join(map(str, minimum)) + return [".".join(map(str, version)) for version in sorted(set(security_releases))] + + +def latest_security_release(major, *, read=None): + return security_releases(major, read=read)[-1] def verify_node_security(installed, *, read=None): diff --git a/node/runtime/templates/dependency_audit.py b/node/runtime/templates/dependency_audit.py index b2e0755..ded2727 100644 --- a/node/runtime/templates/dependency_audit.py +++ b/node/runtime/templates/dependency_audit.py @@ -16,6 +16,7 @@ import yaml LOCAL_PATCHES = runpy.run_path(str(Path(__file__).parents[1] / "local_patches.py")) +NODE_RELEASES = runpy.run_path(str(Path(__file__).parents[1] / "node_releases.py")) SEVERITY_ORDER = {"critical": 0, "high": 1, "moderate": 2, "low": 3, "info": 4} DIRECTORY_PATTERN = re.compile(r"[A-Za-z0-9_./ -]+") @@ -2383,6 +2384,23 @@ def compact_advisory_links(data, name, source, fixed_ids): return links[0] +def package_advisory_links(before, after, name, source, resolved, pending): + links = sorted( + set( + (_advisory_link_items(before, name, source, resolved) if resolved else []) + + (_advisory_link_items(after, name, source, pending) if pending else []) + ) + ) + if not links: + return f"Not supplied by {source}." + count = len(links) - 1 + return links[0] + ( + f" (+{count} additional {'advisory' if count == 1 else 'advisories'}; see full audit details)" + if count + else "" + ) + + def audit_fix_reason(detail, source): status = detail.get("fixStatus", "") if "Blocked by retained override" in status: @@ -2458,6 +2476,8 @@ def format_audit_summary( fixed_ids=None, fixes_only=False, reported_parents=None, + locks=None, + summary=None, ) -> str: if isinstance(after, str): return "" if fixes_only else f"⚪ **Security:** {after}" @@ -2493,6 +2513,9 @@ def complete(data, rows): target.setdefault(name, {})[identity] = detail rows = [] + old_versions, new_versions = ( + (locked_version_sets(lock) for lock in locks) if locks else ({}, {}) + ) fixed_packages, review_packages, blocked_packages = set(), set(), set() new_packages, unconfirmed_packages = set(), set() for name in old_groups.keys() | groups.keys(): @@ -2631,23 +2654,40 @@ def complete(data, rows): badge, rank = "", 2 else: badge, rank = "⚪", 3 + severity_details = details + unconfirmed_details + list(previous.values()) severity = min( - ( - SEVERITY_ORDER.get(detail["severity"], 5) - for detail in details + unconfirmed_details - ), + (SEVERITY_ORDER.get(detail["severity"], 5) for detail in severity_details), default=5, ) - label = f"{badge}\u00a0" if badge else "" + severity_label = next( + ( + level.title() + for level, order in SEVERITY_ORDER.items() + if order == severity + ), + "Unknown", + ) + old_version = ", ".join(sorted(old_versions.get(name, set()))) + new_version = ", ".join(sorted(new_versions.get(name, set()))) + version_change = ( + f"`{old_version or 'not installed'}` → `{new_version or 'not installed'}`" + if old_version != new_version + else f"`{new_version}` (unchanged)" + if new_version + else "Not recorded" + ) + label = { + "🟢": "✅ Fix proposed. ", + "🟠": "🟠 Needs review. ", + "🔴": "🔴 New findings. ", + "⚪": "⚪ Needs verification. ", + "": "Blocked. ", + }[badge] rows.append( ( (rank, severity, name), - f"| {label}`{name}` | {' · '.join(counts)} | {action.strip()} |" - + ( - f" {compact_advisory_links(before, name, source, resolved_ids)} |" - if fixes_only - else "" - ), + f"| `{name}` | {version_change} | {severity_label} | {' · '.join(counts)} | {label}{action.strip()} | " + f"{package_advisory_links(before, after, name, source, resolved_ids.intersection(previous) if fixes_only else set(previous), set() if fixes_only else set(pending))} |", ) ) @@ -2678,19 +2718,110 @@ def packages(number): ) if not comparable: headline += "\n\nResolution unconfirmed; initial or detailed audit evidence unavailable." + if summary is not None: + summary.update( + review_count=len(review_packages), fixed_packages=len(fixed_packages) + ) if rows: unit = "Advisories" if source == "pnpm" else "Affected package entries" headline += ( - f"\n\n| Package | {unit} | Action / blocker |" - + (" Advisories |" if fixes_only else "") - + "\n| --- | --- | --- |" - + (" --- |" if fixes_only else "") + f"\n\n| Package | Update | Severity | {unit} | Action / blocker | Security evidence |" + + "\n| --- | --- | --- | --- | --- | --- |" + "\n" ) headline += "\n".join(row for _, row in sorted(rows)) return headline +def format_platform_security(platform, passed): + if not platform: + return "No platform upgrade is proposed.\n\n" + if not platform["target"]: + return "⚪ " + platform["message"] + "\n\n" + previous = platform["previous"] or "previous patch not pinned" + status = ( + "✅ Upgrade validated" + if passed + else "⚪ Upgrade validation failed or unavailable" + ) + text = f"{status}: **Node.js** `{previous} → {platform['target']}`.\n\n" + if platform["message"]: + text += platform["message"] + "\n\n" + text += ( + "| Security release | Highest severity | CVE summary | Details |\n" + "| --- | --- | --- | --- |\n" + ) + for release in platform["releases"]: + version = release["version"] + severity = (release["severity"] or "unavailable").title() + cves = release["cves"] + count = len(cves) if cves is not None else None + description = ( + f"{count} named CVE" + ("" if count == 1 else "s") + if count is not None + else release["message"] + ) + url = f"https://nodejs.org/en/blog/release/v{version}/" + text += f"| `{version}` | {severity} | {description} | [CVE list]({url}#notable-changes) · [Release notes]({url}) |\n" + return text + ( + "\nCVE counts describe each release's notes, not confirmed vulnerabilities in " + "the starting runtime. They are not added together as a unique-CVE total. " + "Bundled dependency fixes may address additional vulnerabilities.\n\n" + ) + + +def format_security_overview( + *, + platform, + package_report, + package_fixes, + package_updates, + review_count, + passed, + routine_only=False, +): + platform_count = int(bool(platform and platform["target"])) + parts = [ + f"{platform_count} platform security update" + + ("" if platform_count == 1 else "s") + ] + if package_updates: + parts.append( + f"{package_updates} {'routine ' if routine_only else ''}dependency update" + + ("" if package_updates == 1 else "s") + ) + parts.append( + f"{package_fixes} package security fix" + ("" if package_fixes == 1 else "es") + if package_fixes is not None + else "Package security fixes unconfirmed" + ) + if review_count: + parts.append( + f"🟠 {review_count} package" + + (" needs" if review_count == 1 else "s need") + + " your review" + ) + parts.append("Validation " + ("passed" if passed else "failed or unavailable")) + return ( + "**" + + " · ".join(parts) + + "**\n\n" + + "## Platform security\n\n" + + format_platform_security(platform, passed) + + "## Package security\n\n" + + package_report + + "\n\n" + ) + + +def format_validation(install_status, check_status): + return ( + "## Validation\n\n" + f"- Frozen install: {install_status[0].lower() + install_status[1:]}\n" + f"- Project checks: {check_status[0].lower() + check_status[1:]}\n\n" + ) + + def format_change_report( proposed_changes: str, security_fixes: str, routine_upgrades: str = "" ) -> str: @@ -2894,21 +3025,22 @@ def eligible_version( return None +def locked_version_sets(lock: dict) -> dict[str, set[str]]: + result: dict[str, set[str]] = {} + for path, package in lock.get("packages", {}).items(): + version = package.get("version") + name = package.get("name") or ( + path.rsplit("node_modules/", 1)[-1] if "node_modules/" in path else None + ) + if name and version: + result.setdefault(name, set()).add(version) + return result + + def locked_changes(before: dict, after: dict) -> list[str]: """Compare all locked version sets, independent of advisory attribution.""" - def version_sets(lock: dict) -> dict[str, set[str]]: - result: dict[str, set[str]] = {} - for path, package in lock.get("packages", {}).items(): - version = package.get("version") - name = package.get("name") or ( - path.rsplit("node_modules/", 1)[-1] if "node_modules/" in path else None - ) - if name and version: - result.setdefault(name, set()).add(version) - return result - - old, new = version_sets(before), version_sets(after) + old, new = locked_version_sets(before), locked_version_sets(after) return [ f"- {name}: `{', '.join(sorted(old.get(name, set()))) or 'not installed'}` → " f"`{', '.join(sorted(new.get(name, set()))) or 'not installed'}`" @@ -2927,6 +3059,7 @@ def prepare( upgrade_scope="major", local_patches=None, validate_only=False, + platform=None, ) -> str: # Leave time in the 60-minute job to write and upload a partial report. deadline = time.monotonic() + 45 * 60 @@ -3561,14 +3694,6 @@ def published_patch_versions(specifier, *, all_versions=False): and not new_ids and not security_changes ) - update_label = "update" if len(locked_updates) == 1 else "updates" - proposal_summary = f"{len(locked_updates)} {'routine ' if routine_only else ''}dependency {update_label}" - if not routine_only: - proposal_summary += ( - f" · {fixed_count} security {'fix' if fixed_count == 1 else 'fixes'}" - if initial_ids is not None and remaining_ids is not None - else " · Security fixes unconfirmed" - ) before_audit = format_audit(before, before_lock) after_audit = format_audit(after, after_lock) blocked_findings = ( @@ -3579,8 +3704,31 @@ def published_patch_versions(specifier, *, all_versions=False): and not security_changes else None ) + package_metrics = {} + package_report = format_audit_summary( + before, + after, + package_manager, + fixed_ids=fixed_ids, + reported_parents=reported_parents, + locks=(before_lock, after_lock), + summary=package_metrics, + ) + background_blockers = bool(routine_only and blocked_findings) + if background_blockers: + package_report = f"{remaining_count} {remaining_unit} {'is' if remaining_count == 1 else 'are'} blocked, unchanged by this PR." report = ( - f"**{proposal_summary} · Validation {'passed' if check_status == 'Passed.' else 'failed or unavailable'}**\n\n" + format_security_overview( + platform=platform, + package_report=package_report, + package_fixes=fixed_count + if initial_ids is not None and remaining_ids is not None + else None, + package_updates=len(locked_updates), + review_count=package_metrics.get("review_count", 0), + passed=check_status == "Passed.", + routine_only=routine_only, + ) + ( "## Security audit incomplete\n\n" "This run fails because complete before/after security evidence is unavailable. " @@ -3590,15 +3738,11 @@ def published_patch_versions(specifier, *, all_versions=False): if audit_errors else "" ) - + ( - f"## Validation\n\n" - f"- Frozen install: {install_status[0].lower() + install_status[1:]}\n" - f"- Project checks: {check_status[0].lower() + check_status[1:]}\n\n" - ) + + format_validation(install_status, check_status) + ( f"## Existing security findings\n\n{remaining_count} {remaining_unit} {'is' if remaining_count == 1 else 'are'} blocked, unchanged by this PR.\n\n" if blocked_findings - else f"## Security status\n\n{security_counts}.\n\n" + else f"### Package advisory counts\n\n{security_counts}.\n\n" ) ) if isinstance(after, str): @@ -3625,21 +3769,15 @@ def published_patch_versions(specifier, *, all_versions=False): report += "Initial audit evidence was unavailable; retained security-fix changes are unconfirmed.\n\n" report += format_change_report( proposed_changes, - format_audit_summary( - before, - after, - package_manager, - fixed_ids=fixed_ids, - fixes_only=True, - reported_parents=reported_parents, - ), + "", format_routine_upgrades(routine_rows, release_links), ) - report += ( - f"
\n{'Blocked findings and constraints' if blocked_findings else 'Security findings and blockers'}\n\n" - f"{blocked_findings or format_audit_summary(before, after, package_manager, fixed_ids=fixed_ids, reported_parents=reported_parents)}\n\n" - "
\n\n" - ) + if background_blockers: + report += ( + "
\nBlocked findings and constraints\n\n" + + blocked_findings + + "\n\n
\n\n" + ) if change_sections: report += ( "
\nAdditional dependency change details\n\n" @@ -3728,6 +3866,12 @@ def main() -> None: validation: dict = {} try: patches = [] + node_plan = None + if args.node_update is not None: + node_proposal = runpy.run_path( + str(Path(__file__).parents[1] / "node_proposal.py") + ) + node_plan = node_proposal["read_plan"](args.node_update) if args.resolved_config is not None: if not args.resolved_config.is_file(): raise ValueError("Resolved configuration snapshot is missing") @@ -3748,14 +3892,13 @@ def main() -> None: upgrade_scope=args.upgrade_scope, local_patches=patches, validate_only=args.validate_only, + platform=NODE_RELEASES["collect"](node_plan), ) if args.node_update is not None: node_proposal = runpy.run_path( str(Path(__file__).parents[1] / "node_proposal.py") ) - node_plan = node_proposal["read_plan"](args.node_update) node_proposal["verify_candidate"](Path.cwd().resolve(), node_plan) - report = node_proposal["render"](report, node_plan, validation["passed"]) except (OSError, ValueError) as error: parser.error(str(error)) if args.local_patches_report is not None: diff --git a/node/tests/security_report_examples.py b/node/tests/security_report_examples.py new file mode 100644 index 0000000..f444be1 --- /dev/null +++ b/node/tests/security_report_examples.py @@ -0,0 +1,158 @@ +"""Render illustrative evidence through the production report functions.""" + +import argparse +import copy +import json +import runpy +from pathlib import Path + +ENGINE = runpy.run_path( + str(Path(__file__).parents[1] / "runtime/templates/dependency_audit.py") +) +RELEASES = ENGINE["NODE_RELEASES"] +EXAMPLE_CVES = { + "24.18.1": [ + "CVE-2026-56846", + "CVE-2026-56848", + "CVE-2026-58043", + "CVE-2026-56850", + "CVE-2026-58040", + "CVE-2026-58041", + "CVE-2026-58042", + "CVE-2026-58045", + "CVE-2026-56847", + "CVE-2026-58039", + "CVE-2026-58044", + ], + "24.22.1": [ + "CVE-2026-106069", + "CVE-2026-106072", + "CVE-2026-105482", + "CVE-2026-105483", + "CVE-2026-111497", + "CVE-2026-105481", + "CVE-2026-106074", + "CVE-2026-106073", + ], +} + + +def release_index(): + return json.dumps( + [{"version": "v" + version, "security": True} for version in EXAMPLE_CVES] + ) + + +def release_notes(version): + # Minimal release-note fixtures: real IDs, synthetic descriptions for parser tests. + return ( + f"## 2026-01-01, Version {version}\n\n### Notable Changes\n" + + "\n".join(f"- ({cve}) security fix – High" for cve in EXAMPLE_CVES[version]) + + "\n### Commits\n" + ) + + +def node_plan(previous="24.18.0"): + return { + "schema_version": 1, + "directory": "website", + "version": "24.22.1", + "changes": [ + {"path": "website/.nvmrc", "before": previous + "\n", "after": "24.22.1\n"} + ], + "message": "", + } + + +def package_evidence(): + def detail(name, severity): + return { + "url": f"https://example.invalid/advisories/{name}", + "severity": severity, + "fixStatus": ( + "Published patch: `package-b@2.0.2`. Blocked by retained override " + "`pnpm-workspace.yaml overrides.package-b` = `2.0.0`." + if name == "package-b" + else "" + ), + } + + before = { + "metadata": {"vulnerabilities": {"critical": 1, "high": 1}}, + "vulnerabilities": { + "package-a": {"via": [detail("package-a", "critical")]}, + "package-b": {"via": [detail("package-b", "high")]}, + }, + } + after = copy.deepcopy(before) + del after["vulnerabilities"]["package-a"] + after["metadata"]["vulnerabilities"] = {"high": 1} + locks = [ + { + "packages": { + "node_modules/package-a": {"version": version}, + "node_modules/package-b": {"version": "2.0.0"}, + } + } + for version in ("1.0.0", "1.0.1") + ] + return before, after, locks + + +def render_examples(output_directory, *, live_platform=False): + output_directory.mkdir(parents=True, exist_ok=True) + platform = RELEASES["collect"]( + node_plan(), + **( + {} + if live_platform + else {"read_index": release_index, "read_notes": release_notes} + ), + ) + result = {} + for name, use_platform, use_packages in ( + ("platform-only", True, False), + ("packages-only", False, True), + ("platform-and-packages", True, True), + ): + before, after, locks = package_evidence() + if not use_packages: + before = after = { + "metadata": {"vulnerabilities": {}}, + "vulnerabilities": {}, + } + metrics = {} + report = ENGINE["format_audit_summary"]( + before, after, "pnpm", locks=locks, summary=metrics + ) + text = ENGINE["format_security_overview"]( + platform=platform if use_platform else None, + package_report=report, + package_fixes=int(use_packages), + package_updates=int(use_packages), + review_count=metrics["review_count"], + passed=True, + ) + ENGINE["format_validation"]("Passed.", "Passed.") + text += ( + "---\n\n*Generated preview. Package and validation evidence is illustrative; " + "example.invalid advisory links are placeholders. Platform release evidence " + + ( + "was read from official Node.js sources." + if live_platform + else "uses offline release-note fixtures." + ) + + "*\n" + ) + path = output_directory / f"{name}.md" + path.write_text(text) + result[name] = text + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-directory", type=Path, required=True) + parser.add_argument("--live-platform", action="store_true") + args = parser.parse_args() + render_examples(args.output_directory, live_platform=args.live_platform) + print(args.output_directory) diff --git a/node/tests/test_dependency_audit.py b/node/tests/test_dependency_audit.py index 90e3eba..6eb0f9f 100644 --- a/node/tests/test_dependency_audit.py +++ b/node/tests/test_dependency_audit.py @@ -101,7 +101,7 @@ def test_upgrade_keeps_manual_constraints_and_reports_unfixed_findings( assert "high: 1, total: 1" in report assert "npm fix candidate not offered by npm" in report assert "- Project checks: passed." in report - assert "| Package | Blocked package entries |" in report + assert "| Package | Update | Severity | Affected package entries |" in report assert "--force" not in [arg for command in commands for arg in command] @@ -531,7 +531,10 @@ def test_cli_entrypoint_prepares_selected_project(npm_project, monkeypatch): ["dependency-audit", "--directory", ".", "--report", str(report)], ) runpy.run_path(audit_module["__file__"], run_name="__main__") - assert "| Package | Blocked package entries |" in report.read_text() + assert ( + "| Package | Update | Severity | Affected package entries |" + in report.read_text() + ) assert not (npm_project / "DEPENDENCY-AUDIT.md").exists() manifest = json.loads((npm_project / "package.json").read_text()) assert manifest["dependencies"]["renderer"] == "2.0.0" diff --git a/node/tests/test_maintenance_options.py b/node/tests/test_maintenance_options.py index 361dc35..d9054b2 100644 --- a/node/tests/test_maintenance_options.py +++ b/node/tests/test_maintenance_options.py @@ -341,7 +341,7 @@ def churn(directory, *args, **kwargs): report = helper["prepare"](site, maintenance_mode="security-only") assert helper["valid_snapshot"](site) == original assert "Discarded dependency changes" in report - assert report.startswith("**0 dependency updates") + assert report.startswith("**0 platform security updates ·") def test_clear_headline_reports_optional_updates_and_unfixed_security( @@ -349,7 +349,9 @@ def test_clear_headline_reports_optional_updates_and_unfixed_security( ): fake_commands(monkeypatch) report = helper["prepare"](site) - assert report.startswith("**1 routine dependency update · Validation passed**") + assert report.startswith( + "**0 platform security updates · 1 routine dependency update · 0 package security fixes" + ) def test_routine_report_shows_selected_direct_upgrades_and_release_notes( @@ -577,7 +579,7 @@ def churn(directory, *args, **kwargs): monkeypatch.setitem(helper["prepare"].__globals__, "fix_security", churn) report = helper["prepare"](site, maintenance_mode="security-only") assert helper["valid_snapshot"](site) == original - assert report.startswith("**0 dependency updates · 0 security fixes") + assert report.startswith("**0 platform security updates ·") assert "**Fixes in" not in report @@ -896,7 +898,10 @@ def test_security_summary_keeps_disappeared_unconfirmed_advisory_visible(): summary = helper["format_audit_summary"]( before, _clear_finding(), "pnpm", fixed_ids=set() ) - assert "| ⚪\u00a0`renderer` | 1 unconfirmed |" in summary + assert ( + "| `renderer` | Not recorded | High | 1 unconfirmed | ⚪ Needs verification." + in summary + ) assert "No longer reported; fix could not be confirmed." in summary assert "Original affected range: `<1.3.0`." in summary assert "Resolved." not in summary @@ -2182,10 +2187,12 @@ def audit(*args, **kwargs): monkeypatch.setitem(helper["prepare"].__globals__, "audit", audit) report = helper["prepare"](site) - assert report.startswith("**1 routine dependency update · Validation passed**") + assert report.startswith( + "**0 platform security updates · 1 routine dependency update · 0 package security fixes" + ) visible, details = report.split("
", 1) assert visible.index("## Validation") < visible.index( - "## Existing security findings" if blocked else "## Security status" + "## Existing security findings" if blocked else "### Package advisory counts" ) assert "- renderer:" not in visible assert details.startswith("\nAll dependency changes") @@ -2202,14 +2209,17 @@ def audit(*args, **kwargs): else: assert "unchanged by this PR" not in report assert "4 advisories remain (2 critical)" in visible - assert "Security findings and blockers" in details + assert "| Package | Update | Severity |" in visible if not changed_evidence: assert ( - "your review" in details + "your review" in visible if "Published patch:" in status - else "verification" in details + else "verification" in visible ) - assert "worker" not in visible and "Fixes blocked" not in visible + if blocked: + assert "worker" not in visible and "Fixes blocked" not in visible + else: + assert "worker" in visible assert "Validation script:" not in visible assert "Validation script:" in details assert "🔴" not in report diff --git a/node/tests/test_node_proposals.py b/node/tests/test_node_proposals.py index 151df26..30f2c36 100644 --- a/node/tests/test_node_proposals.py +++ b/node/tests/test_node_proposals.py @@ -25,6 +25,13 @@ def release_index(monkeypatch): "read_release_index", lambda: '[{"version":"v24.22.1","security":true}]', ) + monkeypatch.setitem( + ENGINE["NODE_RELEASES"]["SECURITY"], + "read_public", + lambda *args, **kwargs: ( + "## 2026-10-21, Version 24.22.1\n### Notable Changes\n- (CVE-2026-111497) fix – High\n### Commits\n" + ), + ) def site(root, declaration="24\n"): @@ -242,7 +249,8 @@ def run(directory, *args, **kwargs): ], ) ENGINE["main"]() - assert "Node runtime security update to 24.22.1" in report.read_text() + assert "1 platform security update" in report.read_text() + assert "24.22.1" in report.read_text() assert "0 security fixes" in report.read_text() git("clone", "--branch", "main", str(remote), str(publisher), cwd=tmp_path) target = PROPOSAL["publish"](publisher, PROPOSAL["read_plan"](plan_path)) diff --git a/node/tests/test_node_standard.py b/node/tests/test_node_standard.py index b0b62af..4b2da47 100644 --- a/node/tests/test_node_standard.py +++ b/node/tests/test_node_standard.py @@ -861,14 +861,18 @@ def test_summary_precedes_collapsed_full_audit(npm_project, monkeypatch): assert "**Maintenance:**" not in summary assert "**Maintenance:** 0 additional package updates (0 total)." in details assert "0 security fixes · 0 affected package entries remain" in summary - assert "🟢 **No security findings**" in details + assert "🟢 **No security findings**" in summary assert "| Package | Current | Proposed | Release notes |" in summary assert "## Security fixes" not in summary assert "- Project checks: passed." in summary assert "renderer: `1.0.0` → `2.0.0`" not in summary assert "renderer: `1.0.0` → `2.0.0`" in details assert details.startswith("\nAll dependency changes") - assert summary.index("## Validation") < summary.index("## Security status") + assert ( + summary.index("## Platform security") + < summary.index("## Package security") + < summary.index("## Validation") + ) assert "Validation script:" not in summary assert "Run configuration and audit evidence" in details assert "No upgrades to affected dependencies" not in report @@ -984,7 +988,9 @@ def run(command, **kwargs): report = prepare(npm_project) visible, details = report.split("
", 1) if change == "unchanged": - assert report.startswith("**1 routine dependency update · Validation passed**") + assert report.startswith( + "**0 platform security updates · 1 routine dependency update · 0 package security fixes · Validation passed**" + ) assert "1 affected package entry is blocked, unchanged by this PR." in visible assert "🔴" not in report else: @@ -996,8 +1002,8 @@ def run(command, **kwargs): ) assert "unchanged by this PR" not in visible assert ( - "| 🔴\u00a0`leaf` | 1 pending | New advisories reported for this affected package entry." - in details + "| `leaf` | `1.0.0` (unchanged) | High | 1 pending | 🔴 New findings. New advisories reported for this affected package entry." + in visible ) @@ -1027,10 +1033,21 @@ def test_security_outcomes_distinguish_resolved_and_new_with_equal_counts( before = security_findings([("old-package", "old", "high", "")]) after = security_findings([("new-package", "new", "high", "")]) result = audit_module["format_audit_summary"](before, after, "pnpm") - assert "| 🟢\u00a0`old-package` | 1 fixed | Resolved. |" in result - assert "| 🔴\u00a0`new-package` | 1 pending | 1 new advisory." in result + assert ( + "| `old-package` | Not recorded | High | 1 fixed | ✅ Fix proposed. Resolved. |" + in result + ) + assert ( + "| `new-package` | Not recorded | High | 1 pending | 🔴 New findings. 1 new advisory." + in result + ) assert result.index("`old-package`") < result.index("`new-package`") - assert result.count("| Package | Advisories | Action / blocker |") == 1 + assert ( + result.count( + "| Package | Update | Severity | Advisories | Action / blocker | Security evidence |" + ) + == 1 + ) assert "**Fixes in 1 package**" in result assert "**New findings in 1 package**" in result assert "After merging:" not in result @@ -1044,8 +1061,14 @@ def test_security_outcomes_keep_shared_advisory_open_after_path_change( result = audit_module["format_audit_summary"](before, after, "pnpm") assert "🟢" not in result and "New findings" not in result assert "Still reported under `new-path`." in result - assert "| ⚪\u00a0`old-path` | 1 pending |" in result - assert "| ⚪\u00a0`new-path` | 1 pending |" in result + assert ( + "| `old-path` | Not recorded | Critical | 1 pending | ⚪ Needs verification." + in result + ) + assert ( + "| `new-path` | Not recorded | Critical | 1 pending | ⚪ Needs verification." + in result + ) def test_security_outcomes_report_partial_resolution_for_one_package(security_findings): @@ -1057,7 +1080,7 @@ def test_security_outcomes_report_partial_resolution_for_one_package(security_fi security_findings([first] + unchanged), "pnpm", ) - assert "| `leaf` | 1 fixed · 1 pending |" in result + assert "| `leaf` | Not recorded | High | 1 fixed · 1 pending |" in result assert result.count("`leaf`") == 1 assert all(f"`aaa-{i}`" in result for i in range(7)) assert "🟠" not in result @@ -1087,7 +1110,7 @@ def test_security_outcomes_summarize_concrete_blockers(security_findings): ) result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( - "| `tinypool` | 2 pending | Blocked by the `@docusaurus/core` version constraint. |" + "| `tinypool` | Not recorded | Critical | 2 pending | Blocked. Blocked by the `@docusaurus/core` version constraint. |" in result ) assert "No published patch matches the fix range." in result @@ -2019,10 +2042,15 @@ def test_attention_lists_all_blocking_pins_before_other_findings(security_findin rows.append(("pinned-0", "another", "moderate", rows[1][3])) data = security_findings(rows) result = audit_module["format_audit_summary"](data, data, "pnpm") - assert result.count("| Package | Advisories | Action / blocker |") == 1 + assert ( + result.count( + "| Package | Update | Severity | Advisories | Action / blocker | Security evidence |" + ) + == 1 + ) assert result.index("`pinned-6`") < result.index("`aaa-parent`") - assert result.count("| 🟠\u00a0`pinned-") == 7 - assert "| 🟠\u00a0`pinned-0` | 2 pending |" in result + assert result.count("| `pinned-") == 7 + assert "| `pinned-0` | Not recorded | High | 2 pending | 🟠 Needs review." in result assert ( "Pin `1.0.0` blocks fix `1.2.0`; review removing or updating the pin." in result ) @@ -2048,7 +2076,7 @@ def test_attention_escapes_disjunctive_pin_and_preserves_scoped_name(security_fi data = security_findings([("@scope/leaf", "one", "high", status)]) result = audit_module["format_audit_summary"](data, data, "pnpm") assert ( - "| 🟠\u00a0`@scope/leaf` | 1 pending | Pin `1.0.0 || 2.0.0` blocks fix `2.0.1`" + "| `@scope/leaf` | Not recorded | High | 1 pending | 🟠 Needs review. Pin `1.0.0 || 2.0.0` blocks fix `2.0.1`" in result ) @@ -2057,7 +2085,9 @@ def test_attention_is_absent_when_no_blocking_pin_was_verified(security_findings data = security_findings([("leaf", "one", "high", "Could not verify publication.")]) result = audit_module["format_audit_summary"](data, data, "pnpm") assert "🟠" not in result and "your review" not in result - assert "| ⚪\u00a0`leaf` | 1 pending |" in result + assert ( + "| `leaf` | Not recorded | High | 1 pending | ⚪ Needs verification." in result + ) assert "unconfirmed" in result @@ -2077,7 +2107,12 @@ def test_single_table_keeps_other_advisories_for_a_pinned_package_visible( ] ) result = audit_module["format_audit_summary"](data, data, "pnpm") - assert result.count("| 🟠\u00a0`leaf` | 2 pending |") == 1 + assert ( + result.count( + "| `leaf` | Not recorded | Critical | 2 pending | 🟠 Needs review." + ) + == 1 + ) assert "No published patch matches the fix range." in result assert "**1 package needs your review**" in result assert "**Fixes blocked for 1 package**" in result @@ -2086,7 +2121,10 @@ def test_single_table_keeps_other_advisories_for_a_pinned_package_visible( def test_fully_resolved_findings_still_produce_one_table(security_findings): before = security_findings([("leaf", "closed", "critical", "")]) result = audit_module["format_audit_summary"](before, security_findings([]), "pnpm") - assert "| 🟢\u00a0`leaf` | 1 fixed | Resolved. |" in result + assert ( + "| `leaf` | Not recorded | Critical | 1 fixed | ✅ Fix proposed. Resolved. |" + in result + ) assert result.count("| Package |") == 1 assert result.splitlines()[0] == "🟢 **Fixes in 1 package**" assert "remaining" not in result @@ -2123,7 +2161,7 @@ def test_visible_security_fixes_exclude_unrelated_and_unconfirmed_findings( table = audit_module["format_audit_summary"]( before, after, "pnpm", fixed_ids=fixed_ids, fixes_only=True ) - assert "| `leaf` | 1 fixed · 1 pending |" in table + assert "| `leaf` | Not recorded | High | 1 fixed · 1 pending |" in table assert "The registry supplies no patched version." in table assert "unrelated" not in table and "withdrawn" not in table report = audit_module["format_change_report"]("- leaf: `1.0.0` → `1.1.0`", table) @@ -2145,7 +2183,10 @@ def test_new_advisories_for_existing_package_stay_visible(security_findings): security_findings(old + [("package-6", "new", "high", "")]), "pnpm", ) - assert "| 🔴\u00a0`package-6` | 2 pending | 1 new advisory." in result + assert ( + "| `package-6` | Not recorded | High | 2 pending | 🔴 New findings. 1 new advisory." + in result + ) assert all(result.count(f"`package-{i}`") == 1 for i in range(7)) @@ -2274,7 +2315,7 @@ def test_security_fix_links_follow_indirect_advisories(propagated_npm_finding): before, after, "npm", fixed_ids={"parent"}, fixes_only=True ) assert ( - "| 🟢\u00a0`parent` | 1 fixed | Resolved. | [GHSA-test-leaf](https://github.com/advisories/GHSA-test-leaf) |" + "| `parent` | Not recorded | Low | 1 fixed | ✅ Fix proposed. Resolved. | [GHSA-test-leaf](https://github.com/advisories/GHSA-test-leaf) |" in table ) # A cycle must terminate and retain any reachable direct advisory links. @@ -2552,10 +2593,10 @@ def test_npm_outcomes_keep_package_entry_semantics(): "vulnerabilities": {"new": {"severity": "critical", "fixAvailable": False}}, } result = audit_module["format_audit_summary"](before, after, "npm") - assert "| Package | Affected package entries |" in result - assert "| 🟢\u00a0`old` | 1 fixed |" in result + assert "| Package | Update | Severity | Affected package entries |" in result + assert "| `old` | Not recorded | High | 1 fixed | ✅ Fix proposed." in result assert ( - "| 🔴\u00a0`new` | 1 pending | 1 new affected package entry. No fix offered by npm. |" + "| `new` | Not recorded | Critical | 1 pending | 🔴 New findings. 1 new affected package entry. No fix offered by npm. |" in result ) assert "advisory" not in result and "Advisories" not in result @@ -2683,8 +2724,11 @@ def update(command, **kwargs): else: assert "**Maintenance:** 1 additional package update (2 total)." in details assert "1 security fix" in visible - assert "## Security fixes" in visible - assert "| 🟢\u00a0`security` | 1 fixed | Resolved. |" in visible + assert "## Package security" in visible + assert ( + "| `security` | `1.0.0` → `1.1.0` | Critical | 1 fixed | ✅ Fix proposed. Resolved. |" + in visible + ) def test_partial_package_is_orange_only_when_pending_work_needs_user_decision( @@ -2704,9 +2748,9 @@ def test_partial_package_is_orange_only_when_pending_work_needs_user_decision( security_findings([current]), "pnpm", ) - label = f"{badge}\u00a0" if badge else "" - assert f"| {label}`leaf` | 1 fixed · 1 pending |" in result - assert result.count(f"| {label}`leaf`") == 1 + assert "| `leaf` | Not recorded | High | 1 fixed · 1 pending |" in result + assert result.count("| `leaf`") == 1 + assert ("🟠 Needs review." in result) == (badge == "🟠") assert ("your review" in result) == (badge == "🟠") @@ -2715,7 +2759,7 @@ def test_package_advisory_counts_deduplicate_repeated_identity(security_findings before = security_findings([repeated, repeated, ("leaf", "fixed", "high", "")]) after = security_findings([repeated, repeated]) result = audit_module["format_audit_summary"](before, after, "pnpm") - assert "| `leaf` | 1 fixed · 1 pending |" in result + assert "| `leaf` | Not recorded | High | 1 fixed · 1 pending |" in result def test_missing_initial_evidence_cannot_color_a_package_resolved(security_findings): @@ -2723,7 +2767,7 @@ def test_missing_initial_evidence_cannot_color_a_package_resolved(security_findi [("leaf", "open", "high", "No patched version declared by the registry.")] ) result = audit_module["format_audit_summary"]("Audit unavailable", current, "pnpm") - assert "| `leaf` | fixes unconfirmed · 1 pending |" in result + assert "| `leaf` | Not recorded | High | fixes unconfirmed · 1 pending |" in result assert "🟢" not in result and "🟠" not in result @@ -2736,7 +2780,7 @@ def test_npm_major_upgrade_requires_user_attention(): } result = audit_module["format_audit_summary"](data, data, "npm") assert ( - "| 🟠\u00a0`leaf` | 1 pending | The offered fix requires a major upgrade." + "| `leaf` | Not recorded | High | 1 pending | 🟠 Needs review. The offered fix requires a major upgrade." in result ) assert "your review" in result diff --git a/node/tests/test_security_reporting.py b/node/tests/test_security_reporting.py new file mode 100644 index 0000000..da652ff --- /dev/null +++ b/node/tests/test_security_reporting.py @@ -0,0 +1,231 @@ +import json +import os +import subprocess + +import pytest +from security_report_examples import ( + ENGINE, + RELEASES, + node_plan, + package_evidence, + release_index, + release_notes, + render_examples, +) + + +def test_release_history_excludes_starting_release_and_other_majors(): + data = json.loads(release_index()) + [ + {"version": "v24.18.0", "security": True}, + {"version": "v24.20.0", "security": False}, + {"version": "v26.12.1", "security": True}, + ] + report = RELEASES["collect"]( + node_plan(), read_index=lambda: json.dumps(data), read_notes=release_notes + ) + assert [r["version"] for r in report["releases"]] == ["24.18.1", "24.22.1"] + assert [len(r["cves"]) for r in report["releases"]] == [11, 8] + assert all(r["severity"] == "high" for r in report["releases"]) + + +def test_major_only_declaration_does_not_invent_a_previous_patch_or_history(): + report = RELEASES["collect"]( + node_plan("24"), + read_index=lambda: pytest.fail("unknown starting patch"), + read_notes=release_notes, + ) + assert report["previous"] is None + assert [r["version"] for r in report["releases"]] == ["24.22.1"] + assert "not pinned" in report["message"] + + +@pytest.mark.parametrize( + "problem", ["network", "wrong-version", "no-cves", "malformed"] +) +def test_missing_cve_evidence_is_unavailable_and_never_zero(problem): + def notes(version): + if problem == "network": + raise ValueError("offline") + if problem == "wrong-version": + return release_notes("24.18.1") + if problem == "no-cves": + return ( + "## 2026-01-01, Version 24.22.1\n### Notable Changes\n- deps: update\n" + ) + return b"\xff" + + platform = RELEASES["collect"](node_plan("24"), read_notes=notes) + text = ENGINE["format_platform_security"](platform, True) + assert "Unavailable" in text and "CVE details unavailable" in text + assert "0 named CVEs" not in text + + +def test_missing_history_shows_target_and_explicit_coverage_limit(): + def offline(): + raise ValueError("offline") + + platform = RELEASES["collect"]( + node_plan(), read_index=offline, read_notes=release_notes + ) + assert "history unavailable" in platform["message"] + assert [r["version"] for r in platform["releases"]] == ["24.22.1"] + + +def test_untrusted_versions_cannot_choose_an_arbitrary_release_note_url(): + plan = node_plan() + plan["version"] = "../../injected" + with pytest.raises(ValueError): + RELEASES["collect"](plan, read_notes=lambda v: pytest.fail("untrusted URL")) + + +def test_release_parser_counts_unique_ids_only_in_notable_changes(): + notes = release_notes("24.22.1") + "- CVE-2026-999999 – Critical\n" + notes = notes.replace( + "### Commits", "- (CVE-2026-111497) duplicate fix – High\n### Commits" + ) + result = RELEASES["parse_notes"]("24.22.1", notes) + assert len(result["cves"]) == 8 and result["severity"] == "high" + + +def test_package_severity_is_independent_from_review_status_and_versions(): + before, after, locks = package_evidence() + summary = {} + text = ENGINE["format_audit_summary"]( + before, after, "pnpm", locks=locks, summary=summary + ) + assert ( + "| `package-a` | `1.0.0` → `1.0.1` | Critical | 1 fixed | ✅ Fix proposed." + in text + ) + assert ( + "| `package-b` | `2.0.0` (unchanged) | High | 1 pending | 🟠 Needs review." + in text + ) + assert summary == {"fixed_packages": 1, "review_count": 1} + assert "Pin `2.0.0` blocks fix `2.0.2`" in text + + +def test_unconfirmed_package_finding_retains_the_initial_advisory_link(): + before, after, locks = package_evidence() + text = ENGINE["format_audit_summary"]( + before, after, "pnpm", locks=locks, fixed_ids=set() + ) + row = next(line for line in text.splitlines() if line.startswith("| `package-a`")) + assert "unconfirmed" in row + assert "https://example.invalid/advisories/package-a" in row + assert "Not supplied" not in row + + +def test_missing_local_node_declaration_notice_is_not_a_proposed_update(): + plan = node_plan() + plan["changes"] = [] + plan["message"] = "Node security update needs a local Node declaration." + platform = RELEASES["collect"](plan) + text = ENGINE["format_security_overview"]( + platform=platform, + package_report="", + package_fixes=0, + package_updates=0, + review_count=0, + passed=True, + ) + assert plan["message"] in text + assert text.startswith("**0 platform security updates") + assert "Upgrade validated" not in text + + +def test_production_renderer_generates_all_three_scenarios(tmp_path): + examples = render_examples(tmp_path) + assert examples["platform-only"].startswith( + "**1 platform security update · 0 package security fixes · Validation passed**" + ) + assert "0 platform security updates" in examples["packages-only"].splitlines()[0] + assert "| Security release |" not in examples["packages-only"] + assert "1 package security fix" in examples["platform-and-packages"].splitlines()[0] + assert ( + "1 package needs your review" + in examples["platform-and-packages"].splitlines()[0] + ) + assert all( + "## Platform security" in text and "## Package security" in text + for text in examples.values() + ) + assert ( + "`24.18.1`" in examples["platform-only"] + and "`24.22.1`" in examples["platform-only"] + ) + + +def test_failed_validation_is_not_rendered_as_a_successful_upgrade(): + platform = RELEASES["collect"]( + node_plan(), read_index=release_index, read_notes=release_notes + ) + text = ENGINE["format_security_overview"]( + platform=platform, + package_report="", + package_fixes=0, + package_updates=0, + review_count=0, + passed=False, + ) + assert "Validation failed or unavailable" in text + assert "Upgrade validated" not in text + assert "Upgrade validation failed or unavailable" in text + + +def test_partially_missing_severity_does_not_understate_the_highest_severity(): + data = release_notes("24.22.1").replace(" – High", "", 1) + report = RELEASES["parse_notes"]("24.22.1", data) + assert report["severity"] is None + assert len(report["cves"]) == 8 + + +def test_node22_parenthesized_release_severities_are_available(): + # Node22.13.1 uses this format, including a bundled dependency advisory. + notes = ( + "## 2025-01-21, Version 22.13.1 'Jod' (LTS)\n\n### Notable Changes\n" + "- CVE-2025-23083: security fix (High)\n" + "- CVE-2025-23085: security fix (Medium)\n" + "- CVE-2025-23084: security fix (Medium)\n\nDependency update:\n" + "- CVE-2025-22150: dependency fix (Medium)\n### Commits\n" + ) + report = RELEASES["parse_notes"]("22.13.1", notes) + assert report["severity"] == "high" + assert len(report["cves"]) == 4 + + +@pytest.mark.parametrize( + "updates,fixes,routine,expected", + [ + (3, 2, False, "Node: 3 dependency updates, 2 security fixes"), + (1, 1, False, "Node: 1 dependency update, 1 security fix"), + (2, 0, True, "Node: 2 routine dependency updates, no security fixes"), + (0, None, False, "Node: 0 dependency updates, security fixes unconfirmed"), + ], +) +def test_publisher_titles_read_actual_renderer_summaries( + tmp_path, updates, fixes, routine, expected +): + from workflow_source import load_workflow + + body = ENGINE["format_security_overview"]( + platform=None, + package_report="", + package_fixes=fixes, + package_updates=updates, + review_count=1, + passed=True, + routine_only=routine, + ) + path = tmp_path / "report.md" + path.write_text(body) + script = load_workflow()["jobs"]["pull-request"]["steps"][-1]["run"] + functions = script.split("git config user.name", 1)[0] + result = subprocess.run( + ["bash", "-c", functions + '\nproposal_title "$REPORT"'], + env={**os.environ, "REPORT": str(path), "NODE_UPDATE_VERSION": ""}, + text=True, + capture_output=True, + check=True, + ) + assert result.stdout.strip() == expected diff --git a/node/tests/verify_node_project.py b/node/tests/verify_node_project.py index 2acfe48..2b64e85 100644 --- a/node/tests/verify_node_project.py +++ b/node/tests/verify_node_project.py @@ -180,6 +180,9 @@ def check(node, manager, version): assert (project / ".nvmrc").read_text().strip() == selected["node"] preflight["verify_runtime"](directory, selected) audit = runpy.run_path(str(ROOT / "runtime/templates/dependency_audit.py")) + platform = audit["NODE_RELEASES"]["collect"](node_plan) + assert all(release["cves"] for release in platform["releases"]), platform + assert selected["node"] in audit["format_platform_security"](platform, True) before = {p.name: p.read_bytes() for p in project.glob("*") if p.is_file()} validation = {} report = audit["prepare"](