Skip to content

fix(javascript): carry lockfile resolved/integrity into CycloneDX - #5230

Open
Portll wants to merge 2 commits into
anchore:mainfrom
Portll:npm-lockfile-provenance-cyclonedx
Open

fix(javascript): carry lockfile resolved/integrity into CycloneDX#5230
Portll wants to merge 2 commits into
anchore:mainfrom
Portll:npm-lockfile-provenance-cyclonedx

Conversation

@Portll

@Portll Portll commented Aug 26, 2026

Copy link
Copy Markdown

What

Adds the cyclonedx struct tag to Resolved and Integrity on the four npm-ecosystem lockfile entry types — NpmPackageLockEntry, YarnLockEntry, PnpmLockResolution, BunLockEntry — so they reach CycloneDX output as syft:metadata:resolved and syft:metadata:integrity.

Why

EncodeProperties uses CycloneDXFields = RequiredTag("cyclonedx"), so a metadata field is emitted only if it carries that tag (opt-in allowlist). GolangModuleEntry.H1Digest has it, these fields don't.

// golang.go — carried into CycloneDX
H1Digest  string `json:"h1Digest,omitempty" cyclonedx:"h1Digest"`

// npm.go — dropped
Resolved  string `mapstructure:"resolved" json:"resolved"`
Integrity string `mapstructure:"integrity" json:"integrity"`

Same encoder, same mechanism, different outcome. Not a CycloneDX limitation or a deliberate exclusion as far as I can tell?

The purl spec's own npm type definition declares:

"repository": { "use_repository": true, "default_repository_url": "https://registry.npmjs.org/" }

So pkg:npm/name@version resolves against the public registry by specification default. Dropping Resolved therefore doesn't merely omit provenance.

Concrete case, from firebase/firebase-js-sdk (public, current master). Its yarn.lock pins:

"closure-net@git+https://github.com/google/closure-net.git#6f48f578d3e80fe7a85e530a5d95b9351433d135":
  version "0.0.0"

syft's native JSON records the resolution correctly. The CycloneDX output is:

{ "name": "closure-net", "version": "0.0.0", "purl": "pkg:npm/closure-net@0.0.0",
  "properties": [ ...foundBy, language, type, metadataType, location:0:path ] }

— structurally identical to rimraf@2.7.1 beside it, with no VCS marker and no externalReferences. npm has never served closure-net@0.0.0; the registry holds only 0.0.1-security, the security-holder placeholder left after a malware takedown in January. So a consumer of that SBOM either matches nothing, or matches the squatted registry package. Both directions are wrong, from one dropped string.

Integrity is the second half: an SRI hash npm itself uses to refuse a mismatched tarball, currently absent from CycloneDX entirely. (CycloneDX has a first-class hashes field that could carry it — deliberately out of scope here, not my mapping decision.)

Scope

