Skip to content

feat(versions): compare each ecosystem with its own version scheme (PEP 440 for PyPI) - #1397

Open
alex-powell wants to merge 1 commit into
OWASP:mainfrom
alex-powell:feat/pep440-version-scheme
Open

alex-powell wants to merge 1 commit into
OWASP:mainfrom
alex-powell:feat/pep440-version-scheme

Conversation

@alex-powell

@alex-powell alex-powell commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1401
Refs #672

What changed and why

PyPI versions now order with PEP 440, through the helpers that already live in src/utils/version.ts. compareVersions, looksLikeVersion, isPreReleaseVersion, and normalizeAdvisoryVersion keep their names. A caller that has an ecosystem passes it. PyPI and pypi select PEP 440. A missing ecosystem, and every other ecosystem, stays on semver.

npm callers are unchanged. sort and filter still receive these functions directly. The extra argument ignores the numeric index those callbacks pass, so npm ordering stays the same.

The scanner and the offline database pass the ecosystem they already store. A PyPI advisory whose only fix is 2026.3.post1 reports that fix. An unreadable PyPI boundary throws, so a miss stays visible.

This change is the comparison step. Lockfile parsing, package URLs, exit codes, SBOMs, and the offline database shape are later steps.

Done when

Every form in the table parses, and the two orderings come out right, with tests for each. tests/utils/pep440-helpers.test.ts calls the helpers with ecosystem PyPI.

Form Test
2.9.0.post0 parses 2.9.0.post0 and sorts it after 2.9.0
2026.3.post1 parses 2026.3.post1 and sorts it after 2026.3
1!2.0.0 parses the epoch form 1!2.0.0
4.0.0.dev1 parses 4.0.0.dev1 and sorts it before 4.0.0
1.0.0rc1 parses 1.0.0rc1
1.0.0a1 parses 1.0.0a1
2.0.0b2 parses 2.0.0b2
1.0.0.post1.dev2 parses 1.0.0.post1.dev2
1.2.3+local after 1.2.3 sorts 1.2.3+local after 1.2.3
1.0.0rc1 < 1.0.0 orders 1.0.0rc1 before 1.0.0
1!0.1.0 > 2.0.0 orders 1!0.1.0 after 2.0.0

tests/scanner/pep440-fix-version.test.ts, "reports 2026.3.post1 when that is the only fix", calls findFirstFixedVersion for pytz at 2026.3 and expects 2026.3.post1.

The full suite passes with no existing test changed. git diff upstream/main -- tests/ lists only new files.

 tests/advisory/pep440-local-db.test.ts          |  99 +++++++
 tests/scanner/pep440-fix-version.test.ts        |  94 ++++++
 tests/scripts/check-pep440-requirements.test.ts | 114 +++++++
 tests/utils/pep440-helpers.test.ts              |  67 +++++
 tests/utils/pep440.test.ts                      | 376 ++++++++++++++++++++++++
 5 files changed, 750 insertions(+)

npm test on this commit passed 183 suites and 2513 tests. That run is the npm parity check. tests/helpers.test.ts, tests/utils/version-extensions.test.ts, tests/output/parent-update-display.test.ts, and tests/remediation/fix-commands-parent-update-merge.test.ts match upstream c5a5180.

No new runtime dependency. git diff upstream/main -- package.json is empty.

Checked against PyPI and OSV

Node v22.23.4, from a checkout of this branch.

npm ci
npx tsx scripts/check-pep440.ts --requirements path/to/requirements.txt
python3 -m pip install 'packaging==26.3'
npx tsx scripts/check-pep440.ts --sample

--requirements prints JSON for each exact pin. Comments, blank lines, extras, and environment markers are ignored. Each pin is queried on OSV and judged with PEP 440.

