# Changelog of Malicious Package Watch — npm/PyPI Supply Chain Alerts (`0xgollum/malicious-package-watch`) Actor

- **URL**: https://apify.com/0xgollum/malicious-package-watch/changelog.md
- **Full Actor documentation**: https://apify.com/0xgollum/malicious-package-watch.md

## Changelog

### 0.2.6 — Third cold review: every locked copy is checked

**Fixed**

- **A vulnerable copy nested in a lockfile was invisible.** A real
  `package-lock.json` routinely holds two versions of the same package — the
  current one at the root, and an old one nested under a dependency that pins
  it (`node_modules/some-old-lib/node_modules/lodash` at 3.10.1). Deduplication
  ran on the package *name*, so only the root copy was checked and the tree
  came back clean. Every distinct locked version is now its own check; rows
  carry a `version` field saying which copy the finding is about. Verified
  live: the nested `lodash@3.10.1` now surfaces its 6 vulnerabilities.
  In `watch` mode, a locked package's state is keyed by version
  (`npm/lodash@3.10.1`); a name-only watchlist keeps its existing key and
  history.
- **A version range in the hand-listed `packages` input was sent to OSV
  verbatim** (`"version": "^4.17.0"`). It is now treated like a manifest range
  — checked by name — with a warning suggesting an exact version.
- **Under a tight `max_results`, the typosquat row was the first one cut.**
  Sorting was severity, then type alphabetically, which put `advisory` before
  `typosquat`. Within a severity a typosquat now sorts first: "you are about to
  install a booby-trapped package" is the most actionable row we ever emit.
- **A newly-listed twin of an already-reported advisory was re-fetched on every
  run.** It was correctly suppressed but never persisted. It is now recorded
  as seen the first time.

**Changed**

- Advisory detail lookups retry briefly on HTTP 429 and 5xx (honouring
  `Retry-After`) instead of degrading straight to a "(no summary available)"
  row during a big first audit.

**Known limitation (documented, not a bug)**

- `@evil/lodash` — a *scoped* npm package riding a popular unscoped name
  (dependency confusion) — is not a typosquat and is not flagged. `@evil/lodahs`
  is.

### 0.2.5 — Second cold review: one row per vulnerability, no more phantom alerts

A second independent review of 0.2.3 found four further defects, three of
them affecting what the client is billed for. All fixed, all covered by
regression tests, all re-verified against the live OSV.dev API.

**Fixed**

- **Duplicate rows for the same vulnerability.** OSV lists the same flaw once
  per source — a `PYSEC-…` record and its `GHSA-…` twin, aliased to each other
  — and each became its own billed row. On a real run that was 91 rows for 58
  vulnerabilities (+57 %), and the twin was almost always the empty one (no
  summary, severity UNKNOWN). Records are now grouped by alias and **one row
  represents the vulnerability**: the richest record supplies `vuln_id`,
  `summary` and `reference_url`; every other id lands in `aliases`; severity is
  the worst across the group, `malicious` is true if any record says so,
  `published` is the earliest. A new `source_records` field lists every OSV id
  the row stands for. In `watch` mode a newly-listed twin of an advisory you
  were already told about is no longer reported as new.
- **An OSV.dev outage wiped the watch-mode state.** A failed query was read as
  "no advisories", the state was overwritten with an empty list, and the next
  run reported every historical advisory as NEW — old CVEs dated 2020 arriving
  as fresh alerts, all billed. Packages OSV could not be asked about are now
  skipped for the run with their state left exactly as it was, and the log
  says so.
- **Manifest version ranges were sent to OSV as if installed.** `"lodash":
  "~4.17.0"` in `package.json` was queried as `lodash@4.17.0` — 10 advisories
  — when `4.17.21`, the version that range actually installs, has 3. Same for
  `composer.json` and `Cargo.toml` (where a bare `"1.36"` is a caret range).
  Only an explicit pin (`==1.2.3`, a bare `1.2.3` in package.json/composer.json,
  `=1.2.3` in Cargo.toml) or a lockfile entry is now treated as a version; a
  range is queried by name only, so nothing is reported that the installed
  version may already fix.
- **The lockfile lost to the manifest.** When both `package.json` and
  `package-lock.json` were pasted, the manifest's range floor won because it
  came first. A locked/exact version now always beats a manifest range,
  whatever the paste order.
- **Rows dropped by a partial push were marked as seen.** State is now derived
  from the rows `push_data` actually accepted.
- **17 % of rows had no summary although OSV had one.** PyPA records put the
  description in `details`, not `summary`; the first sentence is now used. In
  practice these were the PYSEC twins above, so they are merged away anyway.

**Changed**

