Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions node/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions node/publish/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 0 additions & 26 deletions node/runtime/node_proposal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
138 changes: 138 additions & 0 deletions node/runtime/node_releases.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 18 additions & 11 deletions node/runtime/node_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading