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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
37 changes: 24 additions & 13 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions tests/fuzz-corpus/lockfiles/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions tests/fuzz-corpus/lockfiles/package-lock-v1.json
Original file line number Diff line number Diff line change
@@ -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"}}}
4 changes: 4 additions & 0 deletions tests/fuzz-corpus/lockfiles/package-lock-v3.json
Original file line number Diff line number Diff line change
@@ -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"}}}
11 changes: 11 additions & 0 deletions tests/fuzz-corpus/lockfiles/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions tests/fuzz-corpus/lockfiles/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions tests/fuzz-corpus/lockfiles/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions tests/fuzz-corpus/lockfiles/yarn-berry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__metadata:
version: 8

"react@npm:^18.2.0":
version: 18.2.0
resolution: "react@npm:18.2.0"
8 changes: 8 additions & 0 deletions tests/fuzz-corpus/lockfiles/yarn-v1.lock
Original file line number Diff line number Diff line change
@@ -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"
35 changes: 35 additions & 0 deletions tests/fuzz_lockfiles.py
Original file line number Diff line number Diff line change
@@ -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()
35 changes: 35 additions & 0 deletions tests/fuzz_projects_root.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 6 additions & 0 deletions tests/requirements-fuzz.txt
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading