Bug 2065363 - Integrate pnpm audit into mozlint r=linter-reviewers,ahal

Differential Revision: https://phabricator.services.mozilla.com/D321400
This commit is contained in:
Alex Hochheiden
2026-09-10 03:12:35 +00:00
committed by ahochheiden@mozilla.com
parent bbd47a1061
commit 97313bb6ae
13 changed files with 525 additions and 0 deletions
+1
View File
@@ -9,3 +9,4 @@ rules:
ignore: |
*node_modules*
*bad.yml
*pnpm-lock.yaml
+5
View File
@@ -98,6 +98,11 @@ typescript/index
- `bug 1558517 <https://bugzilla.mozilla.org/show_bug.cgi?id=1558517>`__
- :ref:`JavaScript Coding style`
- https://prettier.io/
* - pnpm-audit
-
- `bug 2065363 <https://bugzilla.mozilla.org/show_bug.cgi?id=2065363>`__
- :ref:`pnpm-audit`
- https://pnpm.io/cli/audit
```
```{eval-rst}
@@ -0,0 +1,57 @@
# pnpm-audit
[pnpm audit](https://pnpm.io/cli/audit) checks the vendored node packages in
`third_party/node` against the npm advisory database.
The linter reads `third_party/node/pnpm-lock.yaml` and does not need
`third_party/node/node_modules` to be present. Advisories rated critical or high
are reported as errors, everything else as a warning.
## Run Locally
This mozlint linter can be run using mach:
```{eval-rst}
.. parsed-literal::
$ mach lint --linter pnpm-audit
```
Pass `-v` to include every dependency path an advisory reaches, rather than the
first three.
## Raised Advisories
An advisory means a package in the vendored set has a published vulnerability.
For a direct dependency, bump the pin in
{searchfox}`third_party/node/package.json <third_party/node/package.json>` and
in the consumer manifest that has to agree with it, then run `mach vendor node`
to regenerate the lock file and the tree. For a transitive dependency, run
`mach vendor node --force`, which discards the lock file so a patched version
can be resolved.
{searchfox}`pnpm-workspace.yaml <third_party/node/pnpm-workspace.yaml>` sets a
`minimumReleaseAge`, so a patched version published inside that window is
rejected with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. To take a security fix
before it ages out, add the package to `minimumReleaseAgeExclude`:
```yaml
minimumReleaseAgeExclude:
- fast-uri@3.1.5
```
Name the exact version. A bare package name exempts every future release of it
as well. Treat the entry as temporary and remove it once the version is old
enough.
If an advisory does not apply, for example because it covers a code path the
build never reaches, add the text it is reported under to the `exclude-error`
list in {searchfox}`pnpm-audit.yml <tools/lint/pnpm-audit.yml>` along with a bug
number. Matching is on a substring, so prefer the GHSA identifier over the
package summary line, since a package name would also suppress unrelated
advisories filed against that package later.
## Sources
- {searchfox}`Configuration (YAML) <tools/lint/pnpm-audit.yml>`
- {searchfox}`Source <tools/lint/pnpm-audit/__init__.py>`
+17
View File
@@ -665,6 +665,23 @@ cargo-audit:
toolchain:
- linux64-rust
pnpm-audit:
description: Audit the vendored node packages for vulnerable dependencies
treeherder:
symbol: node(pnpm-audit)
run:
mach: lint -v --warnings=soft -l pnpm-audit -f treeherder -f json:/builds/worker/mozlint.json .
when:
files-changed:
- 'third_party/node/pnpm-lock.yaml'
- 'tools/lint/pnpm-audit.yml'
- 'tools/lint/pnpm-audit/**'
fetches:
toolchain:
- linux64-node
fetch:
- pnpm
mozcheck-tests:
description: mozcheck Rust unit tests
treeherder:
+2
View File
@@ -225,6 +225,8 @@ mozlint:
- win64-node
- win64-rust
- win64-mozcheck
fetch:
- pnpm
clang-tidy:
- artifact: clang-tidy.tar.zst
dest: clang-tools
+15
View File
@@ -0,0 +1,15 @@
---
pnpm-audit:
description: >
Audits the vendored node packages for vulnerabilities reported in the
npm advisory database.
type: external
payload: pnpm-audit:lint
include:
- third_party/node/pnpm-lock.yaml
exclude-error: []
extensions:
- yaml
support-files:
- 'tools/lint/pnpm-audit/**'
setup: pnpm-audit:setup
+190
View File
@@ -0,0 +1,190 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import json
import os
import subprocess
from mozbuild.nodeutil import find_node_executable
from mozlint import result
from mozlint.pathutils import expand_exclusions
ERROR_SEVERITIES = ("critical", "high")
SHOWN_PATHS = 3
NO_NODE_MESSAGE = """
Could not find a node executable. Run `mach bootstrap` and try again.
""".strip()
NO_PNPM_MESSAGE = """
Could not find or bootstrap pnpm. Check the output above and try again.
""".strip()
REGISTRY_MESSAGE = """
This usually means pnpm could not reach the npm registry to download the
advisory database, which evolves independently of this repository. Please file
a new bug blocking bug 2065363 instead of backing out a push.
""".strip()
def to_str_paths(finding, verbose):
paths = finding.get("paths") or []
if not paths:
return ""
shown = paths if verbose else paths[:SHOWN_PATHS]
lines = [f"\n {path}" for path in shown]
remaining = len(paths) - len(shown)
if remaining:
lines.append(f"\n and {remaining} more, re-run with -v to see them all")
return "\nDependency paths:" + "".join(lines)
def build_message(advisory, verbose):
module = advisory["module_name"]
message = f"Depends on a vulnerable version of {module}."
message += f"\n\nAdvisory:\n{advisory['title']}"
message += f"\nPackage: {module}"
identifier = advisory.get("github_advisory_id") or advisory.get("id")
if identifier:
message += f"\nID: {identifier}"
message += f"\nSeverity: {advisory['severity']}"
cwe = advisory.get("cwe")
if cwe:
message += f"\nCWE: {cwe}"
url = advisory.get("url")
if url:
message += f"\nURL: {url}"
findings = advisory.get("findings") or []
installed = sorted({
finding["version"] for finding in findings if "version" in finding
})
if installed:
message += f"\n\nInstalled versions: {', '.join(installed)}"
for key, label in (
("vulnerable_versions", "Vulnerable versions"),
("patched_versions", "Patched versions"),
):
if advisory.get(key):
message += f"\n{label}: {advisory[key]}"
for finding in findings:
message += to_str_paths(finding, verbose)
return message
def severity_level(severity):
return "error" if severity in ERROR_SEVERITIES else "warning"
def build_issue(config, path, message, level):
return result.from_config(
config,
**{
"path": path,
"message": message,
"lineno": -1,
"column": -1,
"level": level,
},
)
def is_excluded(message, exclusions):
return any(exclusion in message for exclusion in exclusions)
def locate_pnpm():
from mozbuild.bootstrap import bootstrap_toolchain
return bootstrap_toolchain("pnpm/bin/pnpm.cjs")
def audit_failed(args, completed, reason):
message = [
f"pnpm audit {reason} (exit code {completed.returncode}) while running:",
" " + " ".join(args),
]
for name, stream in (("stdout", completed.stdout), ("stderr", completed.stderr)):
if stream.strip():
message.append(f"\n{name}:")
message.append(stream.rstrip())
message.append(f"\n{REGISTRY_MESSAGE}")
return RuntimeError("\n".join(message))
def run_audit(node, pnpm, directory):
args = [node, pnpm, "audit", "--json", "--dir", directory]
completed = subprocess.run(
args,
capture_output=True,
text=True,
check=False,
)
try:
report = json.loads(completed.stdout)
except json.JSONDecodeError:
raise audit_failed(args, completed, "did not return JSON")
if not isinstance(report, dict):
raise audit_failed(args, completed, "did not return a report")
error = report.get("error")
if error:
detail = error.get("message", error) if isinstance(error, dict) else error
raise audit_failed(args, completed, f"reported an error, {detail}")
if not isinstance(report.get("advisories"), dict):
raise audit_failed(args, completed, "returned no advisories section")
return report
def lint(paths, config, log, **lintargs):
node, _ = find_node_executable()
if not node:
raise RuntimeError(NO_NODE_MESSAGE)
pnpm = locate_pnpm()
if not pnpm:
raise RuntimeError(NO_PNPM_MESSAGE)
verbose = lintargs.get("show_verbose", False)
exclusions = config.get("exclude-error", [])
results = []
for path in expand_exclusions(paths, config, lintargs["root"]):
report = run_audit(node, pnpm, os.path.dirname(path))
for advisory in report["advisories"].values():
message = build_message(advisory, verbose)
if is_excluded(message, exclusions):
continue
level = severity_level(advisory["severity"])
results.append(build_issue(config, path, message, level))
return results
def setup(root, log, **lintargs):
node, _ = find_node_executable()
if not node:
log.error(NO_NODE_MESSAGE)
return 1
if not locate_pnpm():
log.error(NO_PNPM_MESSAGE)
return 1
return 0
@@ -0,0 +1,7 @@
{
"name": "pnpm-audit-clean",
"private": true,
"dependencies": {
"isarray": "2.0.5"
}
}
+22
View File
@@ -0,0 +1,22 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
isarray:
specifier: 2.0.5
version: 2.0.5
packages:
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
snapshots:
isarray@2.0.5: {}
@@ -0,0 +1,7 @@
{
"name": "pnpm-audit-vulnerable",
"private": true,
"dependencies": {
"fast-uri": "3.1.4"
}
}
@@ -0,0 +1,22 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
fast-uri:
specifier: 3.1.4
version: 3.1.4
packages:
fast-uri@3.1.4:
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
snapshots:
fast-uri@3.1.4: {}
+2
View File
@@ -64,6 +64,8 @@ skip-if = ["os == 'win'"]
["test_node_package_names.py"]
["test_pnpm_audit.py"]
["test_python_sites.py"]
["test_ruff.py"]
+178
View File
@@ -0,0 +1,178 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import importlib
import json
import os
import mozunit
import pytest
LINTER = "pnpm-audit"
KNOWN_ADVISORY = "GHSA-7p8r-x3mc-p8w7"
FILE_A_BUG = (
"\n\nThis test runs against the live npm advisory database, which changes "
"over time. This failure is most likely caused by the database changing "
"rather than a regression in the pnpm-audit linter. Please file a new bug "
"blocking bug 2065363 instead of backing out a push."
)
@pytest.fixture(scope="module")
def pnpm_audit():
return importlib.import_module("pnpm-audit")
@pytest.fixture
def needs_pnpm(pnpm_audit):
if not pnpm_audit.locate_pnpm():
pytest.skip("pnpm is not available, run `mach lint -l pnpm-audit --setup`")
def test_lint_pnpm_audit_reports_a_known_advisory(lint, paths, needs_pnpm):
test_file = os.path.join("vulnerable", "pnpm-lock.yaml")
results = lint(paths(test_file))
matched = [r for r in results if KNOWN_ADVISORY in r.message]
assert matched, (
f"{KNOWN_ADVISORY} was not reported for {test_file}, got "
f"{[r.message.splitlines()[0] for r in results]}{FILE_A_BUG}"
)
assert matched[0].level == "error"
assert "Depends on a vulnerable version of fast-uri." in matched[0].message
assert "Patched versions: >=3.1.5" in matched[0].message
for result in results:
assert "vulnerable/pnpm-lock.yaml" in result.relpath
def test_lint_pnpm_audit_clean(lint, paths, needs_pnpm):
test_file = os.path.join("clean", "pnpm-lock.yaml")
results = lint(paths(test_file))
assert not results, (
f"Expected no advisories for {test_file}, but got "
f"{len(results)}: {[r.message.splitlines()[0] for r in results]}"
f"{FILE_A_BUG}"
)
def test_severity_level_maps_high_and_critical_to_errors(pnpm_audit):
assert pnpm_audit.severity_level("critical") == "error"
assert pnpm_audit.severity_level("high") == "error"
assert pnpm_audit.severity_level("moderate") == "warning"
assert pnpm_audit.severity_level("low") == "warning"
assert pnpm_audit.severity_level("info") == "warning"
def test_build_message_lists_the_dependency_paths(pnpm_audit):
advisory = {
"module_name": "fast-uri",
"title": "fast-uri vulnerable to host confusion",
"severity": "high",
"github_advisory_id": KNOWN_ADVISORY,
"vulnerable_versions": ">=3.0.0 <3.1.5",
"patched_versions": ">=3.1.5",
"findings": [
{
"version": "3.1.4",
"paths": [f".>webpack>ajv{index}>fast-uri" for index in range(5)],
}
],
}
message = pnpm_audit.build_message(advisory, verbose=False)
assert "Installed versions: 3.1.4" in message
assert ".>webpack>ajv0>fast-uri" in message
assert ".>webpack>ajv4>fast-uri" not in message
assert "and 2 more, re-run with -v to see them all" in message
verbose = pnpm_audit.build_message(advisory, verbose=True)
assert ".>webpack>ajv4>fast-uri" in verbose
assert "and 2 more" not in verbose
def test_is_excluded_matches_on_a_substring(pnpm_audit):
message = pnpm_audit.build_message(
{
"module_name": "fast-uri",
"title": "fast-uri vulnerable to host confusion",
"severity": "high",
},
verbose=False,
)
assert pnpm_audit.is_excluded(message, ["vulnerable version of fast-uri."])
assert not pnpm_audit.is_excluded(message, ["vulnerable version of webpack."])
class FakeCompleted:
def __init__(self, stdout, stderr="", returncode=1):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
def run_with_output(pnpm_audit, monkeypatch, stdout, returncode=1):
monkeypatch.setattr(
pnpm_audit.subprocess,
"run",
lambda *args, **kwargs: FakeCompleted(stdout, "", returncode),
)
return pnpm_audit.run_audit("node", "pnpm.cjs", "somewhere")
def test_run_audit_raises_when_the_registry_is_unreachable(pnpm_audit, monkeypatch):
stdout = json.dumps({"error": {"code": "pnpm", "message": "fetch failed"}})
with pytest.raises(RuntimeError) as raised:
run_with_output(pnpm_audit, monkeypatch, stdout)
assert "fetch failed" in str(raised.value)
def test_run_audit_raises_when_the_output_is_not_a_report(pnpm_audit, monkeypatch):
with pytest.raises(RuntimeError) as raised:
run_with_output(pnpm_audit, monkeypatch, json.dumps([]))
assert "did not return a report" in str(raised.value)
def test_run_audit_raises_on_malformed_output(pnpm_audit, monkeypatch):
with pytest.raises(RuntimeError) as raised:
run_with_output(pnpm_audit, monkeypatch, "not json at all")
assert "did not return JSON" in str(raised.value)
def test_run_audit_raises_without_an_advisories_section(pnpm_audit, monkeypatch):
with pytest.raises(RuntimeError) as raised:
run_with_output(pnpm_audit, monkeypatch, json.dumps({"metadata": {}}))
assert "no advisories section" in str(raised.value)
def test_run_audit_accepts_a_clean_report(pnpm_audit, monkeypatch):
stdout = json.dumps({"advisories": {}, "metadata": {}})
report = run_with_output(pnpm_audit, monkeypatch, stdout, returncode=0)
assert report["advisories"] == {}
def test_run_audit_keeps_advisories_reported_on_a_nonzero_exit(pnpm_audit, monkeypatch):
advisories = {"1130720": {"module_name": "fast-uri", "severity": "high"}}
stdout = json.dumps({"advisories": advisories, "metadata": {}})
report = run_with_output(pnpm_audit, monkeypatch, stdout)
assert report["advisories"]["1130720"]["module_name"] == "fast-uri"
if __name__ == "__main__":
mozunit.main()