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 906de27..2680ad7 100644 --- a/src/models/scoring.py +++ b/src/models/scoring.py @@ -34,6 +34,15 @@ class ValidationSeverity(Enum): SCORING_WEIGHTS = {} +# 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). @@ -58,7 +67,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()