Description
JSFSignature embeds *JSFSigner with a json:"-" tag:
// cyclonedx.go
type JSFSignature struct {
*JSFSigner `json:"-" xml:"-"`
Signers *[]JSFSigner `json:"signers,omitempty" xml:"-"`
Chain *[]JSFSigner `json:"chain,omitempty" xml:"-"`
}
Because the embedded pointer is excluded from JSON, it is never populated during decoding. This has three consequences:
- A single inline signature is discarded.
{"signature": {"algorithm": "RS512", "value": "..."}} — the form used in the specification's own example — decodes to a non-nil Signature whose fields are all empty.
- A round trip destroys it. Decoding and re-encoding a BOM with an inline signature produces output with no signature at all, so any tool that reads and rewrites a signed BOM silently strips the signature.
- Reading
Signature.Algorithm panics for every signature form. The promoted field resolves through the nil embedded pointer. This is not limited to the inline case: signers and chain documents also leave the embedded struct nil, so the panic happens there too.
Reproducer
package main
import (
"bytes"
"fmt"
cdx "github.com/CycloneDX/cyclonedx-go"
)
func main() {
const doc = `{"bomFormat":"CycloneDX","specVersion":"1.6","version":1,` +
`"signature":{"algorithm":"RS512","value":"abc"}}`
var bom cdx.BOM
if err := cdx.NewBOMDecoder(bytes.NewReader([]byte(doc)), cdx.BOMFileFormatJSON).Decode(&bom); err != nil {
panic(err)
}
fmt.Println("Signature != nil :", bom.Signature != nil) // true
fmt.Println("JSFSigner != nil :", bom.Signature.JSFSigner != nil) // false — data was dropped
// Round trip loses the signature entirely:
var out bytes.Buffer
_ = cdx.NewBOMEncoder(&out, cdx.BOMFileFormatJSON).Encode(&bom)
fmt.Println("round trip keeps algorithm:", bytes.Contains(out.Bytes(), []byte("algorithm"))) // false
fmt.Println(bom.Signature.Algorithm) // panic: nil pointer dereference
}
Output:
Signature != nil : true
JSFSigner != nil : false
round trip keeps algorithm: false
panic: runtime error: invalid memory address or nil pointer dereference
Substituting "signature":{"signers":[{"algorithm":"RS512","value":"abc"}]} keeps the data in Signers, but the final line still panics.
Impact
Consumers cannot read a signature through the public API without either crashing or bypassing the library. Two projects have independently worked around it by parsing the raw JSON — sbomqs carries the comment "Since cyclonedx-go doesn't properly unmarshal signatures yet, we need to parse it manually from the raw JSON", and I did the same in my own tool before tracing it here.
Suggested fix
JSF allows a block to be either a single signer or a set (signers / chain), so a custom UnmarshalJSON on JSFSignature seems like the smallest change that keeps the current field layout: decode into the embedded JSFSigner when algorithm is present at the top level, and into Signers/Chain otherwise. A matching MarshalJSON would fix the round trip.
If keeping the embedded pointer is awkward, an alternative is to make the single-signer fields explicit on JSFSignature rather than promoted, which also removes the nil-dereference footgun.
Happy to open a PR if the maintainers prefer one of these shapes.
Environment
cyclonedx-go v0.11.0 (latest release) with go1.26.5. The struct definition on master (cyclonedx.go:909, checked 2026-08-07) is identical, so this is not fixed in an unreleased commit.
Related
#17 tracks implementing JSF signing/verification as a feature. This report is about the existing struct being unusable for reading a signature that is already there — I believe they are separate, but happy to fold this in if you see it as part of the same work.
Description
JSFSignatureembeds*JSFSignerwith ajson:"-"tag:Because the embedded pointer is excluded from JSON, it is never populated during decoding. This has three consequences:
{"signature": {"algorithm": "RS512", "value": "..."}}— the form used in the specification's own example — decodes to a non-nilSignaturewhose fields are all empty.Signature.Algorithmpanics for every signature form. The promoted field resolves through the nil embedded pointer. This is not limited to the inline case:signersandchaindocuments also leave the embedded struct nil, so the panic happens there too.Reproducer
Output:
Substituting
"signature":{"signers":[{"algorithm":"RS512","value":"abc"}]}keeps the data inSigners, but the final line still panics.Impact
Consumers cannot read a signature through the public API without either crashing or bypassing the library. Two projects have independently worked around it by parsing the raw JSON — sbomqs carries the comment "Since cyclonedx-go doesn't properly unmarshal signatures yet, we need to parse it manually from the raw JSON", and I did the same in my own tool before tracing it here.
Suggested fix
JSF allows a block to be either a single signer or a set (
signers/chain), so a customUnmarshalJSONonJSFSignatureseems like the smallest change that keeps the current field layout: decode into the embeddedJSFSignerwhenalgorithmis present at the top level, and intoSigners/Chainotherwise. A matchingMarshalJSONwould fix the round trip.If keeping the embedded pointer is awkward, an alternative is to make the single-signer fields explicit on
JSFSignaturerather than promoted, which also removes the nil-dereference footgun.Happy to open a PR if the maintainers prefer one of these shapes.
Environment
cyclonedx-go v0.11.0(latest release) with go1.26.5. The struct definition onmaster(cyclonedx.go:909, checked 2026-08-07) is identical, so this is not fixed in an unreleased commit.Related
#17 tracks implementing JSF signing/verification as a feature. This report is about the existing struct being unusable for reading a signature that is already there — I believe they are separate, but happy to fold this in if you see it as part of the same work.