From b4ec89b7bbf053453c1c692252981991a23f83a8 Mon Sep 17 00:00:00 2001 From: Rul1an Date: Wed, 17 Jun 2026 09:23:32 +0200 Subject: [PATCH] fix(scoring): detect externalReferences under components[0] (#76) external_references always scored as missing. Two independent causes: the field_registry jsonpath used the singular `$.component.externalReferences` while CycloneDX 1.6/1.7 place the array under `$.components[0]` (plural), and the fallback presence check compared the snake_case registry name `external_references` against the camelCase BOM key `externalReferences`, so neither path matched a populated field. - field_registry.json: correct the jsonpath to $.components[0].externalReferences - scoring.py: map registry field names to their CycloneDX keys in the fallback (defense-in-depth; also covers component_version -> version) - tests: detection under components[0] plus a negative guard A fully populated component_basic category now scores 20/20 again (previously capped at ~17.1/20). Thanks to the reporter for the precise root-cause analysis. Signed-off-by: Rul1an --- src/models/field_registry.json | 2 +- src/models/scoring.py | 11 +++++++++- tests/test_scoring.py | 39 +++++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/models/field_registry.json b/src/models/field_registry.json index 0300a8a..2ebf9ed 100644 --- a/src/models/field_registry.json +++ b/src/models/field_registry.json @@ -512,7 +512,7 @@ "weight": 1.0, "category": "component_basic", "description": "Additional external references", - "jsonpath": "$.component.externalReferences", + "jsonpath": "$.components[0].externalReferences", "aibom_generation": { "location": "$.component.externalReferences", "rule": "include_if_available", diff --git a/src/models/scoring.py b/src/models/scoring.py index 64516de..22fd26f 100644 --- a/src/models/scoring.py +++ b/src/models/scoring.py @@ -70,6 +70,15 @@ def validate_spdx(license_entry): return all(lic in SPDX_LICENSES for lic in license_entry) return license_entry in SPDX_LICENSES +# CycloneDX component keys are camelCase, while some registry field names are +# snake_case. Map the registry name to the BOM key so the fallback presence check +# does not score a populated field as missing (see #76). +_COMPONENT_KEY_ALIASES = { + "external_references": "externalReferences", + "component_version": "version", +} + + def check_field_in_aibom(aibom: Dict[str, Any], field: str) -> bool: """ Check if a field is present in the AIBOM (Legacy/Standard Layout check). @@ -94,7 +103,7 @@ def check_field_in_aibom(aibom: Dict[str, Any], field: str) -> bool: components = aibom.get("components", []) if components: component = components[0] - if field in component: + if field in component or _COMPONENT_KEY_ALIASES.get(field) in component: return True # Component Properties diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 944970c..198ef8e 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -1,5 +1,10 @@ import unittest -from src.models.scoring import calculate_completeness_score, ValidationSeverity +from src.models.scoring import ( + calculate_completeness_score, + check_field_in_aibom, + check_field_with_enhanced_results, + ValidationSeverity, +) class TestScoring(unittest.TestCase): def test_basic_completeness(self): @@ -38,5 +43,37 @@ def test_registry_fallback(self): score = calculate_completeness_score(aibom) self.assertIsNotNone(score) + def test_external_references_detected_under_components_array(self): + # Regression for #76: externalReferences lives under components[0] + # (plural, camelCase) in CycloneDX 1.6/1.7, so a populated field must be + # detected as present rather than scored as missing. + aibom = { + "bomFormat": "CycloneDX", + "components": [ + { + "name": "test-model", + "type": "machine-learning-model", + "externalReferences": [ + {"type": "website", "url": "https://example.com"}, + {"type": "vcs", "url": "https://github.com/example/model"}, + ], + } + ], + } + # registry jsonpath detection ($.components[0].externalReferences) + self.assertTrue(check_field_with_enhanced_results(aibom, "external_references")) + # fallback presence check resolves the snake_case -> camelCase alias + self.assertTrue(check_field_in_aibom(aibom, "external_references")) + + def test_external_references_absent_is_not_reported_present(self): + # Negative guard: with no externalReferences, detection stays False so + # the fix cannot regress into always-present. + aibom = { + "bomFormat": "CycloneDX", + "components": [{"name": "m", "type": "machine-learning-model"}], + } + self.assertFalse(check_field_with_enhanced_results(aibom, "external_references")) + self.assertFalse(check_field_in_aibom(aibom, "external_references")) + if __name__ == '__main__': unittest.main()