Skip to content

Output format reference

Both formats are produced from the same intermediate model (see architecture.md), so they carry the same information; only the spelling differs. Output is pretty-printed JSON with keys in the order the specifications list them, followed by a trailing newline.

Document header

CycloneDX 1.6 / 1.7 SPDX 2.3
Identity bomFormat: CycloneDX, specVersion: 1.6 or 1.7 (--spec-version), matching $schema spdxVersion: SPDX-2.3, dataLicense: CC0-1.0, SPDXID: SPDXRef-DOCUMENT (for --spec-version 3.0 see SPDX 3.0.1)
Unique id serialNumber: urn:uuid:<v5> documentNamespace: https://spdx.org/spdxdocs/pixi-sbom/<name>/<uuid>
Timestamp (UTC, seconds) metadata.timestamp creationInfo.created
Generator metadata.tools.components[]: pixi-sbom with version and repository link creationInfo.creators[]: Tool: pixi-sbom-<version>
Author metadata.authors[] (name, email) from the manifest's authors creationInfo.creators[]: Person: <name> (<email>)
Generation context metadata.lifecycles[]: the phase the input describes (see Lifecycle phase) creationInfo.comment naming the input and the same phase
Data origin (1.7 only) citations[]: one entry attributing /metadata/component, /components and /dependencies to the pixi-sbom tool component, with a note naming the lockfile, environment and platform (none)
Document name (none; the root component carries it) name: <workspace>-<environment>-<platform>
What was described metadata.properties[]: pixi:environment, pixi:platform, pixi:lockfile root package sourceInfo

pixi:lockfile is the lockfile's name relative to the workspace root (normally pixi.lock), never the absolute path it was read from, so a document does not reveal or depend on the layout of the machine that generated it.

Lifecycle phase

What moment in a project's life the document describes depends on what it was made from:

Input CycloneDX metadata.lifecycles[].phase SPDX 3 software_sbomType SPDX 2.3 creationInfo.comment
A lockfile (pixi.lock, pylock.toml) pre-build: resolved, nothing built build "Generated from the lockfile … before any build; lifecycle phase: pre-build"
--prefix operations: what is installed deployed "Generated from the installed environment …; lifecycle phase: operations"
--from-sbom the source document's phases the same, mapped "Generated from the document …", with its phases
--from-sbom, source names none none (lifecycles is left out) none no phase

A source document's phases are read from its metadata.lifecycles (CycloneDX), its software_sbomType (SPDX 3), or the creator comment of an SPDX 2.3 document pixi-sbom wrote. SPDX 3 has one build type for CycloneDX's pre-build, build and post-build, so a lockfile's document read back from SPDX 3 is build; decommission has no SPDX 3 counterpart and is left out there. Before 1.7.0 every document claimed pre-build, which was wrong for --prefix and --from-sbom.

Reproducibility

Documents are deterministic. The identifier is a UUIDv5 derived from the lockfile text, the environment, the platform, the format, and the pixi-sbom version, so the same input always yields the same serialNumber / documentNamespace, and any change to the lockfile yields a new one. The timestamp is the only value that varies between runs; set SOURCE_DATE_EPOCH (seconds since the Unix epoch) to pin it, and two runs produce byte-identical files. An unparsable SOURCE_DATE_EPOCH is ignored with a warning.

The root component

The workspace itself is the subject of the document.

CycloneDX SPDX
Element metadata.component (type: application, bom-ref: root) packages[0] with SPDXID: SPDXRef-Package-root, primaryPackagePurpose: APPLICATION
Name / version name, version from the manifest name, versionInfo
License licenses[] (same rules as packages, see below) licenseDeclared
Homepage / repository externalReferences[] of type website / vcs homepage / downloadLocation
Link to document implicit relationship SPDXRef-DOCUMENT DESCRIBES SPDXRef-Package-root

Name, version, authors, license, homepage and repository come from pixi.toml ([workspace], or the legacy [project] table) or from pyproject.toml ([tool.pixi.workspace], with the PEP 621 [project] table filling in whatever is missing: authors entries as { name, email }, license as an expression string or { text = ... }, [project.urls] keys Homepage and Repository / Source). With no manifest, the name is the lockfile's directory and everything else is absent.

