diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4ec3ec..11de440 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,3 +92,47 @@ jobs: # the system Python, which is what the LaunchAgent runs the service with - name: Unit and end-to-end tests run: /usr/bin/python3 -m unittest discover -s tests -v + + fuzz: + name: Fuzz + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden runner + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + pypi.org:443 + files.pythonhosted.org:443 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Atheris only ships Linux x86-64 wheels, for CPython 3.12-3.14 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Install Atheris (hash-checked) + run: 'python -m pip install --require-hashes --only-binary :all: -r tests/requirements-fuzz.txt' + + # coverage-guided; -timeout makes any input slower than 10 s (a regex blow-up) a failure + - name: Fuzz the lockfile parsers + run: | + mkdir -p "$RUNNER_TEMP/corpus" crashes + cp tests/fuzz-corpus/lockfiles/* "$RUNNER_TEMP/corpus/" + python tests/fuzz_lockfiles.py "$RUNNER_TEMP/corpus" -max_total_time=90 -timeout=10 -rss_limit_mb=2048 -artifact_prefix=crashes/ + + - name: Fuzz the projects-folder check + run: python tests/fuzz_projects_root.py -max_total_time=45 -timeout=10 -artifact_prefix=crashes/ + + - name: Keep crashing inputs + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: fuzz-crashes + path: crashes/ + retention-days: 14 diff --git a/README.md b/README.md index e57dfc3..1ea3775 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,11 @@ they never touch an installed pkgdash, and setup is never finished in them, so n updated. They cover the Host/Origin/token checks, settings validation, turned-off managers, the Socket token check, the settings migration and the demo. +The lockfile parsers read files from other people's projects, so CI also **fuzzes** them with +[Atheris](https://github.com/google/atheris) (coverage-guided; `tests/fuzz_lockfiles.py`, seeded from +`tests/fuzz-corpus/`), along with the projects-folder check (`tests/fuzz_projects_root.py`). Atheris +only runs on Linux x86-64; inputs it has found are kept as regression tests in `test_server.py`. + ## License [MIT](LICENSE) diff --git a/ruff.toml b/ruff.toml index 2cbd965..34330e9 100644 --- a/ruff.toml +++ b/ruff.toml @@ -12,6 +12,6 @@ ignore = [ [lint.per-file-ignores] # tests only open http://127.0.0.1 URLs they build themselves -"tests/*" = ["S310"] +"tests/*" = ["S310", "S101"] # S101: asserts are how tests check results (never run with -O) # the made-up data tables read better one record per line "demo.py" = ["E501"] diff --git a/server.py b/server.py index 392404c..6ddd5f4 100644 --- a/server.py +++ b/server.py @@ -163,7 +163,7 @@ def iter_json(text): def loads(text, default): try: return json.loads(text) - except (ValueError, TypeError): + except (ValueError, TypeError, RecursionError): # RecursionError: absurdly nested JSON return default @@ -1352,18 +1352,24 @@ def lock_deps(fname, text): """[(name, version)] pinned by a lockfile, dependencies included (registry packages only).""" res = set() if fname == "package-lock.json": + # lockfiles come from any project, so check every type rather than trust the shape data = loads(text, {}) - for key, info in (data.get("packages") or {}).items(): # lockfile v2/v3 - if "node_modules/" in key and not info.get("link") and info.get("version"): - res.add((info.get("name") or key.rsplit("node_modules/", 1)[1], info["version"])) - - def walk(deps): # lockfile v1 - for n, info in (deps or {}).items(): - if info.get("version"): - res.add((n, info["version"])) - walk(info.get("dependencies")) - if not data.get("packages"): - walk(data.get("dependencies")) + data = data if isinstance(data, dict) else {} + packages = data.get("packages") + for key, info in (packages.items() if isinstance(packages, dict) else ()): # lockfile v2/v3 + if (isinstance(info, dict) and "node_modules/" in key and not info.get("link") + and isinstance(info.get("version"), str) and info["version"]): + name = info.get("name") + res.add((name if isinstance(name, str) and name else key.rsplit("node_modules/", 1)[1], info["version"])) + if not packages: # lockfile v1: nested "dependencies", walked without recursion + todo = [data.get("dependencies")] + while todo: + deps = todo.pop() + for n, info in (deps.items() if isinstance(deps, dict) else ()): + if isinstance(info, dict): + if isinstance(info.get("version"), str) and info["version"]: + res.add((n, info["version"])) + todo.append(info.get("dependencies")) elif fname == "yarn.lock": # yarn 1 (`version "1.2.3"`) and yarn 2+ (`version: 1.2.3`) name = None for line in text.splitlines(): @@ -1558,7 +1564,12 @@ def projects(self): except OSError: continue rel = os.path.relpath(os.path.dirname(path), root) - out[(kind, rel)] = (eco, lock_deps(os.path.basename(path), text), path, f["mtime"]) + try: + deps = lock_deps(os.path.basename(path), text) + except Exception as e: # one unreadable lockfile mustn't stop the other projects' checks + print("projects: skipping %s: %s: %s" % (path, type(e).__name__, e), flush=True) + continue + out[(kind, rel)] = (eco, deps, path, f["mtime"]) return out def scan(self, job): diff --git a/tests/fuzz-corpus/lockfiles/Cargo.lock b/tests/fuzz-corpus/lockfiles/Cargo.lock new file mode 100644 index 0000000..98ce82e --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/Cargo.lock @@ -0,0 +1,10 @@ +version = 3 + +[[package]] +name = "serde" +version = "1.0.190" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "myapp" +version = "0.1.0" diff --git a/tests/fuzz-corpus/lockfiles/package-lock-v1.json b/tests/fuzz-corpus/lockfiles/package-lock-v1.json new file mode 100644 index 0000000..21e050b --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/package-lock-v1.json @@ -0,0 +1,2 @@ +{"name": "app", "lockfileVersion": 1, "dependencies": {"express": {"version": "4.18.2", + "dependencies": {"debug": {"version": "2.6.9", "dependencies": {"ms": {"version": "2.0.0"}}}}}, "lodash": {"version": "4.17.21"}}} diff --git a/tests/fuzz-corpus/lockfiles/package-lock-v3.json b/tests/fuzz-corpus/lockfiles/package-lock-v3.json new file mode 100644 index 0000000..685a10f --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/package-lock-v3.json @@ -0,0 +1,4 @@ +{"name": "app", "lockfileVersion": 3, "packages": {"": {"name": "app", "version": "1.0.0"}, + "node_modules/left-pad": {"version": "1.3.0", "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}, + "node_modules/@scope/pkg": {"version": "2.1.0"}, "node_modules/a/node_modules/b": {"version": "0.1.0"}, + "node_modules/linked": {"link": true, "resolved": "../linked"}}} diff --git a/tests/fuzz-corpus/lockfiles/poetry.lock b/tests/fuzz-corpus/lockfiles/poetry.lock new file mode 100644 index 0000000..dfa17ae --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/poetry.lock @@ -0,0 +1,11 @@ +[[package]] +name = "click" +version = "8.1.7" + +[[package]] +name = "localdep" +version = "0.1.0" + +[package.source] +type = "directory" +url = "../localdep" diff --git a/tests/fuzz-corpus/lockfiles/requirements.txt b/tests/fuzz-corpus/lockfiles/requirements.txt new file mode 100644 index 0000000..9778989 --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/requirements.txt @@ -0,0 +1,4 @@ +requests==2.31.0 +urllib3[socks]===2.0.7 ; python_version >= "3.8" +flask>=2.0 # not a pin +-e git+https://example.invalid/x.git#egg=x diff --git a/tests/fuzz-corpus/lockfiles/uv.lock b/tests/fuzz-corpus/lockfiles/uv.lock new file mode 100644 index 0000000..2ba1e34 --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/uv.lock @@ -0,0 +1,11 @@ +version = 1 + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } + +[[package]] +name = "myproj" +version = "0.1.0" +source = { editable = "." } diff --git a/tests/fuzz-corpus/lockfiles/yarn-berry.lock b/tests/fuzz-corpus/lockfiles/yarn-berry.lock new file mode 100644 index 0000000..7ff01b2 --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/yarn-berry.lock @@ -0,0 +1,6 @@ +__metadata: + version: 8 + +"react@npm:^18.2.0": + version: 18.2.0 + resolution: "react@npm:18.2.0" diff --git a/tests/fuzz-corpus/lockfiles/yarn-v1.lock b/tests/fuzz-corpus/lockfiles/yarn-v1.lock new file mode 100644 index 0000000..4edf85b --- /dev/null +++ b/tests/fuzz-corpus/lockfiles/yarn-v1.lock @@ -0,0 +1,8 @@ +# yarn lockfile v1 + +"@babel/core@^7.0.0", "@babel/core@^7.1.0": + version "7.23.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.2.tgz" + +lodash@^4.17.21: + version "4.17.21" diff --git a/tests/fuzz_lockfiles.py b/tests/fuzz_lockfiles.py new file mode 100644 index 0000000..38f8638 --- /dev/null +++ b/tests/fuzz_lockfiles.py @@ -0,0 +1,35 @@ +"""Coverage-guided fuzzing of the lockfile parsers with Atheris (Linux x86-64; CI runs it). + +Lockfiles come from any project in the projects folder, other people's code included, so whatever +they contain, every parser must return clean (name, version) pairs and never raise: one bad file +would otherwise stop the malware and CVE checks for every project. + + pip install --require-hashes -r tests/requirements-fuzz.txt + python tests/fuzz_lockfiles.py tests/fuzz-corpus/lockfiles -max_total_time=60 +""" +import os +import sys +import tempfile + +os.environ["HOME"] = tempfile.mkdtemp(prefix="pkgdash-fuzz-") # server.py keeps its state under HOME +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +import atheris # noqa: E402 + +with atheris.instrument_imports(): + import server # noqa: E402 + +NAMES = sorted(server.LOCKFILES) + + +def TestOneInput(data): + text = data.decode("utf-8", "replace") + for fname in NAMES: # every input goes through every parser + for name, version in server.lock_deps(fname, text): + assert isinstance(name, str) and name, (fname, name) + assert isinstance(version, str) and version and ":" not in version and "/" not in version, (fname, version) + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() diff --git a/tests/fuzz_projects_root.py b/tests/fuzz_projects_root.py new file mode 100644 index 0000000..3a68aff --- /dev/null +++ b/tests/fuzz_projects_root.py @@ -0,0 +1,35 @@ +"""Coverage-guided fuzzing, with Atheris, of the check that keeps the projects folder inside the home +folder: for any string, the answer is either "no" (None) or "~/…" inside home, not ~/Library. + + python tests/fuzz_projects_root.py -max_total_time=60 +""" +import os +import sys +import tempfile + +os.environ["HOME"] = tempfile.mkdtemp(prefix="pkgdash-fuzz-") +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +import atheris # noqa: E402 + +with atheris.instrument_imports(): + import server # noqa: E402 + +HOME = os.path.realpath(os.environ["HOME"]) + + +def TestOneInput(data): + value = atheris.FuzzedDataProvider(data).ConsumeUnicodeNoSurrogates(256) + root = server.clean_projects_root(value) + if root is None: + return + assert root.startswith("~/"), (value, root) + parts = root[2:].split("/") + assert parts[0] not in ("", ".", "..", "Library") and ".." not in parts, (value, root) + full = os.path.realpath(os.path.expanduser(root)) + assert full.startswith(HOME + "/"), (value, root, full) + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() diff --git a/tests/requirements-fuzz.txt b/tests/requirements-fuzz.txt new file mode 100644 index 0000000..d84ffb4 --- /dev/null +++ b/tests/requirements-fuzz.txt @@ -0,0 +1,6 @@ +# Fuzzing dependency (Linux x86-64 wheels only), pinned with hashes; Renovate keeps it current. +atheris==3.1.0 \ + --hash=sha256:315a0b5c819852b1ffe1ca72efc389c7724881f2c33e4aacb8c6bcec49bd5011 \ + --hash=sha256:ec5e11f21a4c197fe91f7aea2b2de88e623c73a21fc07b105ac6329a1588457b \ + --hash=sha256:f8a9f51ce8369026e8eb7b7174835e8c4c85a1a6db5d9add36c15100779d2a39 + # via fuzzing diff --git a/tests/test_server.py b/tests/test_server.py index d4f9de1..1789b36 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -54,6 +54,31 @@ def test_outside_home_or_library_is_refused(self): self.assertIsNone(server.clean_projects_root(bad), repr(bad)) +class LockfileParsing(unittest.TestCase): + """Lockfiles come from any project, so a hostile one must be skipped, never crash the scan.""" + + def test_hostile_package_locks_parse_to_nothing(self): + deep = '{"dependencies": ' + '{"x": {"version": "1", "dependencies": ' * 5000 + "{}" + "}}" * 5000 + "}" + for text in ("[]", '{"packages": {"node_modules/x": "1.0.0"}}', '{"packages": ["x"]}', + '{"dependencies": {"x": 5}}', '{"packages": {"node_modules/x": {"version": 3}}}', deep, "not json"): + self.assertEqual(server.lock_deps("package-lock.json", text), [], text[:40]) + + def test_real_lockfiles_still_parse(self): + corpus = os.path.join(HERE, "fuzz-corpus", "lockfiles") + def deps(seed, fname): + with open(os.path.join(corpus, seed)) as f: + return server.lock_deps(fname, f.read()) + self.assertEqual(deps("package-lock-v3.json", "package-lock.json"), + [("@scope/pkg", "2.1.0"), ("b", "0.1.0"), ("left-pad", "1.3.0")]) + self.assertEqual(deps("package-lock-v1.json", "package-lock.json"), + [("debug", "2.6.9"), ("express", "4.18.2"), ("lodash", "4.17.21"), ("ms", "2.0.0")]) + self.assertEqual(deps("yarn-v1.lock", "yarn.lock"), [("@babel/core", "7.23.2"), ("lodash", "4.17.21")]) + self.assertEqual(deps("requirements.txt", "requirements.txt"), [("requests", "2.31.0"), ("urllib3", "2.0.7")]) + self.assertEqual(deps("Cargo.lock", "Cargo.lock"), [("serde", "1.0.190")]) + self.assertEqual(deps("uv.lock", "uv.lock"), [("requests", "2.31.0")]) + self.assertEqual(deps("poetry.lock", "poetry.lock"), [("click", "8.1.7")]) + + class Migration(unittest.TestCase): def setUp(self): self.state = server.STATE_DIR / "state.json"