Bug 2065363 - Integrate pnpm audit into mozlint r=linter-reviewers,ahal
Differential Revision: https://phabricator.services.mozilla.com/D321400
This commit is contained in:
committed by
ahochheiden@mozilla.com
parent
bbd47a1061
commit
97313bb6ae
@@ -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>`
|
||||
@@ -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:
|
||||
|
||||
@@ -225,6 +225,8 @@ mozlint:
|
||||
- win64-node
|
||||
- win64-rust
|
||||
- win64-mozcheck
|
||||
fetch:
|
||||
- pnpm
|
||||
clang-tidy:
|
||||
- artifact: clang-tidy.tar.zst
|
||||
dest: clang-tools
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
@@ -64,6 +64,8 @@ skip-if = ["os == 'win'"]
|
||||
|
||||
["test_node_package_names.py"]
|
||||
|
||||
["test_pnpm_audit.py"]
|
||||
|
||||
["test_python_sites.py"]
|
||||
|
||||
["test_ruff.py"]
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user