Packages

Every package locked for the selected environment and platform becomes one entry, sorted by kind (conda binary, conda source, PyPI), then name, then version.

Lockfile data CycloneDX components[] SPDX packages[]
Kind type: library; property pixi:kind = conda, conda-source, pypi or embedded primaryPackagePurpose: LIBRARY; kind is part of the SPDXID
Identifier bom-ref = purl SPDXID: SPDXRef-Package-<kind>-<name>-<version> (sanitized, de-duplicated with a numeric suffix)
Name, version name, version name, versionInfo
Package URL purl externalRefs[] with referenceCategory: PACKAGE-MANAGER, referenceType: purl
Supplier supplier (name, url[]): the conda channel (e.g. conda-forge) or the PyPI index host (e.g. pypi.org); absent for source packages supplier: Organization: <name> (<url>)
Extra purls (see below) property pixi:purl per purl additional externalRefs[] entries
Download location externalReferences[] of type distribution, or vcs for git+ locations; none when nothing records one (poetry.lock names files, not URLs) downloadLocation (URL or git+<url>@<rev>); NOASSERTION plus sourceInfo for local paths; NOASSERTION alone when nothing records one
SHA-256, MD5 hashes[] (SHA-256, MD5) checksums[] (SHA256, MD5)
License licenses[] (see below) licenseDeclared (see below); licenseConcluded and copyrightText are NOASSERTION
License file names, summary, URLs (--fetch-licenses) pixi:license-file properties, description, externalReferences[] licenseComments, summary, homepage
License texts (--license-texts) licenses[].license.text hasExtractedLicensingInfos for LicenseRef- licenses
Everything else properties[] with pixi: names comment: one key=value per line
filesAnalyzed always false (no archive is opened)

Version is omitted only for pixi-build source packages whose metadata has not been evaluated yet (a "partial" lock entry).

pixi:* properties

Property Set for Value
pixi:kind all conda, conda-source, pypi, embedded, external
pixi:channel conda binary Channel name, e.g. conda-forge (last path segment of the channel URL)
pixi:channel-url conda binary Full channel base URL
pixi:subdir conda linux-64, noarch, ...
pixi:build conda Build string
pixi:build-number conda Build number
pixi:file-name conda binary; PyPI (poetry.lock, pdm.lock) Archive file name; for a Poetry package, the wheel or sdist this platform would install, since Poetry records no download URL
pixi:size conda Archive size in bytes
pixi:license-family conda Channel-declared license family
pixi:noarch conda true when the package is noarch
pixi:purl conda Additional purls the channel declares (typically the PyPI purl of a Python package)
pixi:identifier-hash conda source pixi-build identifier hash from the lock entry
pixi:source-git, pixi:source-rev, pixi:source-tag / pixi:source-branch, pixi:source-subdirectory conda source (git) The pinned build source
pixi:source-url, pixi:source-subdirectory conda source (archive) The pinned build source; its SHA-256 goes into the hashes
pixi:source-path conda source (path) Local path
pixi:direct any true when the workspace manifest declares this package itself, rather than it coming along as somebody else's dependency. For a lockfile other than pixi.lock, the manifest is the pyproject.toml or environment.yml beside it (see What a project without pixi declared) With --prefix, set on what the user asked for by name (REQUESTED, conda-meta/history), with pixi:declared-in = requested
pixi:declared-in any The features whose dependency tables declare it, comma separated (default, default,docs)
pixi:license-exempt any Why the license policy does not apply (--ignore-license); true when no justification was given
pixi:scorecard, pixi:scorecard-date any With --scorecard: the OpenSSF Scorecard aggregate out of ten and when the repository was scored
pixi:scorecard-check-<name> any One per check below --scorecard-min, e.g. pixi:scorecard-check-Signed-Releases=0.0
pixi:cargo-source embedded (cargo) Where a crate read from a cargo auditable binary came from: crates.io, git, local, ...
pixi:index-url PyPI Index the wheel was resolved from
pixi:resolution-markers PyPI (uv.lock) For a package uv locked at more than one version, the environments this one is for, joined with \|\|
pixi:marker PyPI (pylock.toml) The environment marker the lockfile put on the package, e.g. sys_platform == 'win32'; the package is in the document because the marker is true for its platform
pixi:direct-url PyPI (pylock.toml, uv.lock, --prefix) Where a package installed from outside an index came from: the repository URL of a VCS source, the path of a local directory (relative to the lockfile), or the URL of an archive
pixi:source-rev PyPI (pylock.toml, uv.lock, --prefix) The exact commit of a VCS source
pixi:editable PyPI (pylock.toml, uv.lock) true for a local directory installed in editable mode
pixi:requires-python PyPI Requires-Python of the distribution
pixi:python-extras PyPI (pixi.lock, uv.lock, poetry.lock, pdm.lock; --prefix with --infer-extras) The extras the package was installed with, comma separated, e.g. socks,security: what the manifest and the other packages asked of it
pixi:python-extras-inferred PyPI (--prefix --infer-extras) true when pixi:python-extras was inferred from what is installed rather than read from the input
pixi:python-extras-evidence PyPI (--prefix --infer-extras) What an inference rests on: each extra and the installed packages it requires, e.g. socks: pysocks
pixi:via-extra PyPI (pixi.lock, uv.lock, poetry.lock, pdm.lock, pylock.toml) The extras the package is in the document for, as package[extra], comma separated, e.g. requests[socks]. Set only when nothing else needs the package; what such a package depends on carries the same label
pixi:source PyPI true for sdists / source trees

In SPDX these appear in the package comment because SPDX 2.3 has no free-form property field.

Package URLs

Kind Shape
conda binary pkg:conda/<name>@<version>?build=<build>&channel=<channel>&subdir=<subdir>&type=<conda\|tar.bz2>
conda source pkg:conda/<name>@<version>?build=<build>&subdir=<subdir> (no channel; qualifiers present only when known)
PyPI pkg:pypi/<normalized-name>@<version> with PEP 503 normalization (lower-case, runs of -_. collapsed to -)
PyPI, from a GitHub checkout pkg:github/<owner>/<repo>@<commit>
PyPI, from another VCS checkout pkg:pypi/<normalized-name>@<version>?vcs_url=<vcs>+<repository>@<commit>
PyPI, from an archive URL pkg:generic/<name>@<version>?download_url=<url>
PyPI, from a local directory or archive (editable or not) pkg:generic/<name>@<version>
First-party workspace member (uv.lock) pkg:generic/<name>@<version>: no registry has released it

A package installed from somewhere other than an index does not get a plain pkg:pypi purl, since that would claim a PyPI release which may not exist or may hold other code, and a scanner would match the wrong advisories. Where it came from stays in pixi:direct-url, pixi:source-rev and pixi:editable, from every lockfile reader and from --prefix (PEP 610 direct_url.json). --vulnerabilities osv asks about none of these purls: OSV has no ecosystem for generic or github, and a vcs_url checkout is not the release of its version. In pixi.lock a git or local source is recognised; a URL without an index is left as it is, since older lockfiles record index wheels that way.

Purls double as the CycloneDX bom-ref, which is why they must be unique within a document; within one environment and platform they always are. The bom-ref / SPDXID is always derived from the conda purl, even when --primary-purl pypi (below) makes a PyPI purl the component's purl.

PyPI identities for conda packages

Vulnerability databases (OSV, GHSA) and the scanners built on them have no conda ecosystem: a pkg:conda/numpy@... purl matches nothing, so a conda-only Python environment scans as clean whatever it contains. Three sources supply a pkg:pypi/... purl for a conda package:

Source When Recorded as
Lockfile purls: pixi writes them for environments that have pypi-dependencies; an empty list means "not on PyPI" pixi:purl property / extra externalRefs entry
--pypi-mapping prefix the conda-forge mapping pixi itself uses, downloaded and cached for a day as above, plus pixi:pypi-mapping=prefix
--pypi-mapping-file <PATH> an offline copy of that mapping ({"<conda name>": "<pypi name>" \| ["..."] \| null}) as above, plus pixi:pypi-mapping=file

