@@ -67,4 +67,165 @@ def testSpdxLite(self):
6767 self .assertEqual (len (checksum .get ("checksumValue" )), md5_length ) #Check checksum length value be 32
6868
6969
70- os .remove (spdx_lite_output ) #Removes tmp spdxlite.json file
70+ os .remove (spdx_lite_output ) #Removes tmp spdxlite.json file
71+
72+
73+ class SpdxLiteCpeTests (unittest .TestCase ):
74+ """
75+ Exercise CPE extraction and SPDX externalRefs emission.
76+ """
77+
78+ @staticmethod
79+ def _build_raw (vulnerabilities , purl = 'pkg:github/postgres/postgres' ):
80+ return {
81+ 'src/main.c' : [{
82+ 'id' : 'file' ,
83+ 'component' : 'postgresql' ,
84+ 'vendor' : 'postgresql' ,
85+ 'version' : '17.0' ,
86+ 'latest' : '17.0' ,
87+ 'url' : 'https://www.postgresql.org' ,
88+ 'url_hash' : 'abc123' ,
89+ 'download_url' : 'https://example.com/pg.tar.gz' ,
90+ 'purl' : [purl ],
91+ 'licenses' : [{'name' : 'PostgreSQL' , 'source' : 'component_declared' }],
92+ 'vulnerabilities' : vulnerabilities ,
93+ }]
94+ }
95+
96+ def _run (self , raw ):
97+ fd , out_path = tempfile .mkstemp (prefix = 'spdxlite_cpe_' , suffix = '.json' )
98+ os .close (fd ) # SpdxLite re-opens the path itself for writing
99+ try :
100+ spdx = SpdxLite (debug = False , output_file = out_path )
101+ spdx .produce_from_json (raw )
102+ with open (out_path , 'r' ) as f :
103+ return json .load (f )
104+ finally :
105+ if os .path .exists (out_path ):
106+ os .remove (out_path )
107+
108+ def _security_refs (self , doc ):
109+ refs = doc ['packages' ][0 ]['externalRefs' ]
110+ return [r for r in refs if r ['referenceCategory' ] == 'SECURITY' ]
111+
112+ def test_cpe23_emits_cpe23Type (self ):
113+ cpe = 'cpe:2.3:a:postgresql:postgresql:17.0:*:*:*:*:*:*:*'
114+ doc = self ._run (self ._build_raw ([{'ID' : cpe , 'source' : 'nvd' }]))
115+ refs = self ._security_refs (doc )
116+ self .assertEqual (len (refs ), 1 )
117+ self .assertEqual (refs [0 ]['referenceType' ], 'cpe23Type' )
118+ self .assertEqual (refs [0 ]['referenceLocator' ], cpe )
119+
120+ def test_legacy_cpe22_slash_emits_cpe22Type (self ):
121+ cpe = 'cpe:/a:postgresql:postgresql:17.0'
122+ doc = self ._run (self ._build_raw ([{'ID' : cpe , 'source' : 'nvd' }]))
123+ refs = self ._security_refs (doc )
124+ self .assertEqual (len (refs ), 1 )
125+ self .assertEqual (refs [0 ]['referenceType' ], 'cpe22Type' )
126+ self .assertEqual (refs [0 ]['referenceLocator' ], cpe )
127+
128+ def test_explicit_cpe22_prefix_emits_cpe22Type (self ):
129+ cpe = 'cpe:2.2:a:postgresql:postgresql:17.0'
130+ doc = self ._run (self ._build_raw ([{'ID' : cpe , 'source' : 'nvd' }]))
131+ refs = self ._security_refs (doc )
132+ self .assertEqual (len (refs ), 1 )
133+ self .assertEqual (refs [0 ]['referenceType' ], 'cpe22Type' )
134+
135+ def test_case_insensitive_prefix_detection (self ):
136+ cpe = 'CPE:2.3:a:postgresql:postgresql:17.0:*:*:*:*:*:*:*'
137+ doc = self ._run (self ._build_raw ([{'ID' : cpe , 'source' : 'nvd' }]))
138+ refs = self ._security_refs (doc )
139+ self .assertEqual (len (refs ), 1 )
140+ self .assertEqual (refs [0 ]['referenceType' ], 'cpe23Type' )
141+ self .assertEqual (refs [0 ]['referenceLocator' ], cpe ) # casing preserved in locator
142+
143+ def test_duplicate_cpes_are_deduplicated (self ):
144+ cpe = 'cpe:2.3:a:postgresql:postgresql:17.0:*:*:*:*:*:*:*'
145+ doc = self ._run (self ._build_raw ([
146+ {'ID' : cpe , 'source' : 'nvd' },
147+ {'ID' : cpe , 'source' : 'nvd' },
148+ {'ID' : cpe , 'source' : 'nvd' },
149+ ]))
150+ refs = self ._security_refs (doc )
151+ self .assertEqual (len (refs ), 1 )
152+
153+ def test_dedup_is_case_insensitive_and_preserves_first_locator (self ):
154+ lower = 'cpe:2.3:a:postgresql:postgresql:17.0:*:*:*:*:*:*:*'
155+ upper = 'CPE:2.3:A:POSTGRESQL:POSTGRESQL:17.0:*:*:*:*:*:*:*'
156+ doc = self ._run (self ._build_raw ([
157+ {'ID' : lower , 'source' : 'nvd' },
158+ {'ID' : upper , 'source' : 'nvd' },
159+ ]))
160+ refs = self ._security_refs (doc )
161+ self .assertEqual (len (refs ), 1 )
162+ self .assertEqual (refs [0 ]['referenceLocator' ], lower ) # first-seen wins
163+
164+ def test_cve_entries_are_ignored (self ):
165+ doc = self ._run (self ._build_raw ([
166+ {'ID' : 'CVE-2024-12345' , 'CVE' : 'CVE-2024-12345' ,
167+ 'source' : 'nvd' , 'severity' : 'high' },
168+ {'ID' : 'GHSA-xxxx-yyyy-zzzz' , 'source' : 'github' },
169+ ]))
170+ refs = self ._security_refs (doc )
171+ self .assertEqual (refs , [])
172+
173+ def test_mixed_cpe_versions_in_same_component (self ):
174+ cpe23 = 'cpe:2.3:a:postgresql:postgresql:17.0:*:*:*:*:*:*:*'
175+ cpe22 = 'cpe:/a:postgresql:postgresql:17.0'
176+ doc = self ._run (self ._build_raw ([
177+ {'ID' : cpe23 , 'source' : 'nvd' },
178+ {'ID' : cpe22 , 'source' : 'nvd' },
179+ ]))
180+ refs = self ._security_refs (doc )
181+ self .assertEqual (len (refs ), 2 )
182+ types = {r ['referenceType' ]: r ['referenceLocator' ] for r in refs }
183+ self .assertEqual (types ['cpe23Type' ], cpe23 )
184+ self .assertEqual (types ['cpe22Type' ], cpe22 )
185+
186+ def test_unknown_cpe_format_falls_back_to_cpe23Type (self ):
187+ odd_cpe = 'cpe:weird-format:postgresql:17.0'
188+ doc = self ._run (self ._build_raw ([{'ID' : odd_cpe , 'source' : 'nvd' }]))
189+ refs = self ._security_refs (doc )
190+ self .assertEqual (len (refs ), 1 )
191+ self .assertEqual (refs [0 ]['referenceType' ], 'cpe23Type' )
192+ self .assertEqual (refs [0 ]['referenceLocator' ], odd_cpe )
193+
194+ def test_no_vulnerabilities_field_produces_no_security_refs (self ):
195+ raw = self ._build_raw ([])
196+ # Drop the key entirely to simulate entries without a vulnerabilities block
197+ del raw ['src/main.c' ][0 ]['vulnerabilities' ]
198+ doc = self ._run (raw )
199+ self .assertEqual (self ._security_refs (doc ), [])
200+ # PURL externalRef must still be present
201+ refs = doc ['packages' ][0 ]['externalRefs' ]
202+ self .assertEqual (len (refs ), 1 )
203+ self .assertEqual (refs [0 ]['referenceType' ], 'purl' )
204+
205+ def test_empty_vulnerabilities_list_produces_no_security_refs (self ):
206+ doc = self ._run (self ._build_raw ([]))
207+ self .assertEqual (self ._security_refs (doc ), [])
208+
209+ def test_dependency_entries_do_not_emit_cpes (self ):
210+ raw = {
211+ 'package.json' : [{
212+ 'id' : 'dependency' ,
213+ 'dependencies' : [{
214+ 'purl' : 'pkg:npm/left-pad' ,
215+ 'component' : 'left-pad' ,
216+ 'version' : '1.3.0' ,
217+ 'url' : 'https://npmjs.com/package/left-pad' ,
218+ 'licenses' : [{'name' : 'MIT' , 'source' : 'component_declared' }],
219+ }]
220+ }]
221+ }
222+ doc = self ._run (raw )
223+ self .assertEqual (self ._security_refs (doc ), [])
224+
225+ def test_lowercase_id_key_is_also_supported (self ):
226+ cpe = 'cpe:2.3:a:postgresql:postgresql:17.0:*:*:*:*:*:*:*'
227+ # Raw scan output has been known to use 'id' (lowercase) occasionally
228+ doc = self ._run (self ._build_raw ([{'id' : cpe , 'source' : 'nvd' }]))
229+ refs = self ._security_refs (doc )
230+ self .assertEqual (len (refs ), 1 )
231+ self .assertEqual (refs [0 ]['referenceType' ], 'cpe23Type' )
0 commit comments