--sample compares PyPI release lists and OSV ranges for python-dateutil, pytz, numpy, django, requests, urllib3, jinja2, pillow, cryptography, setuptools, idna, and packaging with packaging 26.3. 2.9.0, 2.9.0.post0, 2026.3, 2026.3.post1, 1.0rc1, 1!2.0, 1.22, and 1.22.0 are checked against the same oracle and are left out of the seen and parsed totals.

Re-run on 52d1792.

2.9.0 -> 2.9.0, 2.9.0.post0 -> 2.9.0.post0, compare=-1
2026.3 -> 2026.3, 2026.3.post1 -> 2026.3.post1, compare=-1
versions seen: 2074
versions parsed: 2029
versions rejected: 45
parse disagreements: 0
ordering mismatches: 0
range verdicts: 306617
range mismatches: 0
advisories checked: 671

Both parsers reject the same 45 pytz legacy tags, 2004d through 2013d. The sampled release lists contain no epoch. Epoch ordering is covered by 1!2.0 in the explicit check and by the unit tests.

Verified against a production requirements.txt

I ran --requirements against the pinned requirements.txt of the production Python backend I mentioned in #672, and compared it with pip-audit 2.10.1 on the same file. That run used Node 20.19.2 at c65cdc5.

this branch pip-audit 2.10.1
pins checked 20 20
skipped 0 0
unparseable 0 n/a
affected pins 1 1

Both flag opentelemetry-api==1.45.0. This branch reports GHSA-xcq7-p3pf-p88h. pip-audit reports CVE-2026-114603, fixed in 1.45.1. OSV lists them as aliases of the same advisory, so the two results agree. The other 19 pins are unaffected in both.

That file has no post, dev, pre-release, epoch, or two-part versions, so this run confirms parity on a real tree. The new forms are covered by the sample run above. The one unusual form is the single-segment date version pdfminer.six==20260107, which parses.

compareVersions, looksLikeVersion, isPreReleaseVersion, and
normalizeAdvisoryVersion keep their names. Passing a PyPI ecosystem
selects PEP 440. Other callers stay on semver, including sort and
filter callbacks that pass an index.

A PyPI advisory whose only fix is 2026.3.post1 is reported.
scripts/check-pep440.ts judges a pinned requirements file against OSV
and compares a PyPI sample with packaging.
@cursor
cursor Bot force-pushed the feat/pep440-version-scheme branch from c65cdc5 to 52d1792 Compare October 25, 2026 03:14
@sonukapoor
sonukapoor requested a balanced review from Copilot October 25, 2026 13:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Order-dependent advisory evaluation can miss vulnerable versions, its reference check repeats the error, and scanner fix selection still discards invalid PyPI fixed boundaries.

3 open issues
What changed in this PR

Adds ecosystem-aware version comparison for experimental Python support, using PEP 440 for PyPI while preserving the default semver behavior.

Changes:

  • Adds PEP 440 parsing, normalization, ordering, and version-scheme selection.
  • Passes ecosystem information through scanner fix selection and offline advisory matching.
  • Adds regression tests and a requirements/PyPI validation script without new runtime dependencies.
File Description
tsconfig.test.json Includes the validation script in test compilation.
tests/​utils/​pep440.test.ts Tests parsing, ordering, schemes, and advisory evaluation.
tests/​utils/​pep440-helpers.test.ts Tests PyPI dispatch through existing version helpers.
tests/​scripts/​check-pep440-requirements.test.ts Tests requirements parsing and advisory reports.
tests/​scanner/​pep440-fix-version.test.ts Tests PyPI fix selection and npm compatibility.
tests/​advisory/​pep440-local-db.test.ts Tests offline PyPI matching and invalid boundaries.
src/​utils/​versions/​scheme.ts Defines the version-scheme contract and parse error.
src/​utils/​versions/​registry.ts Selects the scheme by ecosystem.
src/​utils/​versions/​pep440.ts Implements PEP 440 parsing and comparison.
src/​utils/​versions/​advisory.ts Adds advisory-bound and PyPI event evaluation.
src/​utils/​version.ts Adds ecosystem-aware dispatch to existing helpers.
src/​scanner.ts Uses ecosystem-aware comparison for fix selection.
src/​advisory/​local-db.ts Applies ecosystem-aware offline range matching.
scripts/​check-pep440.ts Adds requirements reports and Python reference comparisons.

