diff --git a/.yamllint b/.yamllint index feeea94d064b..9cc4142d5b74 100644 --- a/.yamllint +++ b/.yamllint @@ -9,3 +9,4 @@ rules: ignore: | *node_modules* *bad.yml + *pnpm-lock.yaml diff --git a/docs/code-quality/index.md b/docs/code-quality/index.md index 268edf517907..69314cd8b291 100644 --- a/docs/code-quality/index.md +++ b/docs/code-quality/index.md @@ -98,6 +98,11 @@ typescript/index - `bug 1558517 `__ - :ref:`JavaScript Coding style` - https://prettier.io/ + * - pnpm-audit + - + - `bug 2065363 `__ + - :ref:`pnpm-audit` + - https://pnpm.io/cli/audit ``` ```{eval-rst} diff --git a/docs/code-quality/lint/linters/pnpm-audit.md b/docs/code-quality/lint/linters/pnpm-audit.md new file mode 100644 index 000000000000..3b96e02b180c --- /dev/null +++ b/docs/code-quality/lint/linters/pnpm-audit.md @@ -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 ` 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 ` 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 ` 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) ` +- {searchfox}`Source ` diff --git a/taskcluster/kinds/source-test/mozlint.yml b/taskcluster/kinds/source-test/mozlint.yml index c604ff52acb8..3c9ac432fb11 100644 --- a/taskcluster/kinds/source-test/mozlint.yml +++ b/taskcluster/kinds/source-test/mozlint.yml @@ -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: diff --git a/taskcluster/kinds/source-test/python.yml b/taskcluster/kinds/source-test/python.yml index 54a01a352308..0be7e899ffc5 100644 --- a/taskcluster/kinds/source-test/python.yml +++ b/taskcluster/kinds/source-test/python.yml @@ -225,6 +225,8 @@ mozlint: - win64-node - win64-rust - win64-mozcheck + fetch: + - pnpm clang-tidy: - artifact: clang-tidy.tar.zst dest: clang-tools diff --git a/tools/lint/pnpm-audit.yml b/tools/lint/pnpm-audit.yml new file mode 100644 index 000000000000..d09d6b0a8c66 --- /dev/null +++ b/tools/lint/pnpm-audit.yml @@ -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 diff --git a/tools/lint/pnpm-audit/__init__.py b/tools/lint/pnpm-audit/__init__.py new file mode 100644 index 000000000000..592fa58aa786 --- /dev/null +++ b/tools/lint/pnpm-audit/__init__.py @@ -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 diff --git a/tools/lint/test/files/pnpm-audit/clean/package.json b/tools/lint/test/files/pnpm-audit/clean/package.json new file mode 100644 index 000000000000..fd72e143c9cf --- /dev/null +++ b/tools/lint/test/files/pnpm-audit/clean/package.json @@ -0,0 +1,7 @@ +{ + "name": "pnpm-audit-clean", + "private": true, + "dependencies": { + "isarray": "2.0.5" + } +} diff --git a/tools/lint/test/files/pnpm-audit/clean/pnpm-lock.yaml b/tools/lint/test/files/pnpm-audit/clean/pnpm-lock.yaml new file mode 100644 index 000000000000..de35f1da1b61 --- /dev/null +++ b/tools/lint/test/files/pnpm-audit/clean/pnpm-lock.yaml @@ -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: {} diff --git a/tools/lint/test/files/pnpm-audit/vulnerable/package.json b/tools/lint/test/files/pnpm-audit/vulnerable/package.json new file mode 100644 index 000000000000..603d794f657c --- /dev/null +++ b/tools/lint/test/files/pnpm-audit/vulnerable/package.json @@ -0,0 +1,7 @@ +{ + "name": "pnpm-audit-vulnerable", + "private": true, + "dependencies": { + "fast-uri": "3.1.4" + } +} diff --git a/tools/lint/test/files/pnpm-audit/vulnerable/pnpm-lock.yaml b/tools/lint/test/files/pnpm-audit/vulnerable/pnpm-lock.yaml new file mode 100644 index 000000000000..cd90c2d0eb2d --- /dev/null +++ b/tools/lint/test/files/pnpm-audit/vulnerable/pnpm-lock.yaml @@ -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: {} diff --git a/tools/lint/test/python.toml b/tools/lint/test/python.toml index e3e8fd174b51..29f7190955b9 100644 --- a/tools/lint/test/python.toml +++ b/tools/lint/test/python.toml @@ -64,6 +64,8 @@ skip-if = ["os == 'win'"] ["test_node_package_names.py"] +["test_pnpm_audit.py"] + ["test_python_sites.py"] ["test_ruff.py"] diff --git a/tools/lint/test/test_pnpm_audit.py b/tools/lint/test/test_pnpm_audit.py new file mode 100644 index 000000000000..b9ea1fdd135a --- /dev/null +++ b/tools/lint/test/test_pnpm_audit.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()