Skip to content

feat(modelcard): give local model files the modelCard of the Hugging Face repo they came from - #114

Merged
lab700xdev merged 2 commits into
mainfrom
local-hf-modelcard
Sep 18, 2026
Merged

lab700xdev merged 2 commits into
mainfrom
local-hf-modelcard

Conversation

@lab700xdev

Copy link
Copy Markdown
Contributor

What

A local copy of a Hugging Face model now gets the same modelCard an hf:// scan of its repo produces, when the link can be proven.

  1. Candidate repo from local evidence. Either the HF cache layout (models--<org>--<name>/snapshots/<commit>/…, which gives the repo and revision), or a config.json next to the weights whose _name_or_path is an org/name repo id. File names are never used.
  2. Proof by content. The file's SHA-256 must equal an lfs.oid in the repo's file listing, or, for small files stored in git, its git blob id must equal an oid. A renamed file still matches. A fine-tune whose config still names its base model does not.
  3. No card when nothing proves out, when two repos both prove out, or on any network failure, following the same best-effort contract as the hf:// card fetch.

A matched card carries aisbom:hf:match (hf-cache or config-name-or-path), so an inferred link can be told apart from an hf:// scan.

Privacy and opt-out

  • Only the repo id and commit are sent to huggingface.co. The hash comparison happens locally, so paths, file names and hashes are never sent.
  • A _name_or_path that is a single name, looks like a path, or names a directory that exists is never requested.
  • A tree with no HF evidence makes no request and prints nothing.
  • On by default for CycloneDX 1.7, SPDX 3.0 and score on a directory. --no-hf-lookup, AISBOM_NO_HF_LOOKUP=1 or --offline turn it off.
  • The README's Telemetry & Privacy section, the Action README and the air-gapped guide document this.

Other changes

  • SPDX 3.0 links each model only to the datasets its own card declares (unchanged for hf:// scans, where every model shares one card).
  • score's model-card and dataset fix hints mention that a local copy in the HF cache also works.

Verification

  • poetry run pytest --cov=aisbom --cov-fail-under=85: 1452 passed, 93.73% coverage. aisbom bypass-scorecard --check passes.
  • New tests cover candidate rules, hash proof (LFS and git blob), fine-tune rejection, ambiguity, pagination, host-pinned pagination, malformed ids, every opt-out making zero requests, outputs that skip the lookup, per-file cards in CycloneDX and SPDX 3.0, and strict 1.7 schema validation.
  • Live on hf-internal-testing/tiny-random-bert, comparing main against this branch:
    • HF cache copy: matched 2 of 2 (model.safetensors via git blob id, pytorch_model.bin via LFS SHA-256). No field regressions; the only added component key is modelCard, and it equals the hf:// scan's card apart from aisbom:hf:match.
    • The same weights with no HF evidence: output identical to main apart from serial number and timestamp.
    • config.json naming the repo: matched. Fine-tune with a config naming the base model: no card.
    • All outputs pass strict CycloneDX 1.7 validation.
    • aisbom score on the cache copy: 50.0 (D) with --no-hf-lookup, 65.0 (C) without.

Release note

Local scans of HF-cached models now contact huggingface.co by default, including in the GitHub Action when the scanned directory has that evidence.

…Face repo they came from

A local copy of a Hugging Face model used to get no modelCard, while an
hf:// scan of the same repo did. Local scans now look for evidence of the
repo (the HF cache layout, or a config.json whose _name_or_path is an
org/name repo id), prove it by finding the file's SHA-256 or git blob id in
that repo's file listing, and only then attach the repo's card.

- No proof, a lookup failure, or more than one repo proving out: no card.
- A matched card records aisbom:hf:match (hf-cache or config-name-or-path).
- Only the repo id and commit are sent; paths, names and hashes stay local.
  Path-like or single-name _name_or_path values are never requested.
- On by default for CycloneDX 1.7, SPDX 3.0 and score on a directory;
  --no-hf-lookup, AISBOM_NO_HF_LOOKUP=1 or --offline turn it off.
- SPDX 3.0 now links each model only to its own card's datasets.
- README privacy section, Action README and air-gapped guide updated.

@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: c519ec516d

ℹ️ 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 aisbom/spdx3_gen.py
for dep in results.get("dependencies", [])
],
"datasets": _card_datasets(results.get("hf_model_card")),
"datasets": _all_card_datasets(results),

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 Include per-model dataset assignments in the namespace

When the same artifacts and dependencies have different per-model dataset assignments but the same first-seen dataset union—for example, model A changes from [x, y] to [x] while model B changes from [z] to [y, z]—this fingerprint remains identical even though the generated trainedOn relationships differ. Both SPDX documents consequently reuse the same document, package, and relationship IRIs, so importing them into one SPDX/RDF store can conflate distinct relationship versions. Fingerprint each artifact's own _card_datasets(_card_for(...)) list rather than only the union.

Useful? React with 👍 / 👎.

… namespace

Two scans could share a dataset union while assigning it differently between
models (A [x, y]/B [z] versus A [x]/B [y, z]). The content-derived namespace
hashed only the union, so both produced the same document, package and
relationship IRIs while their trainedOn relationships differed, and a store
importing both could conflate them.

A matched model now carries its own dataset list in its fingerprint entry.
Only matched artifacts carry it, so hf:// scans and local scans that matched
nothing keep the IRIs they already have — asserted against a namespace
produced by the released generator.
@lab700xdev

Copy link
Copy Markdown
Contributor Author

