Skip to content

feat(licenses): resolve requirements.txt pin licenses from PyPI and add --offline - #113

Merged
lab700xdev merged 2 commits into
mainfrom
slice-129-pypi-licenses
Sep 15, 2026
Merged

lab700xdev merged 2 commits into
mainfrom
slice-129-pypi-licenses

Conversation

@lab700xdev

Copy link
Copy Markdown
Contributor

What

Dependency components parsed from requirements.txt now carry the license their package declares on PyPI, and a new --offline flag makes a scan touch no network at all.

Before this, every library component had a name and version and nothing else, so aisbom score lost most of the Licenses dimension for information PyPI publishes, and its remediation had to say dependency licenses were "not yet resolved automatically".

License resolution (default-on)

  • For each exact == pin, the scan reads https://pypi.org/pypi/<name>/<version>/json. Ranges (torch>=2.0) are skipped and counted in the summary line, since they don't name the installed release.
  • The result goes into the component's standard licenses[], plus an aisbom:license:source=pypi property. SPDX 2.3 output carries SPDX-valid values as licenseDeclared (licenseConcluded stays NOASSERTION). SPDX 3.0 and Markdown output skip the lookup.
  • Nothing is guessed. Order: PEP 639 license_expression → SPDX id/expression in license → a short table of unambiguous free-text spellings (Apache 2.0 LicenseApache-2.0) → a classifier naming exactly one license → any other short declaration kept as a license name. A license text pasted into the field (numpy ships ~46KB of it) and ambiguous classifiers (BSD License, Apache Software License) produce no license.
  • Never a verdict. legal_status is never derived from it. For libraries nothing downstream turns licenses[] into one: aisbom diff reads the model description, so upgrading reports no drift.
  • Best-effort. An outage, rate limit, unknown package, malformed body or exhausted 15s budget costs that dependency its license plus a stderr warning. Exit codes and model findings never change. Unlike the OSV lookup, a partial answer is kept, since one package's license says nothing about another's.
  • Cached in ~/.aisbom/pypi_license_cache.json: 30 days resolved, 24 hours unresolved, failures never cached. A run where every pin is a hit does not rewrite the file.

--offline / AISBOM_OFFLINE=1

On scan and score (the env var works for every command): no PyPI lookup, no OSV lookup, no telemetry, no update check. hf:// / https:// targets and --share are refused with exit 1 before anything is fetched.

Disclosure

README gets new "Dependency licenses from PyPI" and "Offline and air-gapped scans" sections, a Privacy entry for the PyPI lookup, and an Action data-flow note (a fourth outbound call: registry lookups). action.yml, action/README_ACTION.md and docs/air-gapped-guide.md are updated to match.

Verification

  • poetry run pytest --cov=aisbom --cov-fail-under=85: 1376 passed, 93.61% (1269 / 93.29% at branch point). aisbom bypass-scorecard --check passes.
  • New tests cover normalisation built from real PyPI payloads (torch 2.13.0, transformers 5.13.1, numpy 1.26.4), resolved / unknown / unreachable / malformed / retry / budget / cache paths, strict CycloneDX 1.5–1.7 and SPDX 2.3 validation, no-drift, and the CLI flows.
  • Offline is proven at the socket layer: one test restores the real HTTP, telemetry and update-check clients, refuses getaddrinfo/connect, and asserts --offline makes zero attempts. With the telemetry gate mutated out it fails on three real connection attempts to api.aisbom.io.
  • Old CLI (origin/main) vs new on one tree (mock models + torch==2.13.0, transformers==5.13.1, numpy==1.26.4, requests==2.32.3, a nonexistent package, scipy>=1.10), live PyPI:
    • Both exit 2. Same bom-refs, no field regressions, no model component changed. The only additions are licenses[] + source property on torch (full six-part SPDX expression), transformers (Apache-2.0) and requests (Apache-2.0). numpy, the unknown package and the range get nothing.
    • aisbom diff old new: "No changes detected."
    • Strict CycloneDX 1.7 and SPDX 2.3 validation: PASS.
    • Completeness score 53.0 (D) → 57.5 (C); Licenses 3.0 → 7.5 of 15.
    • A repeat scan was served entirely from cache (fetched_at unchanged, file not rewritten).
    • --offline components are byte-identical to the old CLI's; scan hf://… --offline exits 1.
  • Found while verifying: the subprocess CLI tests run the real binary outside the conftest stubs, so they were querying PyPI (and, already, posting telemetry and running the update check) from the suite. They now run with AISBOM_OFFLINE=1.
  • CI gets a new smoke test: --offline still writes an SBOM and refuses hf://.

Not in this PR

  • SPDX 3.0 has no license model in the generator yet, for dependencies or models.
  • The hosted dashboard's compliance remediation copy still says dependency licenses aren't resolved. It should change once a release carrying this ships, since older CLIs keep sending unlicensed dependencies.

