10 SPDX 3.0 Report class.
20 from datetime
import datetime, timezone
23 from rdflib
import BNode, Literal, Namespace, RDF, RDFS, URIRef
24 from rdflib.util
import SUFFIX_FORMAT_MAP
25 from semantic_version
import Version
27 from spdx_tools.spdx3.model
import (
32 ProfileIdentifierType,
38 from spdx_tools.spdx3.model.software
import (
43 from license_expression
import get_spdx_licensing
44 from spdx_tools.spdx3.model.licensing
import (
46 ConjunctiveLicenseSet,
48 DisjunctiveLicenseSet,
51 from spdx_tools.spdx3.payload
import Payload
52 from spdx_tools.spdx3.writer.json_ld
import json_ld_writer
as _json_ld_writer_mod
53 from spdx_tools.spdx3.writer.json_ld.json_ld_converter
import (
54 convert_payload_to_json_ld_list_of_elements,
57 from .CliOptions
import CliOptions
58 from .ReportBase
import ReportBase
59 from .Scanners
import Scanners, ScanResultList
61 SPDX3_SPEC_VERSION = Version(
"3.0.1")
62 TOOL_NAME =
"FOSSology CI Scanner"
67 def __init__(self, license_expression: str):
73 Handle SPDX 3.0 reports.
75 :ivar cli_options: CliOptions object
76 :ivar scanner: Scanners object
77 :ivar payload: spdx_tools Payload holding all SPDX 3.0 elements
78 :ivar root_package: Root Package element for the scanned project
79 :ivar creation_info: Shared CreationInfo for all elements
82 def __init__(self, cli_options: CliOptions, scanner: Scanners):
84 Initialise the SPDX 3.0 report builder.
86 :param cli_options: CliOptions to use
87 :param scanner: Scanners to use
95 self._license_cache: dict[str, object] = {}
98 parent_package = self.
scannerscannerscanner.get_scan_packages().parent_package
99 project_name = (parent_package.get(
'name')
or '').strip()
102 project_name = getattr(
108 org_name = (parent_package.get(
'author')
or 'Unknown').strip()
or 'Unknown'
115 f
"{self._base}/Agent/"
116 f
"{re.sub(r'[^a-zA-Z0-9]', '', org_name)}"
118 tool_id = f
"{self._base}/Tool/fossology-scanner"
121 spec_version=SPDX3_SPEC_VERSION,
122 created=datetime.now(timezone.utc),
124 created_using=[tool_id],
126 ProfileIdentifierType.CORE,
127 ProfileIdentifierType.SOFTWARE,
128 ProfileIdentifierType.LICENSING,
133 org_elem = Organization(
143 self.
payloadpayload.add_element(org_elem)
144 self.
payloadpayload.add_element(tool_elem)
147 safe_pkg = re.sub(
r"[^a-zA-Z0-9._-]",
"-", project_name)
148 root_pkg_id = f
"{self._base}/Package/{safe_pkg}"
153 primary_purpose=SoftwarePurpose.APPLICATION,
163 Process all scan results (parent + dependencies), create File elements
164 and Relationships, then build the SpdxDocument.
167 parent = self.
scannerscannerscanner.get_scan_packages().parent_package
171 for _purl, component
in (
172 self.
scannerscannerscanner.get_scan_packages().dependencies.items()
178 dep_rel = Relationship(
179 spdx_id=f
"{self._base}/Relationship/{self._rel_idx}",
181 relationship_type=RelationshipType.DEPENDS_ON,
182 to=[dep_pkg.spdx_id],
185 self.
payloadpayload.add_element(dep_rel)
189 all_ids = [e.spdx_id
for e
in self.
payloadpayload.get_full_map().values()]
191 describes_rel_id = f
"{self._base}/Relationship/{self._rel_idx}"
192 all_ids.append(describes_rel_id)
194 doc_id = f
"{self._base}/Document"
195 spdx_doc = SpdxDocument(
202 self.
payloadpayload.add_element(spdx_doc)
204 describes_rel = Relationship(
205 spdx_id=describes_rel_id,
207 relationship_type=RelationshipType.DESCRIBES,
211 self.
payloadpayload.add_element(describes_rel)
214 logging.info(
"SPDX 3.0 document finalized with %d elements.",
215 len(self.
payloadpayload.get_full_map()))
219 os.makedirs(os.path.dirname(os.path.abspath(file_name)), exist_ok=
True)
222 element_list = convert_payload_to_json_ld_list_of_elements(self.
payloadpayload)
223 context_path = os.path.join(
224 os.path.dirname(_json_ld_writer_mod.__file__),
"context.json",
226 with open(context_path,
"r", encoding=
"utf-8")
as ctx_file:
227 context = json.load(ctx_file)
232 ctx_inner = context
if "@context" not in context
else context[
"@context"]
233 ctx_inner[
"LicenseExpression"] =
"licensing:LicenseExpression"
234 ctx_inner[
"licenseExpression"] = {
235 "@id":
"licensing:licenseExpression",
236 "@type":
"xsd:string"
239 json_ld_dict = {
"@context": context,
"@graph": element_list}
240 json_ld_str = json.dumps(json_ld_dict)
243 g.parse(data=json_ld_str, format=
"json-ld")
252 if out_format
in (
"xml",
"pretty-xml"):
255 g.serialize(destination=file_name, format=out_format)
256 logging.info(
"SPDX 3.0 %s written to: %s (%d bytes)",
257 out_format.upper(), file_name, os.path.getsize(file_name))
269 Load the SPDX SHACL model bundled with spdx-tools.
271 :return: (rdflib.Graph, shacl_path_str)
273 shacl_path = os.path.join(os.path.dirname(_json_ld_writer_mod.__file__),
"model.ttl")
274 model = rdflib.Graph()
275 with open(shacl_path,
"r", encoding=
"utf-8")
as f:
276 model.parse(data=f.read(), format=
"turtle")
277 return model, shacl_path
281 Merge identical CreationInfo blank nodes into a single named node.
283 The spdx-tools JSON-LD serializer inlines CreationInfo as blank nodes,
284 causing duplication. This replaces all of them with one shared URI node.
286 SPDX_CORE = Namespace(
"https://spdx.org/rdf/Core/")
287 ci_type = SPDX_CORE.CreationInfo
291 s
for s
in g.subjects(RDF.type, ci_type)
if isinstance(s, BNode)
293 if len(ci_bnodes) <= 1:
297 ci_uri = URIRef(f
"{self._base}/CreationInfo/shared")
301 for p, o
in g.predicate_objects(first):
302 g.add((ci_uri, p, o))
305 for bnode
in ci_bnodes:
306 for s, p
in list(g.subject_predicates(bnode)):
307 g.remove((s, p, bnode))
308 g.add((s, p, ci_uri))
309 for p, o
in list(g.predicate_objects(bnode)):
310 g.remove((bnode, p, o))
312 logging.info(
"Deduplicated %d CreationInfo nodes into 1", len(ci_bnodes))
314 def _fixup_rdf_graph(self, g, rdflib):
316 SH = Namespace(
"http://www.w3.org/ns/shacl#")
323 subclass_map: dict[URIRef, set[URIRef]] = {}
324 for child, parent
in model.subject_objects(RDFS.subClassOf):
325 if isinstance(child, URIRef)
and isinstance(parent, URIRef):
326 subclass_map.setdefault(child, set()).add(parent)
331 SPDX_LIC = Namespace(
"https://spdx.org/rdf/Licensing/")
332 subclass_map.setdefault(
333 SPDX_LIC.LicenseExpression, set()
334 ).add(SPDX_LIC.AnyLicenseInfo)
340 for child, parents
in list(subclass_map.items()):
341 for parent
in list(parents):
342 grandparents = subclass_map.get(parent, set())
343 new_ancestors = grandparents - parents
345 parents.update(new_ancestors)
352 class_constraints: dict[URIRef, URIRef] = {}
353 for shape
in model.subjects(SH.property,
None):
354 for prop_node
in model.objects(shape, SH.property):
355 sh_path = model.value(prop_node, SH.path)
356 sh_class = model.value(prop_node, SH[
"class"])
357 if sh_path
and sh_class
and isinstance(sh_path, URIRef):
358 class_constraints[sh_path] = sh_class
366 for prop_path, expected_class
in class_constraints.items():
367 for s, o
in list(g.subject_objects(prop_path)):
368 if isinstance(o, Literal):
370 if val.startswith((
"http://",
"https://",
"urn:")):
375 iri = URIRef(f
"{expected_class}/{val}")
376 g.remove((s, prop_path, o))
377 g.add((s, prop_path, iri))
382 for child_class, ancestors
in subclass_map.items():
383 for subject
in list(g.subjects(RDF.type, child_class)):
384 for ancestor
in ancestors:
385 g.add((subject, RDF.type, ancestor))
392 for prop_path, expected_class
in class_constraints.items():
393 for _, o
in g.subject_objects(prop_path):
394 if isinstance(o, URIRef):
395 existing_types = set(g.objects(o, RDF.type))
398 existing_types, expected_class, subclass_map
400 g.add((o, RDF.type, expected_class))
405 Check whether any of *existing_types* equals *expected_class* or is
406 a known subclass of it (according to *subclass_map*).
408 for t
in existing_types:
409 if t == expected_class:
411 if expected_class
in subclass_map.get(t, set()):
417 Read SCANNER_RESULTS and COPYRIGHT_RESULT from *component* dict,
418 create File elements, and wire CONTAINS relationships.
421 file_data: dict[str, dict] = {}
423 for scan_result
in component.get(
'SCANNER_RESULTS', []):
424 path = scan_result.file
425 entry = file_data.setdefault(path, {
426 'scan_result': scan_result,
'licenses': [],
'copyrights': []
428 for lic
in scan_result.result:
429 lic_str = lic.get(
'license',
'')
if isinstance(lic, dict)
else str(lic)
431 entry[
'licenses'].append(lic_str)
433 for cr_result
in component.get(
'COPYRIGHT_RESULT', []):
434 path = cr_result.file
435 entry = file_data.setdefault(path, {
436 'scan_result': cr_result,
'licenses': [],
'copyrights': []
438 for cpy
in cr_result.result:
439 text = cpy.get(
'content',
'')
if isinstance(cpy, dict)
else str(cpy)
441 entry[
'copyrights'].append(text)
443 for path, data
in file_data.items():
445 if path.startswith(
'.git/')
or '/.git/' in path:
448 scan_result = data[
'scan_result']
449 file_id = f
"{self._base}/File/{self._file_idx}"
455 sha256_hash = hashlib.sha256()
456 with open(scan_result.path,
"rb")
as fh:
457 for chunk
in iter(
lambda: fh.read(4096), b
""):
458 sha256_hash.update(chunk)
460 Hash(algorithm=HashAlgorithm.SHA256,
461 hash_value=sha256_hash.hexdigest())
463 except (OSError, AttributeError):
467 copyright_text =
"\n".join(data[
'copyrights'])
or None
470 licenses = sorted(set(data[
'licenses']))
477 copyright_text=copyright_text,
478 verified_using=hashes
if hashes
else None,
479 primary_purpose=SoftwarePurpose.FILE,
480 concluded_license=concluded_license,
482 self.
payloadpayload.add_element(f_elem)
485 contains_rel = Relationship(
486 spdx_id=f
"{self._base}/Relationship/{self._rel_idx}",
487 from_element=parent_pkg_id,
488 relationship_type=RelationshipType.CONTAINS,
492 self.
payloadpayload.add_element(contains_rel)
497 Resolve a list of license identifiers to the correct SPDX 3.0 model
500 - SPDX-listed licenses → ListedLicense (ExpandedLicensing)
501 - Non-SPDX licenses → CustomLicense with LicenseRef- prefix
502 - Multiple licenses → ConjunctiveLicenseSet (AND semantics)
503 - No licenses → NoAssertionLicense
505 Objects are cached so the same license ID reuses one instance.
508 return NoAssertionLicense()
512 if len(resolved) == 1:
514 return ConjunctiveLicenseSet(member=resolved)
517 """Return a cached LicenseExpression or CustomLicense for a single ID.
519 For SPDX-listed licenses, creates a LicenseExpression with just the
520 spdx expression string (no full license object needed per the spec).
521 For custom/unknown licenses, creates a CustomLicense with licenseText
522 as required by the SPDX 3.0 spec (minCount=1).
524 if lic_id
in self._license_cache:
525 return self._license_cache[lic_id]
527 if not self.
_spdx_licensing_spdx_licensing.validate(lic_id).invalid_symbols:
531 license_expression=lic_id,
535 safe = re.sub(
r"[^a-zA-Z0-9._-]",
"-", lic_id)
536 ref_id = lic_id
if lic_id.startswith(
"LicenseRef-")
else f
"LicenseRef-fossology-{safe}"
539 display_name = lic_id.removeprefix(
"LicenseRef-")
if lic_id.startswith(
"LicenseRef-")
else lic_id
542 license_name=display_name,
543 license_text=f
"The license text for {ref_id} has to be entered.",
546 self._license_cache[lic_id] = obj
551 Create (or retrieve cached) a dependency Package element.
553 pkg_name = component.get(
'name',
'UNKNOWN')
554 pkg_version = component.get(
'version',
'UNKNOWN')
555 safe = re.sub(
r"[^a-zA-Z0-9._-]",
"-", f
"{pkg_name}-{pkg_version}")
556 pkg_id = f
"{self._base}/Package/{safe}"
559 existing = self.
payloadpayload.get_full_map().
get(pkg_id)
560 if existing
is not None:
567 primary_purpose=SoftwarePurpose.LIBRARY,
569 self.
payloadpayload.add_element(dep_pkg)
574 """Generate a unique base URI for the document."""
575 unique = uuid.uuid4().hex[:12]
576 safe = re.sub(
r"[^a-zA-Z0-9._-]",
"-", doc_name)
577 return f
"urn:spdx:{safe}/{unique}"
581 _XML_INVALID_RE = re.compile(
582 '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x84\x86-\x9f'
583 '\ud800-\udfff\ufdd0-\ufdef\ufffe\uffff]'
589 Remove characters that are invalid in XML 1.0 from all Literal values.
591 rdflib's RDF/XML serializer writes these characters verbatim, but
592 XML parsers (expat) reject them on re-read, causing 'not well-formed'
593 errors. Only needed for XML-based output formats.
595 for s, p, o
in list(g):
596 if isinstance(o, Literal):
598 clean = Spdx3Report._XML_INVALID_RE.sub(
'', val)
601 g.add((s, p, Literal(clean, datatype=o.datatype, lang=o.language)))
606 Determine the rdflib serialization format from a file extension.
608 Uses rdflib's own ``SUFFIX_FORMAT_MAP`` so that new formats are
609 supported automatically without code changes. Falls back to
610 ``json-ld`` for unknown extensions.
612 ext = os.path.splitext(file_name)[1].lstrip(
".").lower()
613 return SUFFIX_FORMAT_MAP.get(ext,
"json-ld")
617 Validate the written SPDX 3.0 report using pyshacl against the
618 SHACL schema bundled with spdx-tools.
620 Works for any RDF serialization format (Turtle, JSON-LD, RDF/XML, etc.).
624 logging.info(
"Validating SPDX 3.0 report against SHACL schema...")
630 data_graph = rdflib.Graph()
631 data_graph.parse(file_name, format=data_format)
633 conforms, results_graph, results_text = pyshacl.validate(
634 data_graph=data_graph,
635 shacl_graph=shacl_graph,
637 except ImportError
as e:
638 logging.warning(
"SHACL validation skipped (missing dependency: %s)", e)
640 except Exception
as e:
641 logging.warning(
"SHACL validation could not run: %s", e)
645 logging.info(
"SPDX 3.0 report conforms to SHACL schema.")
647 lines = results_text.strip().split(
'\n')
648 violation_count = len(
649 [l
for l
in lines
if 'Violation' in l]
652 "SHACL validation reported %d violation(s).",
656 logging.debug(
"SHACL: %s", line)
657 logging.info(
"Report saved despite SHACL warnings (non-blocking).")
def _fixup_rdf_graph(self, g, rdflib)
def _deduplicate_creation_info(self, g, rdflib)
def finalize_document(self)
None _validate_report(self, str file_name)
str _rdf_format_for_file(str file_name)
Package _get_or_create_dep_package(self, dict component)
def _process_component(self, dict component, str parent_pkg_id)
str _make_base_uri(str doc_name)
def __init__(self, CliOptions cli_options, Scanners scanner)
def _type_satisfies(existing_types, expected_class, subclass_map)
def _sanitize_xml_literals(g, rdflib)
def write_report(self, str file_name)
def _load_spdx_shacl_model()
def _resolve_licenses(self, list[str] licenses)
def _get_license_object(self, str lic_id)
list_t type structure used to keep various lists. (e.g. there are multiple lists).