fix(javascript): carry lockfile resolved/integrity into CycloneDX - #5230
fix(javascript): carry lockfile resolved/integrity into CycloneDX#5230Portll wants to merge 2 commits into
Conversation
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>
|
Added a test in
The new case sits alongside the apk one in CI has never run on this PRThe only check on any head here is DCO. No
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
|
What
Adds the
cyclonedxstruct tag toResolvedandIntegrityon the four npm-ecosystem lockfile entry types —NpmPackageLockEntry,YarnLockEntry,PnpmLockResolution,BunLockEntry— so they reach CycloneDX output assyft:metadata:resolvedandsyft:metadata:integrity.Why
EncodePropertiesusesCycloneDXFields = RequiredTag("cyclonedx"), so a metadata field is emitted only if it carries that tag (opt-in allowlist).GolangModuleEntry.H1Digesthas it, these fields don't.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:
So
pkg:npm/name@versionresolves against the public registry by specification default. DroppingResolvedtherefore doesn't merely omit provenance.Concrete case, from
firebase/firebase-js-sdk(public, currentmaster). Itsyarn.lockpins: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.1beside it, with no VCS marker and noexternalReferences. npm has never servedclosure-net@0.0.0; the registry holds only0.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.Integrityis the second half: an SRI hash npm itself uses to refuse a mismatched tarball, currently absent from CycloneDX entirely. (CycloneDX has a first-classhashesfield that could carry it — deliberately out of scope here, not my mapping decision.)Scope
Measured across
syft/pkg/*.goonmain: 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
resolvedmeans 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.yarn.lock(1,755 packages). Before: neither field present. After:Component count unchanged; registry packages gain
resolved/integrityand 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 theIntegrityhalf out if you would rather take them separately.