The mapping is applied only to conda-forge binary packages whose lock entry has no purls: at all; the lockfile's own answer, including an explicit empty list, is authoritative. The PyPI purl carries the conda package's version.

pixi writes lockfile purls as bare names — pkg:pypi/click?source=compressed-mapping, with no version, because the mapping behind them is name to name. A purl with no version is a name, not an identity: no database can be asked about it. Where the lock entry states a bare purl, its version is filled in from that same entry (pkg:pypi/click@8.5.0?source=compressed-mapping). A purl that already carries a version is left alone, and purls: [] keeps meaning "not on PyPI".

--primary-purl pypi then makes that PyPI purl the component's purl (first externalRefs entry in SPDX) and moves the conda purl to pixi:purl, because scanners only read the primary identity. With it, grype / trivy / osv-scanner report advisories for conda-installed Python packages.

Licenses

Package license strings are whatever the channel or index declared; conda-forge is mostly SPDX-clean but not entirely, and PyPI wheels in a pixi lockfile carry no license at all unless --fetch-licenses looks them up (below). The rules:

  1. A valid SPDX expression is kept verbatim. Deprecated identifiers such as GPL-3.0 or LGPL-2.1 count as valid because conda-forge still uses them widely.
  2. Common non-conforming spellings are parsed leniently and rewritten: MIT/Apache-2.0 becomes MIT OR Apache-2.0, mit becomes MIT. Operator precedence follows the SPDX rules (AND binds tighter than OR) and parentheses are emitted only where needed.
  3. Anything else is treated as free text and preserved:
  4. CycloneDX: licenses[].license.name instead of licenses[].expression.
  5. SPDX: licenseDeclared becomes LicenseRef-pixi-<sanitized text> and a matching entry is added to hasExtractedLicensingInfos with the original text, so nothing is lost.
  6. No license → no licenses entry (CycloneDX) / NOASSERTION (SPDX).

conda-forge spells a few toolchain licenses with a clause that is not an SPDX exception, e.g. LGPL-2.0-or-later WITH exceptions (kernel-headers, sysroot, the glibc family). Such a clause is rewritten to WITH AdditionRef-exceptions, which SPDX allows for a reference to an exception text, so the expression remains evaluable by consumers and by the license policy; the original spelling is kept in a pixi:license-raw property. A genuine SPDX exception (WITH Classpath-exception-2.0) is never rewritten.

License details with --fetch-licenses

