#!/usr/bin/env python3 # # Copyright (C) 2023 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Serialize objects defined in package sbom_data to SPDX format: tagvalue, JSON. """ import json import sbom_data SPDX_VER = 'SPDX-2.3' DATA_LIC = 'CC0-1.0' class Tags: # Common SPDXID = 'SPDXID' SPDX_VERSION = 'SPDXVersion' DATA_LICENSE = 'DataLicense' DOCUMENT_NAME = 'DocumentName' DOCUMENT_NAMESPACE = 'DocumentNamespace' CREATED = 'Created' CREATOR = 'Creator' EXTERNAL_DOCUMENT_REF = 'ExternalDocumentRef' # Package PACKAGE_NAME = 'PackageName' PACKAGE_DOWNLOAD_LOCATION = 'PackageDownloadLocation' PACKAGE_VERSION = 'PackageVersion' PACKAGE_SUPPLIER = 'PackageSupplier' FILES_ANALYZED = 'FilesAnalyzed' PACKAGE_VERIFICATION_CODE = 'PackageVerificationCode' PACKAGE_EXTERNAL_REF = 'ExternalRef' # Package license PACKAGE_LICENSE_CONCLUDED = 'PackageLicenseConcluded' PACKAGE_LICENSE_INFO_FROM_FILES = 'PackageLicenseInfoFromFiles' PACKAGE_LICENSE_DECLARED = 'PackageLicenseDeclared' PACKAGE_LICENSE_COMMENTS = 'PackageLicenseComments' # File FILE_NAME = 'FileName' FILE_CHECKSUM = 'FileChecksum' # File license FILE_LICENSE_CONCLUDED = 'LicenseConcluded' FILE_LICENSE_INFO_IN_FILE = 'LicenseInfoInFile' FILE_LICENSE_COMMENTS = 'LicenseComments' FILE_COPYRIGHT_TEXT = 'FileCopyrightText' FILE_NOTICE = 'FileNotice' FILE_ATTRIBUTION_TEXT = 'FileAttributionText' # Relationship RELATIONSHIP = 'Relationship' class TagValueWriter: @staticmethod def marshal_doc_headers(sbom_doc): headers = [ f'{Tags.SPDX_VERSION}: {SPDX_VER}', f'{Tags.DATA_LICENSE}: {DATA_LIC}', f'{Tags.SPDXID}: {sbom_doc.id}', f'{Tags.DOCUMENT_NAME}: {sbom_doc.name}', f'{Tags.DOCUMENT_NAMESPACE}: {sbom_doc.namespace}', ] for creator in sbom_doc.creators: headers.append(f'{Tags.CREATOR}: {creator}') headers.append(f'{Tags.CREATED}: {sbom_doc.created}') for doc_ref in sbom_doc.external_refs: headers.append( f'{Tags.EXTERNAL_DOCUMENT_REF}: {doc_ref.id} {doc_ref.uri} {doc_ref.checksum}') headers.append('') return headers @staticmethod def marshal_package(sbom_doc, package, fragment): download_location = sbom_data.VALUE_NOASSERTION if package.download_location: download_location = package.download_location tagvalues = [ f'{Tags.PACKAGE_NAME}: {package.name}', f'{Tags.SPDXID}: {package.id}', f'{Tags.PACKAGE_DOWNLOAD_LOCATION}: {download_location}', f'{Tags.FILES_ANALYZED}: {str(package.files_analyzed).lower()}', ] if package.version: tagvalues.append(f'{Tags.PACKAGE_VERSION}: {package.version}') if package.supplier: tagvalues.append(f'{Tags.PACKAGE_SUPPLIER}: {package.supplier}') if package.verification_code: tagvalues.append(f'{Tags.PACKAGE_VERIFICATION_CODE}: {package.verification_code}') if package.external_refs: for external_ref in package.external_refs: tagvalues.append( f'{Tags.PACKAGE_EXTERNAL_REF}: {external_ref.category} {external_ref.type} {external_ref.locator}') tagvalues.append('') if package.id == sbom_doc.describes and not fragment: tagvalues.append( f'{Tags.RELATIONSHIP}: {sbom_doc.id} {sbom_data.RelationshipType.DESCRIBES} {sbom_doc.describes}') tagvalues.append('') for file in sbom_doc.files: if file.id in package.file_ids: tagvalues += TagValueWriter.marshal_file(file) return tagvalues @staticmethod def marshal_packages(sbom_doc, fragment): tagvalues = [] marshaled_relationships = [] i = 0 packages = sbom_doc.packages while i < len(packages): if (i + 1 < len(packages) and packages[i].id.startswith('SPDXRef-SOURCE-') and packages[i + 1].id.startswith('SPDXRef-UPSTREAM-')): # Output SOURCE, UPSTREAM packages and their VARIANT_OF relationship together, so they are close to each other # in SBOMs in tagvalue format. tagvalues += TagValueWriter.marshal_package(sbom_doc, packages[i], fragment) tagvalues += TagValueWriter.marshal_package(sbom_doc, packages[i + 1], fragment) rel = next((r for r in sbom_doc.relationships if r.id1 == packages[i].id and r.id2 == packages[i + 1].id and r.relationship == sbom_data.RelationshipType.VARIANT_OF), None) if rel: marshaled_relationships.append(rel) tagvalues.append(TagValueWriter.marshal_relationship(rel)) tagvalues.append('') i += 2 else: tagvalues += TagValueWriter.marshal_package(sbom_doc, packages[i], fragment) i += 1 return tagvalues, marshaled_relationships @staticmethod def marshal_file(file): tagvalues = [ f'{Tags.FILE_NAME}: {file.name}', f'{Tags.SPDXID}: {file.id}', f'{Tags.FILE_CHECKSUM}: {file.checksum}', '', ] return tagvalues @staticmethod def marshal_files(sbom_doc, fragment): tagvalues = [] files_in_packages = [] for package in sbom_doc.packages: files_in_packages += package.file_ids for file in sbom_doc.files: if file.id in files_in_packages: continue tagvalues += TagValueWriter.marshal_file(file) if file.id == sbom_doc.describes and not fragment: # Fragment is not a full SBOM document so the relationship DESCRIBES is not applicable. tagvalues.append( f'{Tags.RELATIONSHIP}: {sbom_doc.id} {sbom_data.RelationshipType.DESCRIBES} {sbom_doc.describes}') tagvalues.append('') return tagvalues @staticmethod def marshal_relationship(rel): return f'{Tags.RELATIONSHIP}: {rel.id1} {rel.relationship} {rel.id2}' @staticmethod def marshal_relationships(sbom_doc, marshaled_rels): tagvalues = [] sorted_rels = sorted(sbom_doc.relationships, key=lambda r: r.id2 + r.id1) for rel in sorted_rels: if any(r.id1 == rel.id1 and r.id2 == rel.id2 and r.relationship == rel.relationship for r in marshaled_rels): continue tagvalues.append(TagValueWriter.marshal_relationship(rel)) tagvalues.append('') return tagvalues @staticmethod def write(sbom_doc, file, fragment=False): content = [] if not fragment: content += TagValueWriter.marshal_doc_headers(sbom_doc) content += TagValueWriter.marshal_files(sbom_doc, fragment) tagvalues, marshaled_relationships = TagValueWriter.marshal_packages(sbom_doc, fragment) content += tagvalues content += TagValueWriter.marshal_relationships(sbom_doc, marshaled_relationships) file.write('\n'.join(content)) class PropNames: # Common SPDXID = 'SPDXID' SPDX_VERSION = 'spdxVersion' DATA_LICENSE = 'dataLicense' NAME = 'name' DOCUMENT_NAMESPACE = 'documentNamespace' CREATION_INFO = 'creationInfo' CREATORS = 'creators' CREATED = 'created' EXTERNAL_DOCUMENT_REF = 'externalDocumentRefs' DOCUMENT_DESCRIBES = 'documentDescribes' EXTERNAL_DOCUMENT_ID = 'externalDocumentId' EXTERNAL_DOCUMENT_URI = 'spdxDocument' EXTERNAL_DOCUMENT_CHECKSUM = 'checksum' ALGORITHM = 'algorithm' CHECKSUM_VALUE = 'checksumValue' # Package PACKAGES = 'packages' PACKAGE_DOWNLOAD_LOCATION = 'downloadLocation' PACKAGE_VERSION = 'versionInfo' PACKAGE_SUPPLIER = 'supplier' FILES_ANALYZED = 'filesAnalyzed' PACKAGE_VERIFICATION_CODE = 'packageVerificationCode' PACKAGE_VERIFICATION_CODE_VALUE = 'packageVerificationCodeValue' PACKAGE_EXTERNAL_REFS = 'externalRefs' PACKAGE_EXTERNAL_REF_CATEGORY = 'referenceCategory' PACKAGE_EXTERNAL_REF_TYPE = 'referenceType' PACKAGE_EXTERNAL_REF_LOCATOR = 'referenceLocator' PACKAGE_HAS_FILES = 'hasFiles' # File FILES = 'files' FILE_NAME = 'fileName' FILE_CHECKSUMS = 'checksums' # Relationship RELATIONSHIPS = 'relationships' REL_ELEMENT_ID = 'spdxElementId' REL_RELATED_ELEMENT_ID = 'relatedSpdxElement' REL_TYPE = 'relationshipType' class JSONWriter: @staticmethod def marshal_doc_headers(sbom_doc): headers = { PropNames.SPDX_VERSION: SPDX_VER, PropNames.DATA_LICENSE: DATA_LIC, PropNames.SPDXID: sbom_doc.id, PropNames.NAME: sbom_doc.name, PropNames.DOCUMENT_NAMESPACE: sbom_doc.namespace, PropNames.CREATION_INFO: {} } creators = [creator for creator in sbom_doc.creators] headers[PropNames.CREATION_INFO][PropNames.CREATORS] = creators headers[PropNames.CREATION_INFO][PropNames.CREATED] = sbom_doc.created external_refs = [] for doc_ref in sbom_doc.external_refs: checksum = doc_ref.checksum.split(': ') external_refs.append({ PropNames.EXTERNAL_DOCUMENT_ID: f'{doc_ref.id}', PropNames.EXTERNAL_DOCUMENT_URI: doc_ref.uri, PropNames.EXTERNAL_DOCUMENT_CHECKSUM: { PropNames.ALGORITHM: checksum[0], PropNames.CHECKSUM_VALUE: checksum[1] } }) if external_refs: headers[PropNames.EXTERNAL_DOCUMENT_REF] = external_refs headers[PropNames.DOCUMENT_DESCRIBES] = [sbom_doc.describes] return headers @staticmethod def marshal_packages(sbom_doc): packages = [] for p in sbom_doc.packages: package = { PropNames.NAME: p.name, PropNames.SPDXID: p.id, PropNames.PACKAGE_DOWNLOAD_LOCATION: p.download_location if p.download_location else sbom_data.VALUE_NOASSERTION, PropNames.FILES_ANALYZED: p.files_analyzed } if p.version: package[PropNames.PACKAGE_VERSION] = p.version if p.supplier: package[PropNames.PACKAGE_SUPPLIER] = p.supplier if p.verification_code: package[PropNames.PACKAGE_VERIFICATION_CODE] = { PropNames.PACKAGE_VERIFICATION_CODE_VALUE: p.verification_code } if p.external_refs: package[PropNames.PACKAGE_EXTERNAL_REFS] = [] for ref in p.external_refs: ext_ref = { PropNames.PACKAGE_EXTERNAL_REF_CATEGORY: ref.category, PropNames.PACKAGE_EXTERNAL_REF_TYPE: ref.type, PropNames.PACKAGE_EXTERNAL_REF_LOCATOR: ref.locator, } package[PropNames.PACKAGE_EXTERNAL_REFS].append(ext_ref) if p.file_ids: package[PropNames.PACKAGE_HAS_FILES] = [] for file_id in p.file_ids: package[PropNames.PACKAGE_HAS_FILES].append(file_id) packages.append(package) return {PropNames.PACKAGES: packages} @staticmethod def marshal_files(sbom_doc): files = [] for f in sbom_doc.files: file = { PropNames.FILE_NAME: f.name, PropNames.SPDXID: f.id } checksum = f.checksum.split(': ') file[PropNames.FILE_CHECKSUMS] = [{ PropNames.ALGORITHM: checksum[0], PropNames.CHECKSUM_VALUE: checksum[1], }] files.append(file) return {PropNames.FILES: files} @staticmethod def marshal_relationships(sbom_doc): relationships = [] sorted_rels = sorted(sbom_doc.relationships, key=lambda r: r.relationship + r.id2 + r.id1) for r in sorted_rels: rel = { PropNames.REL_ELEMENT_ID: r.id1, PropNames.REL_RELATED_ELEMENT_ID: r.id2, PropNames.REL_TYPE: r.relationship, } relationships.append(rel) return {PropNames.RELATIONSHIPS: relationships} @staticmethod def write(sbom_doc, file): doc = {} doc.update(JSONWriter.marshal_doc_headers(sbom_doc)) doc.update(JSONWriter.marshal_packages(sbom_doc)) doc.update(JSONWriter.marshal_files(sbom_doc)) doc.update(JSONWriter.marshal_relationships(sbom_doc)) file.write(json.dumps(doc, indent=4))