@@ -275,14 +275,17 @@ suite('testing the python-pyproject data provider', () => {
275275 /** Verifies stack and component SBOM output matches expected pip fixtures. */
276276 SBOM_CASES . forEach ( ( { type, method, fixture} ) => {
277277 test ( `verify pyproject.toml sbom provided for ${ type } analysis with pip` , async ( ) => {
278- let expectedSbom = fs . readFileSync ( path . join ( pipFixtureDir , fixture ) ) . toString ( ) . trim ( )
279- expectedSbom = JSON . stringify ( JSON . parse ( expectedSbom ) )
278+ let expectedSbom = JSON . parse ( fs . readFileSync ( path . join ( pipFixtureDir , fixture ) ) . toString ( ) . trim ( ) )
280279 let result = await pipProvider [ method ] ( path . join ( pipFixtureDir , 'pyproject.toml' ) )
281- expect ( result ) . to . deep . equal ( {
282- ecosystem : 'pip' ,
283- contentType : 'application/vnd.cyclonedx+json' ,
284- content : expectedSbom
285- } )
280+ let actualSbom = JSON . parse ( result . content )
281+ // Strip hashes before comparison — they are platform-dependent
282+ // (e.g. charset-normalizer resolves to different wheels per platform).
283+ // Hash correctness is verified by the dedicated SHA-256 tests below.
284+ for ( let c of expectedSbom . components ) { delete c . hashes }
285+ for ( let c of actualSbom . components ) { delete c . hashes }
286+ expect ( result . ecosystem ) . to . equal ( 'pip' )
287+ expect ( result . contentType ) . to . equal ( 'application/vnd.cyclonedx+json' )
288+ expect ( actualSbom ) . to . deep . equal ( expectedSbom )
286289 } ) . timeout ( TIMEOUT )
287290 } )
288291
@@ -328,6 +331,49 @@ suite('testing the python-pyproject data provider', () => {
328331 expect ( pkg ) . to . exist
329332 expect ( pkg . version ) . to . equal ( '3.4.7' )
330333 } ) . timeout ( TIMEOUT )
334+
335+ /** Verifies SHA-256 hashes from pip report appear in SBOM components. */
336+ test ( 'SBOM components include SHA-256 hashes from pip report' , async ( ) => {
337+ let result = await pipProvider . provideStack ( path . join ( pipFixtureDir , 'pyproject.toml' ) )
338+ let sbom = JSON . parse ( result . content )
339+ for ( let component of sbom . components ) {
340+ expect ( component . hashes ) . to . be . an ( 'array' ) . with . lengthOf ( 1 )
341+ expect ( component . hashes [ 0 ] . alg ) . to . equal ( 'SHA-256' )
342+ expect ( component . hashes [ 0 ] . content ) . to . be . a ( 'string' ) . with . lengthOf ( 64 )
343+ }
344+ } ) . timeout ( TIMEOUT )
345+
346+ /** Verifies graceful handling when pip report lacks hash data. */
347+ test ( '_parsePipReport handles missing hash data gracefully' , ( ) => {
348+ // Given a pip report with packages that have no download_info or hashes
349+ let report = JSON . stringify ( {
350+ install : [
351+ {
352+ download_info : { dir_info : { } } ,
353+ metadata : { name : 'root-project' , version : '1.0.0' , requires_dist : [ 'dep-a' ] }
354+ } ,
355+ {
356+ metadata : { name : 'dep-a' , version : '2.0.0' }
357+ } ,
358+ {
359+ download_info : { archive_info : { } } ,
360+ metadata : { name : 'dep-b' , version : '3.0.0' }
361+ } ,
362+ {
363+ download_info : { archive_info : { hashes : { } } } ,
364+ metadata : { name : 'dep-c' , version : '4.0.0' }
365+ }
366+ ]
367+ } )
368+
369+ // When parsing the report
370+ let { graph } = pipProvider . _parsePipReport ( report )
371+
372+ // Then packages without SHA-256 should have no hashes
373+ expect ( graph . get ( 'dep-a' ) . hashes ) . to . be . undefined
374+ expect ( graph . get ( 'dep-b' ) . hashes ) . to . be . undefined
375+ expect ( graph . get ( 'dep-c' ) . hashes ) . to . be . undefined
376+ } )
331377 } )
332378
333379 /** Verifies uv and poetry validateLockFile returns false when no lock file is present. */
0 commit comments