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"](