Measured across syft/pkg/*.go on main: 85 tagged fields across 11 of 63 metadata files. The tagged set is the OS/container lineage — alpm, apk, dpkg, rpm, portage — plus Java, Go and the ML formats. Eleven untagged files hold provenance or integrity fields: npm (7), rust (3), python (3), deno (3), conda (2), and six more with one each.

I have only changed npm. I have verified what resolved means in npm lockfiles by parsing them directly; I have not done that for conda or rockspec. If maintainers want the wider sweep, the measurement above is the starting list and I am happy to do it as follow-ups.

Verification

  • ./syft/format/cyclonedxjson, ./syft/format/cyclonedxxml, ./syft/format/internal/cyclonedxutil/..., ./syft/pkg — all pass.
  • Snapshot goldens unchanged. The fixtures carry no npm lockfile entry with these fields populated, so nothing needed regenerating.
  • Built and run against firebase's real yarn.lock (1,755 packages). Before: neither field present. After:
syft:metadata:resolved  = git+https://github.com/google/closure-net.git#6f48f578...
syft:metadata:integrity = sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409V...   (rimraf)

Component count unchanged; registry packages gain resolved/integrity and are otherwise untouched.

Context

Found while auditing why a malicious-package advisory (MAL-2026-276) matched a dependency that could not have come from the registry. Happy to adjust scope, naming, or split the Integrity half out if you would rather take them separately.

Portll added 2 commits August 27, 2026 01:19
The CycloneDX encoder emits a package metadata field only when it carries a
`cyclonedx` struct tag (CycloneDXFields = RequiredTag("cyclonedx")). The npm
lockfile entry types never had one, so Resolved and Integrity are dropped from
CycloneDX output while GolangModuleEntry.H1Digest — same mechanism, same
encoder — is carried through as syft:metadata:h1Digest.

Adds the tag to the four npm-ecosystem lockfile entry types:
NpmPackageLockEntry, YarnLockEntry, PnpmLockResolution and BunLockEntry.

Why it matters: a git-pinned dependency is indistinguishable from a registry one
in the CycloneDX output. The purl spec's npm type definition declares
use_repository: true with default_repository_url https://registry.npmjs.org/, so
a bare pkg:npm/name@version purl resolves against the public registry by
specification default. Dropping Resolved therefore does not merely omit
provenance — it leaves that default assertion unqualified for a package the
registry never served.

Integrity is an SRI hash npm itself uses to reject a mismatched tarball, and is
currently absent from CycloneDX entirely.

Tests: ./syft/format/cyclonedxjson, ./syft/format/cyclonedxxml,
./syft/format/internal/cyclonedxutil/... and ./syft/pkg all pass. Snapshot
goldens are unchanged — the fixtures carry no npm lockfile entry with these
fields populated.

Signed-off-by: Portll <2323009+Portll@users.noreply.github.com>
…tted

The tags in the previous commit changed nothing any test could see. Snapshot goldens carry no
npm lockfile entry with resolved or integrity populated, so removing all six tags again left the
suite green -- verified by stripping them and re-running.

Adds a case to Test_encodeComponentProperties alongside the apk one, which already asserts the
same shape for pullChecksum. With the tags stripped it fails naming both missing properties.

Signed-off-by: Portll <2323009+Portll@users.noreply.github.com>
@Portll

Portll commented Sep 2, 2026

Copy link
Copy Markdown
Author

Added a test in b946a4b, and I should be direct about why: the original commit changed nothing any test could see.

syft:metadata:integrity and syft:metadata:resolved appear in no file anywhere in the repository. I checked that against a positive control — syft:metadata:pullChecksum, an existing tagged field, returns one hit — so the zero is a reading rather than a broken grep. Stripping all six tags back off and re-running left cyclonedxjson, cyclonedxxml, cyclonedxutil, cyclonedxutil/helpers and syft/pkg green. Reverting the change was a no-op to the suite.

The new case sits alongside the apk one in Test_encodeComponentProperties, which already asserts this exact shape for pullChecksum. With the six tags stripped it fails, naming both absent properties:

--- FAIL: Test_encodeComponentProperties (0.00s)
      Name: (string) (len=23) "syft:metadata:integrity",
      Name: (string) (len=22) "syft:metadata:resolved",

CI has never run on this PR

The only check on any head here is DCO. No Validations run exists for 92e6199 — consistent with workflow approval being required for a first-time contributor, so there has been nothing for a reviewer to triage on. I ran the relevant packages locally against current main (go1.27.0, darwin/arm64) so the result exists somewhere:

patch applies to today's main cleanly
syft/format/cyclonedxjson ok
syft/format/cyclonedxxml ok
syft/format/internal/cyclonedxutil ok
syft/format/internal/cyclonedxutil/helpers ok
syft/pkg ok

Snapshot goldens are unchanged, and now that is a measured statement rather than an assumption: no fixture populates these fields, which is precisely why nothing caught their absence.

On hashes versus a property

The obvious objection is that an SRI integrity hash belongs in component.hashes rather than in syft:metadata:*. Two things argue against it here, and I would rather raise them than have them raised:

  • Hashes is populated only for ComponentTypeFile components, from artifacts.FileDigests (to_format_model.go). Package components have no path to it, so routing lockfile integrity there is a materially larger change than this one.
  • ApkDBEntry.Checksum already ships as cyclonedx:"pullChecksum". A package checksum travelling as a property is the established convention, not a workaround — 91 fields across syft/pkg/*.go use this tag.

Happy to take it in the hashes direction instead if that is the preference; it is a different PR and I would rather be told than guess.

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