Review effort: Balanced

if (event.fixed !== undefined) {
const fixed = boundary(event.fixed);
if (!fixed) unparsed.push({ field: "fixed", value: event.fixed });
else if (pep440Scheme.compare(version, fixed) >= 0) affected = false;
Comment thread scripts/check-pep440.ts
Comment on lines +354 to +355
elif ver >= parsed:
affected = False
Comment thread src/scanner.ts
Comment on lines +1131 to +1132
const fixed = normalizeAdvisoryVersion(event.fixed, ecosystem);
if (!fixed || isPreReleaseVersion(fixed, ecosystem)) continue;

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Forced process termination can truncate piped JSON reports while still returning a successful exit status.

4 open issues

Review effort: Balanced

Comment thread scripts/check-pep440.ts
process.exit(1);
}
const code = requirements !== undefined ? await runRequirements(requirements) : await runSample();
process.exit(code);

@sonukapoor sonukapoor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is careful work, @alex-powell, and the core of it is right. I ran about fifty PEP 440 cases through the parser, covering dev, pre and post ordering, dev-of-pre and dev-of-post, the alternate spellings, implicit post releases, epochs and local labels, and every one matched packaging. On the npm side nothing moves: merged onto current main, the full suite passes with no existing test changed, and scan JSON is identical to main on nest, analog, yarn-berry, cline, vscode and juice-shop offline, and on nest and analog online.

A few things before it goes in.

  1. An unreadable fix version still turns into "no fix available". The description says an unreadable PyPI boundary throws, but in the scanner only introduced does. A fixed value that cannot be read is dropped, so an advisory whose only fix is 2004d (pytz has real ones) reports no fix. That is the exact failure #1401 was filed for. Copilot's comment on that line is right.

  2. Throwing aborts the whole scan, though, so I would rather this degraded than threw. Nothing catches UnparseableVersionError on the online path or through the local advisory source, so once Python is wired in, one odd row (a GIT commit hash in an older offline DB, a VCS pin) stops the entire run. In local-db the throw also fires before the introduced check, so a row whose window can never include the version still kills it. You already have the right model in judgeOne, which returns inconclusive. Please do the same in the scanner and the local DB: mark that advisory or package as unreadable, say so in the output, and keep going. That also fixes point 1 the honest way.

  3. Please move the verification tooling to its own PR. scripts/check-pep440.ts and most of src/utils/versions/advisory.ts are only used by that script and its tests, but advisory.ts compiles into dist. versionMatchesAdvisoryBounds is used nowhere, and schemeFor only in tests. The script was useful for convincing us, and I would like to keep it, separately. Copilot's urllib3 point applies to it too: with events listed out of order, both your evaluator and the Python reference mark 2.0.0 unaffected, so they agree with each other while both miss it. That needs fixing before the "0 range mismatches" figure means anything.

  4. One place for "is this PyPI". The same check now lives in version.ts, scanner.ts, local-db.ts and registry.ts. Export the one in version.ts and use it everywhere.

  5. Pin the npm fallback. Nothing tests that a non-string third argument, such as the index filter passes, stays on semver. That is the guarantee npm parity rests on, so it deserves its own test. The comment above it also says sort passes an index, which it does not; filter and map do.

Small ones: pep440Ecosystem in the scanner sits between versionWithinAffectedWindow's doc comment and that function, so the comment now describes the wrong function. group() in pep440.ts returns its input unchanged. The branch also needs a rebase.

Once 1, 2 and 5 are in and the tooling is split out, I expect to approve this quickly.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(python): read and order PEP 440 versions

3 participants