Codex P2 (per-model dataset assignments in the SPDX 3.0 namespace): fixed in 730d3d6.

Reproduced first: A [x, y]/B [z] and A [x]/B [y, z] produced the identical namespace aisbom-scan-34ae86474043d58cf2fb0377fa9c6ca1 while their trainedOn relationships differed. A matched model now carries its own dataset list in its fingerprint entry, so the two documents get distinct IRIs.

Only matched artifacts carry the extra element, so hf:// scans and local scans that matched nothing keep the IRIs they already have. A test pins that namespace to a value produced by the released generator, so a future change to the fingerprint's shape that would churn existing documents fails the suite.

@lab700xdev
lab700xdev merged commit deb3abc into main Sep 18, 2026
2 checks passed
@lab700xdev
lab700xdev deleted the local-hf-modelcard branch September 18, 2026 02:34
lab700xdev added a commit that referenced this pull request Sep 19, 2026
…risk artifacts (#131) (#115)

* fix(scanner): report unreadable model files as errors, not clean LOW-risk artifacts (#131)

`LOW` is an assertion: AIsbom opened the artifact and found nothing
dangerous. For a file no parser could read, the honest answer is
different — and collapsing the two meant a truncated download passed a
CI gate as a clean model.

Five inspectors already recorded their own parse failure in
`meta["error"]` and nothing in the codebase ever read it. Two corruption
shapes never raise at all (a text `.pt`, a two-byte pickle), and a
git-LFS pointer set that key to the empty string, so a truthiness check
would still have missed it. Each inspector now reports whether its parse
actually succeeded.

Unreadable files land in `results["errors"]` with a closed-set subtype
(LfsPointer, EmptyFile, TruncatedStream, UnrecognizedFormat), print in
their own "Could not read" section, and exit 1 — which is what the
README's exit-code table has always documented for "a file failed to
parse". The component stays in the SBOM so an auditor sees the file was
present, but carries no framework label and no risk verdict, marked with
`aisbom:unreadable` properties. Dropping the label is what stops
`aisbom:format` claiming 200 random bytes are SafeTensors. `aisbom score`
refuses to grade such a scan via the existing #114 gate.

Also fixed, as the same class of problem:

- The three `UNKNOWN (...)` verdicts (GGUF invalid header, Keras
  unrecognized container, unparsable ONNX) were honest but scored 0 in
  `_risk_score` — below LOW — so those files exited 0 and rated safer
  than a clean model.
- Any text file in `.pt`/`.pth`/`.bin` was classified as a Python path
  config, so an HTML error page saved over a checkpoint scored LOW. That
  classification is now `.pth`-only and validated against the format's
  actual spec.
- A SafeTensors header longer than the file is rejected before the read,
  replacing an internal OverflowError with a message naming the problem.

A real-tree probe drove one design decision: requiring the pickle opcode
walk to reach STOP flagged 75 valid files — every `.pkl` in joblib's own
test corpus — because joblib appends array data as raw bytes after STOP.
The rule is "nothing past the protocol header disassembled" instead, and
that file shape is now a regression test. The probe over 35,283
artifacts (141 real `.pth` files, a node_modules tree, an HF cache, a
generated valid-model corpus) reports zero false positives.

BREAKING: a tree containing unreadable files now exits 1 where it
exited 0. Called out at the top of the changelog entry and in a README
upgrade note.

* fix(scanner): apply unreadable handling to remote scans and caught parse errors (#131)

Addresses all three findings from the Codex review on PR #115.

P1 — remote artifacts skipped the fix entirely. Every unreadable
recording was guarded on `local_path is not None`, so `hf://` and HTTPS
scans — the flagship "verify before you `git clone`" path — kept the old
behaviour: a remote `.safetensors` of random bytes came back
`framework="SafeTensors"`, `risk_level="LOW"` and exit 0. Each inspector
now finalizes through one shared `_finalize_inspection`, identified by
`local_path or name`, so local and remote are reported identically.

The git-LFS subtype survives on the remote path too, which is a real
case rather than a contrived one: `raw.githubusercontent.com` serves the
pointer text, not the file, for anything LFS-tracked. The head is
re-read only after a parse has already failed, so the extra Range
request is never paid by a healthy file.

P2 — a caught inspector exception stayed a clean verdict. All five
inspectors wrap their body in `except Exception: meta["error"] = ...`,
and nothing read that key; GGUF, Keras and ONNX seed `risk_level` to LOW,
so a file that threw mid-parse exited 0 as clean. `_finalize_inspection`
now picks that up — by key membership, not truthiness, because the LFS
stub sets it to the empty string.

A verdict already reached is never overwritten: an inspector can find a
CRITICAL payload and then throw, and downgrading that to "unreadable"
would lose the finding — the one outcome worse than the bug being fixed.
Only the seeded UNKNOWN/LOW placeholders give way. Pinned by test.

P2 — `.pth` path entries may contain spaces. `site.addpackage` only
rstrips a line before joining it, so `/opt/My Models/site-packages` and
the Windows `C:\Program Files\...` shape are valid and were being called
corrupt. Rejecting only control characters (as suggested) would have let
`totally not a model at all` read as a path and undone the fix, so a
line carrying whitespace must additionally hold a path separator: prose
has none, a directory path does. The 141-file probe corpus contained no
spaced path, which is why the real-tree probe could not catch this.

Verification: 1498 tests pass (+8), coverage 93.79%. Real-tree probe
re-run after the `.pth` change — still zero false positives, 141 `.pth`
files kept clean. Scorecard, full-repo scan and `--strict` gates all
exit 0.
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