Skip to content

Commit 7d3e6f6

Browse files
committed
fix: handle identity field as dict or array in ComponentEvidence deserialization
The identity field in ComponentEvidence can be either a single object (dict) in CycloneDX 1.5 or a single object/array of objects in CycloneDX 1.6. The previous implementation failed when deserializing a single object format, throwing 'str' object has no attribute 'items' error. Added _IdentitySetSerializationHelper to properly handle both formats: - json_normalize: serializes Identity objects as list while preserving view context - json_deserialize: handles both dict (single) and list (array) formats Also updated _ComponentEvidenceSerializationHelper.json_denormalize to normalize single dict format to array before deserialization for consistency. Fixes deserialization of CycloneDX 1.5 and 1.6 SBOMs with component evidence. Signed-off-by: Quentin Kaiser <quentin.kaiser@onekey.com>
1 parent 0f17348 commit 7d3e6f6

2 files changed

Lines changed: 160 additions & 0 deletions

File tree

cyclonedx/model/component_evidence.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,24 @@ def xml_denormalize(cls, o: 'XmlElement', *,
185185
return [BomRef(value=t.get('ref')) for t in o]
186186

187187

188+
class _IdentitySetSerializationHelper(serializable.helpers.BaseHelper):
189+
""" THIS CLASS IS NON-PUBLIC API """
190+
191+
@classmethod
192+
def json_normalize(cls, o: Iterable['Identity'], *,
193+
view: Optional[type[serializable.ViewType]],
194+
**__: Any) -> list[dict[str, Any]]:
195+
# Serialize identity as a list of dicts, preserving the view context
196+
return [json_loads(item.as_json(view)) for item in o] # type: ignore[attr-defined]
197+
198+
@classmethod
199+
def json_deserialize(cls, o: Union[dict[str, Any], list[dict[str, Any]]]) -> list['Identity']:
200+
# Handle identity field which can be a dict (CycloneDX 1.5) or list of dicts (CycloneDX 1.6)
201+
if isinstance(o, dict):
202+
return [Identity.from_json(o)] # type: ignore[attr-defined]
203+
return [Identity.from_json(item) for item in o] # type: ignore[attr-defined]
204+
205+
188206
@serializable.serializable_class(ignore_unknown_during_deserialization=True)
189207
class Identity:
190208
"""
@@ -654,6 +672,7 @@ def __init__(
654672
@property
655673
@serializable.view(SchemaVersion1Dot5)
656674
@serializable.view(SchemaVersion1Dot6)
675+
@serializable.type_mapping(_IdentitySetSerializationHelper)
657676
@serializable.xml_sequence(1)
658677
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'identity')
659678
def identity(self) -> 'SortedSet[Identity]':
@@ -768,6 +787,10 @@ def json_normalize(cls, o: ComponentEvidence, *,
768787

769788
@classmethod
770789
def json_denormalize(cls, o: dict[str, Any], **__: Any) -> Any:
790+
# Handle identity field which can be a dict (CycloneDX 1.5) or list of dicts (CycloneDX 1.6)
791+
# Before passing to ComponentEvidence.from_json, ensure it's always a list
792+
if 'identity' in o and isinstance(o['identity'], dict):
793+
o = {**o, 'identity': [o['identity']]}
771794
return ComponentEvidence.from_json(o) # type:ignore[attr-defined]
772795

773796
@classmethod

tests/test_model_component_evidence.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,143 @@ def test_not_same_1(self) -> None:
201201
self.assertNotEqual(hash(ce_1), hash(ce_2))
202202
self.assertFalse(ce_1 == ce_2)
203203

204+
def test_identity_deserialization_single_dict_format(self) -> None:
205+
"""Test deserialization of identity field as a single dict (CycloneDX 1.5 format)"""
206+
# This is the format that was failing before the fix
207+
json_data = {
208+
'identity': {
209+
'field': 'name',
210+
'confidence': 1.0,
211+
'concludedValue': 'test-component'
212+
}
213+
}
214+
ce = ComponentEvidence.from_json(json_data) # type: ignore[attr-defined]
215+
self.assertEqual(len(ce.identity), 1)
216+
identity = list(ce.identity)[0]
217+
self.assertEqual(identity.field, IdentityField.NAME)
218+
self.assertEqual(identity.confidence, Decimal('1.0'))
219+
self.assertEqual(identity.concluded_value, 'test-component')
220+
221+
def test_identity_deserialization_array_format(self) -> None:
222+
"""Test deserialization of identity field as an array (CycloneDX 1.6 format)"""
223+
json_data = {
224+
'identity': [
225+
{
226+
'field': 'name',
227+
'confidence': 1.0,
228+
'concludedValue': 'test-component'
229+
},
230+
{
231+
'field': 'version',
232+
'confidence': 0.8,
233+
'concludedValue': '1.0.0'
234+
}
235+
]
236+
}
237+
ce = ComponentEvidence.from_json(json_data) # type: ignore[attr-defined]
238+
self.assertEqual(len(ce.identity), 2)
239+
240+
# Check that both identities are present
241+
identities = sorted(ce.identity, key=lambda x: x.field.value)
242+
self.assertEqual(identities[0].field, IdentityField.NAME)
243+
self.assertEqual(identities[0].concluded_value, 'test-component')
244+
self.assertEqual(identities[1].field, IdentityField.VERSION)
245+
self.assertEqual(identities[1].concluded_value, '1.0.0')
246+
247+
def test_identity_dict_format_converts_to_array_internally(self) -> None:
248+
"""Test that single dict identity format is converted to array format internally"""
249+
# When deserializing a single dict, it should be normalized to array format
250+
# before being passed to ComponentEvidence
251+
json_data_dict = {
252+
'identity': {
253+
'field': 'name',
254+
'confidence': 1.0,
255+
'concludedValue': 'test-component'
256+
}
257+
}
258+
259+
json_data_array = {
260+
'identity': [
261+
{
262+
'field': 'name',
263+
'confidence': 1.0,
264+
'concludedValue': 'test-component'
265+
}
266+
]
267+
}
268+
269+
# Both formats should produce the same result
270+
ce_from_dict = ComponentEvidence.from_json(json_data_dict) # type: ignore[attr-defined]
271+
ce_from_array = ComponentEvidence.from_json(json_data_array) # type: ignore[attr-defined]
272+
273+
self.assertEqual(len(ce_from_dict.identity), 1)
274+
self.assertEqual(len(ce_from_array.identity), 1)
275+
276+
# The identity objects should be equivalent
277+
identity_dict = list(ce_from_dict.identity)[0]
278+
identity_array = list(ce_from_array.identity)[0]
279+
self.assertEqual(identity_dict.field, identity_array.field)
280+
self.assertEqual(identity_dict.confidence, identity_array.confidence)
281+
self.assertEqual(identity_dict.concluded_value, identity_array.concluded_value)
282+
283+
def test_identity_dict_with_multiple_methods(self) -> None:
284+
"""Test deserialization of single identity dict with multiple methods"""
285+
json_data = {
286+
'identity': {
287+
'field': 'purl',
288+
'confidence': 0.95,
289+
'concludedValue': 'pkg:npm/example@1.0.0',
290+
'methods': [
291+
{
292+
'technique': 'source-code-analysis',
293+
'confidence': 0.9,
294+
'value': 'Found in package.json'
295+
},
296+
{
297+
'technique': 'binary-analysis',
298+
'confidence': 0.85,
299+
'value': 'Found in binary metadata'
300+
}
301+
]
302+
}
303+
}
304+
ce = ComponentEvidence.from_json(json_data) # type: ignore[attr-defined]
305+
self.assertEqual(len(ce.identity), 1)
306+
identity = list(ce.identity)[0]
307+
self.assertEqual(identity.field, IdentityField.PURL)
308+
self.assertEqual(len(identity.methods), 2)
309+
310+
# Verify methods are properly deserialized
311+
methods = sorted(identity.methods, key=lambda m: m.technique.value)
312+
self.assertEqual(methods[0].technique, AnalysisTechnique.BINARY_ANALYSIS)
313+
self.assertEqual(methods[0].confidence, Decimal('0.85'))
314+
self.assertEqual(methods[1].technique, AnalysisTechnique.SOURCE_CODE_ANALYSIS)
315+
self.assertEqual(methods[1].confidence, Decimal('0.9'))
316+
317+
def test_identity_deserialization_dict_with_methods(self) -> None:
318+
"""Test deserialization of single identity dict with methods"""
319+
json_data = {
320+
'identity': {
321+
'field': 'name',
322+
'confidence': 0.95,
323+
'concludedValue': 'test-lib',
324+
'methods': [
325+
{
326+
'technique': 'source-code-analysis',
327+
'confidence': 0.9,
328+
'value': 'Found in metadata'
329+
}
330+
]
331+
}
332+
}
333+
ce = ComponentEvidence.from_json(json_data) # type: ignore[attr-defined]
334+
self.assertEqual(len(ce.identity), 1)
335+
identity = list(ce.identity)[0]
336+
self.assertEqual(len(identity.methods), 1)
337+
method = list(identity.methods)[0]
338+
self.assertEqual(method.technique, AnalysisTechnique.SOURCE_CODE_ANALYSIS)
339+
self.assertEqual(method.confidence, Decimal('0.9'))
340+
204341

205342
class TestModelCallStackFrame(TestCase):
206343

0 commit comments

Comments
 (0)