- `checked_at` is now a full UTC timestamp (`2026-09-09T12:46:30Z`), not a
  date.
- CVSS v3 vectors are preferred over v4 regardless of the order OSV lists
  them (v3 is computed exactly; v4 is only banded).
- Manifest parsers report whether a version is exact (`exact`), which the
  dedup step uses for precedence.

### 0.2.3 — Precision & correctness pass on the 0.2 features

A review of the 0.2.0 code found four real defects. All are fixed and covered
by regression tests.

**Fixed**

- **Typosquat false positives.** 0.2.0 flagged anything within two edits of a
  popular package, which meant `task`, `mask`, `flash`, `block`, `clock`,
  `star`, `lang`, `goto` and `mess` were all reported as CRITICAL typosquats.
  Detection now requires the difference to be an actual typing-error class —
  a transposition, a missing/doubled/inserted character, an adjacent-key slip,
  or a look-alike glyph — against a package name long enough to be worth
  impersonating, and never fires on a name that is itself an ordinary word.
  A substitution of two unrelated characters (`flask`→`flash`) is a different
  word, not a typo, and is no longer reported. Real squats that 0.2.0 *missed*
  (`reqeusts`) are now caught.
- **Repeated alerts for the same finding in `watch` mode.** When a name sat one
  edit from two popular packages, which one was reported depended on set
  iteration order, which is not stable between runs. That name feeds the state
  key, so the same finding re-alerted on every run. Candidate selection is now
  deterministic.
- **Findings silently lost in `watch` mode.** State was recorded before the
  `max_results` cap was applied, so anything truncated was marked as reported
  and never came back — with the free preview capped at 25 rows, most of a real
  lockfile's findings. State is now written after the push and only for rows
  actually delivered; the rest stay pending and are reported next run, with a
  log line saying how many were held back.
- **`Pipfile.lock` returned nothing.** It was routed to the TOML `Pipfile`
  parser even though the lockfile is JSON. It now has its own parser.

**Changed**

- Typosquat rows explain the exact slip ("two characters swapped ('sh' -> 'hs')
  away from 'lodash'") instead of a bare edit distance.
- `checks` values that aren't recognized now produce a warning instead of
  silently running every check.
- Name matching is ~8× faster (the popular-package index is built once at
  import instead of once per dependency): 800 packages went from 10.0s to 1.3s.

### 0.2.0 — Dependency file scanning + proactive typosquat detection

Major upgrade. Everything from 0.1 still works unchanged; the hand-listed
`packages` input and the scheduled diff-based alerting are untouched.

**New**

- **`manifest_files` input** — paste a real dependency file straight from your
  repo instead of hand-listing every package. Supported: `requirements*.txt`,
  `Pipfile`, `poetry.lock`, `pyproject.toml`, `package.json`,
  `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `go.mod`, `Gemfile`,
  `Gemfile.lock`, `composer.json`, `composer.lock`, `Cargo.toml`, `Cargo.lock`.
  Parsed with the standard library only — no new dependencies pulled in.
- **Offline typosquat / name-confusion check** — flags dependency names that
  are a near-miss of a very popular package (`reqeusts` → `requests`,
  `python-dateutil` typed as `dateutil`, an added `-js` suffix, an `l`↔`1`
  swap, a doubled letter…). OSV can only warn about a malicious package *after*
  a takedown advisory exists; this catches the install-time trap before that.
  Runs with no API call.
- **`mode` input** — `watch` (default, unchanged: scheduled, silent first-run
  baseline, then only new findings) or `audit` (one-shot: report every current
  advisory + name-confusion hit now, no baseline suppression) for scanning a
  repo on demand.
- **Exact-version matching** — when a manifest pins a version, it's passed to
  OSV so a dependency only matches advisories that actually affect that
  version (far fewer false alarms than the old name-only query).

**Changed**

- Every row now carries a `type` field: `"advisory"` or `"typosquat"`.
- `severity` is normalized to `CRITICAL` / `HIGH` / `MEDIUM` / `LOW` /
  `UNKNOWN` from whatever OSV provides (a CVSS vector, a GHSA word, or
  nothing). Rows are returned severity-first.
- Advisory rows gained `aliases`, `malicious` (true for OSV `MAL-` malicious
  package records) and `reference_url`.
- `/v1/querybatch` is now chunked at 500 packages per request so a full
  lockfile with hundreds of entries works in a bounded number of calls.

**Not changed**

- Scheduled `watch` mode behaviour, the `packages` input, the state store,
  the free-tier preview cap (25 rows), the failure-alert email, empty-run =
  no charge.

### 0.1 — Initial release

Watchlist of packages → OSV.dev advisory check on a schedule → report only
advisories new since the last run.