…dd --offline

Library components parsed from requirements.txt now carry the license their
package declares on PyPI. For each exact == pin the scan reads
pypi.org/pypi/<name>/<version>/json and writes the result into the
component's licenses[] (with an aisbom:license:source=pypi property), and
into licenseDeclared for SPDX 2.3 output. Unpinned requirements are skipped.

Sources are tried from most to least precise: PEP 639 license_expression,
an SPDX id or expression in the license field, a short table of unambiguous
free-text spellings, a classifier naming exactly one license, then any other
short declaration kept as a license name. License texts pasted into the field
and ambiguous classifiers (BSD License) resolve to nothing.

Best-effort: a PyPI outage, rate limit, unknown package or exhausted time
budget costs that dependency its license and prints a warning; exit codes and
model findings never change. legal_status is never derived from it. Answers
are cached in ~/.aisbom/pypi_license_cache.json (30 days resolved, 24 hours
unresolved, failures never cached), and an all-hit run does not rewrite it.

--offline (scan, score) or AISBOM_OFFLINE=1 makes no network access of any
kind: no PyPI or OSV lookup, no telemetry, no update check. Remote targets and
--share are refused up front.

The subprocess CLI tests now run with AISBOM_OFFLINE=1: they are out of reach
of the conftest stubs and were reaching the network from the suite.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 84389683c2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread action.yml
inputs:
directory:
description: 'Directory to scan for AI model artifacts.'
description: 'Directory to scan for AI model artifacts. Exact requirements.txt pins found in it (package name and version only) are looked up on pypi.org from the runner to fill in their declared licenses; set AISBOM_OFFLINE=1 in the step env to skip network lookups.'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Propagate offline/share refusal from the Action

When an Action user follows this description by setting AISBOM_OFFLINE=1 while share: true is configured, the new CLI rejects the combination with exit 1, but action/entrypoint.sh only propagates SCAN_EXIT == 2 and ultimately exits 0. The workflow therefore reports success despite producing no new SBOM; worse, if the configured output path already exists, the wrapper proceeds to comment on or upload that stale file. Handle the CLI's exit 1 in the Action wrapper before processing the output.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 3d5b314. action/entrypoint.sh now rejects AISBOM_OFFLINE together with share: true before the scan runs and exits 1, so the job fails and no pre-existing sbom.json is commented on or uploaded. Exit 1 is added to the entrypoint's documented exit codes. Tests cover the refused pair (no scan, no helper invocations, exit 1) and offline without share (scans normally). I kept this narrow on purpose: the wrapper not re-raising a CLI exit 1 in general (e.g. a missing directory) predates this PR, and changing it would flip the job result for existing users, so it is tracked separately.

Comment thread aisbom/pypi.py Outdated
Comment on lines +234 to +238
mapped = {_CLASSIFIERS[c] for c in classifiers if c in _CLASSIFIERS}
# Two license classifiers could mean a choice, a combination or a stale
# leftover. Joining them into an expression would assert one of those.
if len(mapped) == 1:
return ResolvedLicense(mapped.pop(), is_spdx=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count unmapped license classifiers as conflicting

When PyPI supplies one mapped classifier together with an unmapped license classifier, such as MIT plus the generic BSD classifier, the comprehension drops the BSD entry before counting and this branch emits MIT as the sole declared license. Multiple license classifiers can represent dual or alternative licensing, so this creates an incorrect compliance assertion for those packages despite the stated no-guessing behavior; determine whether there is exactly one license classifier before returning a mapped value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 3d5b314. Every License :: classifier is now counted before mapping, except the bare License :: OSI Approved / DFSG approved category nodes, which name no license. More than one declared license classifier resolves nothing, so MIT plus the generic BSD classifier no longer becomes plain MIT. Regression tests cover MIT beside BSD License and beside Other/Proprietary License, and confirm the bare category node is not treated as a second license.

…share in the Action

Classifiers: a mapped classifier beside one the table cannot map (MIT plus the
generic BSD License) used to resolve to plain MIT, because unmapped entries
were dropped before counting. Every License :: classifier now counts, bar the
bare OSI Approved / DFSG approved category nodes, and more than one resolves
nothing.

Action: with AISBOM_OFFLINE=1 in the step env and share: true, the CLI refuses
with exit 1, which the entrypoint does not re-raise, so the job passed and a
sbom.json already in the workspace would have been commented on and uploaded.
The entrypoint now rejects that pair before scanning and exits 1.
@lab700xdev
lab700xdev merged commit 3624fd6 into main Sep 15, 2026
2 checks passed
@lab700xdev
lab700xdev deleted the slice-129-pypi-licenses branch September 15, 2026 03:07
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.

1 participant