--fetch-licenses works for every package kind. For conda packages it reads the extracted package in the local package cache (<pixi cache>/pkgs/<name>-<version>-<build>/info/): about.json supplies the license expression when the lockfile has none (recorded as pixi:license-source=package-cache), the license family, the summary and the project URLs, and info/licenses/ supplies the names of the license files (pixi:license-files-source=package-cache). Packages that are not in the local cache are read from the archive on the channel without downloading it: a .conda file is a zip whose info-*.tar.zst member holds the same files, so one or two HTTP range requests (the archive's tail, then the member when it is not already in the tail) fetch a few kilobytes per package. The extracted files are cached under the pixi-sbom cache directory by the archive's SHA-256, so repeated runs are offline. Provenance is recorded as conda-archive. Legacy .tar.bz2 archives have no central directory, so one whose lockfile size is at most 2 MiB is downloaded whole and read the same way; larger ones (or ones without a recorded size) are skipped, with the size in the debug log. A package whose archive cannot be reached keeps the lockfile's license and the run continues. For PyPI packages it asks the index as described next, and reads each wheel's dist-info the same way (the METADATA member and the license files, by HTTP range, cached by SHA-256): the PEP 639 License-Expression or the older License header fills a missing license (pixi:license-source=wheel), Summary and the project URLs fill the description and references, and the License-File names (PEP 639 licenses/ directory, or files next to METADATA in older wheels) become the license files. Sdists are skipped.

By default only the license type is recorded: the expression as always, plus the file names as pixi:license-file properties (CycloneDX) / licenseComments (SPDX). --license-texts additionally embeds the file contents:

Declared license CycloneDX licenses[] SPDX 2.3
single SPDX identifier, e.g. Zlib { license: { id, text, acknowledgement: declared } } for the first file, then { license: { name: <file>, text } } per additional file licenseDeclared: Zlib; licenseComments: License files: ... (the spec has no per-package text for known identifiers)
compound expression, e.g. MIT OR Apache-2.0 { expression } only, since CycloneDX cannot put texts next to an expression; file names as pixi:license-file properties as above
free text, e.g. Proprietary { license: { name, text, acknowledgement: declared } } plus named entries for further files LicenseRef-pixi-<name>-<hash> whose hasExtractedLicensingInfos entry carries the file text
none, but files exist one { license: { name: <file>, text } } per file NOASSERTION; licenseComments lists the files

Texts can add a few megabytes to a large environment, which is why they are opt-in. Summary and URLs from about.json become the component description and externalReferences of type website / vcs / documentation (SPDX: summary, homepage).

PyPI license lookup

With --fetch-licenses, PyPI packages that still have no license after their wheel was read (sdists, unreachable wheels, wheels that declare only classifiers) are looked up on the index JSON API (https://pypi.org/pypi/<name>/<version>/json, or PIXI_SBOM_PYPI_URL, ten at a time) for every PyPI package that has no license and takes, in order: the PEP 639 license_expression; the classic license field when it is a short single line (it sometimes holds a whole license text, which is ignored); and the License :: trove classifiers, mapped to SPDX identifiers for the common unambiguous ones (MIT License → MIT, Apache Software License → Apache-2.0, BSD License → BSD-3-Clause, ...) and joined with OR when several are listed. The result goes through the same normalization as conda licenses and the package gets pixi:license-source=pypi. Responses are cached under the cache directory (a release's metadata never changes). A lookup that fails is logged and the package is left without a license; once the index looks unreachable the remaining lookups are skipped.

Embedded SBOMs (PEP 770)

Wheels may ship their own SBOM fragments under *.dist-info/sboms/ (PEP 770): maturin records the Rust crates it compiled into a wheel as a CycloneDX document, and some projects add hand-written SPDX fragments for vendored libraries. With --embedded-sboms those files are read out of each wheel the same way its license details are (by HTTP range, cached), parsed as CycloneDX 1.4 – 1.7 or SPDX 2.x JSON, and their components become packages of kind embedded:

Fragment data Package
name, version, purl (pkg:cargo/..., pkg:generic/...) name, version, purl (a component without a purl gets <wheel purl>#<name>@<version>)
license (expression, license.id / name; SPDX licenseDeclared else licenseConcluded) license, normalized like every other
SHA-256, distribution / VCS reference, description hashes, externalReferences, description
dependencies / DEPENDS_ON edges between the embedded packages
the fragment root's direct dependencies (metadata.component + dependencies, or DESCRIBES), else its undepended components edges from the wheel to them

Every embedded package carries pixi:kind=embedded and pixi:embedded-sbom=<wheel>/<file> (several, ; separated, when more than one fragment declares the same purl; fragments merge into one package per purl). Embedded packages take part in --report, --fetch-licenses (their declared licenses) and the license policy. SPDX 3 fragments are not read yet. Conda packages have no equivalent convention.

Rust crates inside a binary (cargo auditable)

conda-forge builds its Rust packages with cargo auditable, which embeds the resolved crate graph in the binary's own .dep-v0 section. With --prefix and --embedded-sboms those crates are read out of the binaries the environment installed (ELF, Mach-O and PE alike) and attached like any other embedded component: pixi:kind=embedded, a pkg:cargo/<name>@<version> purl, pixi:embedded-sbom=cargo-auditable:<file> naming the binary, pixi:cargo-source (crates.io, git, local, ...), and an edge from the conda package to the crate its program was built from. Crates that only built the program (kind: build) are not in it and are left out. Because the purls are pkg:cargo, --vulnerabilities osv covers them through RUSTSEC.

Installed environments

With --prefix the document describes an installed environment instead of a lockfile: the metadata property is pixi:prefix (the environment directory's name) rather than pixi:lockfile, the SPDX root package's source info says prefix <name>, and pip-installed packages are located by a file:// URL of their dist-info directory (or <vcs>+<url> for direct VCS installs, with pixi:direct-url and pixi:source-rev), carry pixi:installer, and have no hashes. Conda packages carry pixi:extracted-package-dir, where the record says the archive was unpacked. A venv or a plain Python installation has no python package to list, so the Python it was made with is the document's pixi:python-version (a CycloneDX metadata property, a line of the SPDX root package's comment).

Documents derived from documents

With --from-sbom the document describes what another document described: the metadata property is pixi:source-document — the source's CycloneDX serial number or SPDX document namespace — rather than pixi:lockfile, and the SPDX root package's source info says document <identity>. Everything the source records is carried over, including the pixi:* properties, so a document this tool wrote round-trips unchanged; a package whose purl is neither pkg:conda nor pkg:pypi, or which has no purl, gets pixi:kind=external.

Yanked releases

With --fetch-licenses, a PyPI package whose release the index has yanked (PEP 592) carries pixi:yanked=true and, when the index gives one, pixi:yanked-reason (CycloneDX properties; SPDX package comment lines, as for every other pixi:* fact).

Excluded packages

With --exclude / --include / --exclude-kind the document lists only what survived the filter, and the root says so: a pixi:excluded metadata property (CycloneDX) or a pixi:excluded=... comment on the root package (SPDX 2.3 and 3.0.1) naming every package left out, whether it matched a pattern or was only needed by one that did.

Incomplete enrichment

A document built where the lookups failed otherwise looks exactly like one built where everything answered: licenses are simply absent, vulnerabilities[] is simply empty. The warnings go to stderr, which does not survive the upload, so what a run could not finish is recorded in the document as well.

Property Example value
pixi:incomplete osv, pypi-releases — the enrichment steps that did not complete
pixi:incomplete-detail osv: 14 of 14 purls unasked (offline, nothing cached); pypi-releases: 12 of 38 index lookups failed, separated by ;
pixi:stale-cache kev: 9 days old — data served past its lifetime because the fetch failed

The steps are named after what they do: wheel-licenses, conda-archives, pypi-releases, osv, scorecard, license-texts. OSV records three things separately, because an empty vulnerabilities[] has three causes and only one of them is good news: no package carried a purl the database answers to, the purls could not be asked about (offline with a cold cache), or an advisory record could not be fetched and is recorded by id alone.

In a run that writes several documents the lookups happen once for all of them, so the counts in pixi:incomplete-detail are the run's rather than that one document's, and the line says so: conda-archives: 2 of 2 archive reads failed (looked up once for every document in the run).

All three are absent when everything answered, so a complete document is unchanged and existing documents compare as they always did. In SPDX the same lines go in the root package's comment, one per line, beside pixi:excluded. Two documents that differ only because one run could not reach the network now say so.

Dependency graph

Each conda package's depends (matchspecs) and each PyPI package's requires_dist (PEP 508) are resolved by normalized package name against the packages present in the same environment and platform. The solver has already chosen one package per name, so name matching is exact and version constraints do not need re-evaluating. PyPI requirements may resolve to conda packages (pixi satisfies PyPI requirements from conda when it can). Virtual packages (__glibc, __osx, ...) and requirements not present in the environment (unused extras, other-platform markers) are dropped. Self-references are removed. Every PyPI package additionally depends on the environment's conda python package: wheels never declare the interpreter, but cannot run without it, and the edge keeps PyPI packages attached to the graph instead of floating as extra roots.

CycloneDX SPDX
Package → package dependencies[]: { ref, dependsOn[] } for every component DEPENDS_ON relationship per edge
Root → packages dependencies[0] (ref: root) lists what the workspace declared, plus the packages nothing else depends on; beside a lockfile that is not pixi.lock, with a manifest that declares something, only what it declared; for an installed environment that recorded it, what was requested by name SPDXRef-Package-root DEPENDS_ON ... for the same set

The declared half comes from the manifest next to the lockfile: the dependency tables of the environment's features (see pixi:direct), so a declared dependency that something else also needs — python is the usual example — is on the root where it belongs. The other half is the graph-root heuristic: packages nothing else depends on. Without a readable manifest (--prefix, a lockfile on its own) the heuristic is the whole answer, and nothing is marked direct.

Runtime, development and optional

When the input tells what the project needs to run apart from what it needs for development or an extra, each format says so in its own field. Everything reachable from the default dependencies is required, shared packages included. What only a dependency group reaches is for development, and what only one of the project's extras reaches is optional; a package both reach counts as optional.

CycloneDX SPDX 2.3 SPDX 3.0.1
Required scope: required DEPENDS_ON dependsOn
Development scope: optional <package> DEV_DEPENDENCY_OF <dependent> LifecycleScopedRelationship, dependsOn, scope: development
Optional scope: optional <package> OPTIONAL_DEPENDENCY_OF <dependent> dependsOn (3.0.1 has no optional scope)

The SPDX edges change only where the graph crosses from what is required into a group or extra, usually at the root: pytest DEV_DEPENDENCY_OF my-app. Inside a group the edges stay DEPENDS_ON (pytest depends on pluggy). pixi:declared-in still names the group or extra.

The scopes come from the manifest beside a lockfile other than pixi.lock, when it declares dependency groups or extras (see What a project without pixi declared), and from the categories of a conda-lock.yml when there are others than main (dev is development, any other optional). A pixi.lock environment is already one selection of features, so its documents carry no scope, and neither does an input with nothing to tell apart.

Vulnerabilities

With --vulnerabilities osv the CycloneDX document carries a vulnerabilities[] array (1.6 and 1.7 alike) and an SPDX 3.0.1 document carries the same findings through its security profile. SPDX 2.3 has nowhere to put them and says so on stderr, pointing at --spec-version 3.0.

One CycloneDX entry per finding, after records describing the same vulnerability have been merged:

Field Content
bom-ref vuln-<id>
id, source The OSV record id (GHSA-..., PYSEC-..., RUSTSEC-...) and { name: OSV, url: https://osv.dev/vulnerability/<id> }
references[] Every alias with where it is published: CVE-* → NVD, GHSA-* → GitHub Advisory Database, others → OSV
ratings[] The database's qualitative severity (method: other, source e.g. GitHub Advisory Database) and every CVSS vector the record carries (method: CVSSv31 / CVSSv3 / CVSSv4, with the v3 base score computed from the vector)
cwes[] CWE numbers from the record
description, detail The record's summary and details
recommendation Upgrade <package> to <version> for each affected package with a fixed version, the smallest fix above the installed version
advisories[] The record's reference URLs
published, updated The record's timestamps
affects[] { ref } for every affected component (bom-ref = the package's purl); a conda package matched through its PyPI purl is listed under its own bom-ref
analysis Only for findings accepted with --ignore-vuln: { state, justification, response, detail }, the CycloneDX impact-analysis (VEX) block. justification only when one was given after not_affected, response only when one was given. firstIssued / lastUpdated are never written (see the vulnerability gate).
properties[] With --kev, for known-exploited findings: pixi:kev=true, pixi:kev-cve, pixi:kev-date-added, pixi:kev-due-date, pixi:kev-ransomware; the rating from CISA KEV is critical and, without a fixed version, recommendation carries the catalog's required action

Entries are ordered by the worst rating, then id.

The VEX document

--vex <PATH> writes the assessments as a document of their own: a CycloneDX BOM with no components, the same vulnerabilities[] entries, and two differences from the ones inside the SBOM.

Field Content
serialNumber Its own, derived from the SBOM's, so the two are never confused
metadata.properties[] pixi:vex-for = the SBOM's serial number, besides the usual pixi:environment / pixi:platform
analysis On every finding: the --ignore-vuln state where one was given, else in_triage (or what --vex-open says)
affects[].ref A BOM-Link into the SBOM — urn:cdx:<the SBOM's serial number without the urn:uuid: prefix>/1#<bom-ref> — rather than a local reference

SPDX 3.0.1 security profile

SPDX 3 is a graph, so a finding is an element with assessments attached rather than a row in an array. A document that carries any finding adds security to its profileConformance; one that carries none does not claim the profile.

Element Content
security_Vulnerability One per finding. name is the advisory id, summary / description the record's text, and externalIdentifier[] holds the id and every alias — CVE-* as type cve, the rest as securityOther, so a reader searching for a CVE finds it whether or not we keyed the record under it. security_publishedTime / security_modifiedTime when the record has them.
Relationship hasAssociatedVulnerability One per affected package, from the package to the vulnerability: the direction a reader follows from a component.
security_CvssV2/V3/V4VulnAssessmentRelationship One per CVSS rating, from the vulnerability hasAssessmentFor the affected packages, with security_score, security_severity and security_vectorString. The schema requires all three together, so a rating missing any of them is left out rather than half-recorded — as is a rating whose method is not CVSS, since SPDX has no class for it.
security_ExploitCatalogVulnAssessmentRelationship With --kev, for a finding in CISA's catalog: security_catalogType: kev, security_exploited: true, and the catalog URL as security_locator. The added and due dates go in comment.
security_VexNotAffectedVulnAssessmentRelationship For a finding accepted with --ignore-vuln in the not_affected state, carrying your text as security_impactStatement and, where it has an SPDX equivalent, your justification as security_justificationType. The other states CycloneDX accepts have no SPDX class and stay CycloneDX-only, as do responses.

Timestamps are rewritten. SPDX 3 pins these fields to exactly YYYY-MM-DDThh:mm:ssZ — no fractional seconds, no numeric offset — while OSV records carry nanoseconds (2026-07-08T06:00:54.217433740Z). Values are converted to UTC second precision, and one that cannot be parsed is omitted rather than guessed at.

security_justificationType is set only from a justification given on --ignore-vuln, never guessed from the text, and only where one of SPDX's five values means the same thing:

--ignore-vuln justification security_justificationType
code_not_present vulnerableCodeNotPresent
code_not_reachable vulnerableCodeNotInExecutePath
protected_by_mitigating_control inlineMitigationsAlreadyExist
protected_at_runtime, protected_at_perimeter vulnerableCodeCannotBeControlledByAdversary
the others not set; the text stays in security_impactStatement

SPDX 3.0.1

--format spdx --spec-version 3.0 writes the SPDX 3.0.1 JSON-LD serialization: a @context of https://spdx.org/rdf/3.0.1/spdx-context.jsonld and a @graph of typed elements, each with an IRI spdxId under https://spdx.org/spdxdocs/pixi-sbom/<name>/<uuid>#... and a reference to one shared CreationInfo blank node.

Element Content
CreationInfo specVersion: 3.0.1, created, createdBy (a SoftwareAgent for pixi-sbom plus a Person per manifest author), createdUsing (the Tool), a comment naming the lockfile, environment and platform
SpdxDocument rootElement = the software_Sbom; dataLicense = a CC0-1.0 license element; profile conformance core, software, simpleLicensing
software_Sbom software_sbomType for the input's phase (see Lifecycle phase), rootElement = the workspace package, element = every package, relationship and license element
software_Package (workspace) software_primaryPurpose: application, name, version, homepage, repository as download location, software_sourceInfo
software_Package (each locked package) software_primaryPurpose: library, name, version, software_packageUrl, software_downloadLocation (or software_sourceInfo for local paths), software_homePage, summary, suppliedBy (an Organization per channel or index), verifiedUsing (Hash sha256 / md5), externalIdentifier (extra purls, repository URL), and the pixi:* properties as comment lines
simplelicensing_LicenseExpression / simplelicensing_SimpleLicensingText one element per distinct license expression, or per free-text license (carrying the license file text when fetched); packages point at them with hasDeclaredLicense relationships
Relationship dependsOn from each package to its dependencies and from the workspace package to the top level; hasDeclaredLicense from packages to license elements

The document is validated against the official 3.0.1 JSON schema in the tests and parses with the reference spdx-python-model deserializer. SPDX 2.3 remains the default because most consumers still read only 2.x.

Validation

tests/schemas/ holds the official JSON schemas (bom-1.6.schema.json and the spdx.schema.json / jsf-0.82.schema.json it references, plus SPDX's spdx-2.3.schema.json). The end-to-end tests validate every generated document against them, and the unit tests validate a hand-built model that exercises the edge cases (source packages, missing versions, non-SPDX licenses). Both documents also round-trip through syft convert.