diff --git a/Mk/LuaScripts/ports-spdx-traverse-deps.lua b/Mk/LuaScripts/ports-spdx-traverse-deps.lua new file mode 100755 --- /dev/null +++ b/Mk/LuaScripts/ports-spdx-traverse-deps.lua @@ -0,0 +1,179 @@ +#!/usr/libexec/flua + +-- SPDX-License-Identifier: BSD-2-Clause +-- +-- Copyright(c) 2025 The FreeBSD Foundation. +-- +-- This software was developed by Tuukka Pasanen +-- under sponsorship from the FreeBSD Foundation. +-- +-- Traverse package dependencies for SDPX Lite 3.0.1 SBOM +-- + +local Logging = require("logging") +local ucl = require("ucl") + +logger = Logging.new(nil, "INFO") + +require("ports-make") +require("ports-spdx") + +------------------------------------------------------------------------------- +-- Run 'describe-json' with make and parse output. +-- @param location If something else than nil then make if runned with '-C' +-- @return Parse output which contains name, version and licenses array +------------------------------------------------------------------------------- +local function spdx_traverse_describe_json(location) + local addition_str = "" + + if location ~= nil then + addition_str = "-C " .. location .. " " + end + + local output_table = ports_make_target(addition_str .. "describe-json") + local parser = ucl.parser() + local parsed_json, err = parser:parse_string(output_table) + + ucl_obj = parser:get_object() + rtn_table = {} + + -- Example for this one is glib 2.0 package which + -- outputs JSON with object that has okeys 'default-glib20' and 'bootstrap-glib20' + -- To simplify we choose first which should be 'default'. + -- otherwise there is only one object so use that one + if ucl_obj["pkgbase"] == nil then + logger:debug("Multiple packages describe in JSON") + for key, cur_obj in pairs(ucl_obj) do + if cur_obj["pkgbase"] ~= nil then + rtn_table["name"] = cur_obj["pkgbase"] + rtn_table["version"] = cur_obj["distversion"] + rtn_table["license"] = cur_obj["license"] + break + end + end + else + rtn_table["name"] = ucl_obj["pkgbase"] + rtn_table["version"] = ucl_obj["distversion"] + rtn_table["license"] = ucl_obj["license"] + end + + logger:debug("Described name: " .. rtn_table["name"]) + logger:debug("Described version: " .. rtn_table["version"]) + + for _, license_str in ipairs(rtn_table["license"]) do + logger:debug("Described license: " .. license_str) + end + + return rtn_table +end + +------------------------------------------------------------------------------- +-- Parses 'package-depends-list' one line and creates unified table +-- @param table_string One line of output which should be parsed +-- @return Parse output as table which contains name, version, full_location +-- and location +------------------------------------------------------------------------------- +local function spdx_traverse_split_output(table_string) + local splitted_table = ports_make_split_string(table_string, " ") + local version_table = ports_make_split_string(splitted_table[1], "-") + + version_table_len = #version_table + local name_len = (string.len(splitted_table[1]) - string.len(version_table[version_table_len])) - 1 + + local version_str = version_table[version_table_len] + local name_str = string.sub(splitted_table[1], 0, name_len) + + rtn_table = {} + rtn_table["name"] = name_str + rtn_table["version"] = version_str + rtn_table["full_location"] = splitted_table[2] + rtn_table["location"] = splitted_table[3] + + logger:debug("Package name: " .. rtn_table["name"]) + logger:debug("Package version: " .. rtn_table["version"]) + logger:debug("Package full location: " .. rtn_table["full_location"]) + logger:debug("Package location: " .. rtn_table["location"]) + + return rtn_table +end + +------------------------------------------------------------------------------- +-- Add depends for some packge to Graph from 'package-depends-list' output +-- @param deps_table Output of package-depends-list as table +-- @param package Current package for relationship from-key +-- @param software_sbom Current softwareSbom +-- @param spdx_document Current spdxDocument +-- @param creation_info Current creationInfo +-- @return none +------------------------------------------------------------------------------- +local function spdx_traverse_add_depends_on(deps_table, package, software_sbom, spdx_document, creation_info) + local depends_on_table = nil + for _, output_str in ipairs(deps_table) do + package_dep_table = spdx_traverse_split_output(output_str) + to_spdx_id = ports_spdx_get_spdxId("software_Package", package_dep_table["name"]) + + if depends_on_table == nil then + depends_on_table = ports_spdx_add_relationship( + root_graph, + package.name, + package.spdxId, + to_spdx_id, + "packages", + "dependsOn", + software_sbom, + spdx_document, + creation_info + ) + else + table.insert(depends_on_table.to, to_spdx_id) + end + end +end + +root_graph = {} +-- Create Graph root objects +local agent, creation_info, spdx_document = ports_spdx_create_root(root_graph) + +local output_table = ports_make_target_as_table("package-depends-list") +local package_data = spdx_traverse_describe_json() + +-- Create SBOM object for package we are currently SBOMing +local software_sbom, package = ports_spdx_create_sbom( + root_graph, + package_data["name"], + package_data["version"], + package_data["license"], + spdx_document, + creation_info, + agent, + "build" +) + +-- Add depends for current SBOM +spdx_traverse_add_depends_on(output_table, package, software_sbom, spdx_document, creation_info) + +-- Add depends to current package +for _, output_str in ipairs(output_table) do + local package_dep_table = spdx_traverse_split_output(output_str) + local package_data = spdx_traverse_describe_json(package_dep_table["full_location"]) + local dep_software_sbom, dep_package = ports_spdx_create_sbom( + root_graph, + package_data["name"], + package_data["version"], + package_data["license"], + spdx_document, + creation_info, + agent, + "build" + ) + + local deps_table = + ports_make_target_as_table("-C " .. package_dep_table["full_location"] .. " package-depends-list") + spdx_traverse_add_depends_on(deps_table, dep_package, software_sbom, spdx_document, creation_info) +end + +-- Add licenses to Graph +ports_spdx_add_liceses(root_graph, spdx_document, creation_info) + +json_ld = ports_spdx_json_ld(root_graph) +print(ucl.to_format(json_ld, "json")) diff --git a/Mk/LuaScripts/ports-spdx.lua b/Mk/LuaScripts/ports-spdx.lua new file mode 100644 --- /dev/null +++ b/Mk/LuaScripts/ports-spdx.lua @@ -0,0 +1,364 @@ +-- SPDX-License-Identifier: BSD-2-Clause +-- +-- Copyright(c) 2025 The FreeBSD Foundation. +-- +-- This software was developed by Tuukka Pasanen +-- under sponsorship from the FreeBSD Foundation. +-- +-- Lua script for creating SPDX Lite profile version 3.0.1 files +-- +-- SPDX documentation can be found from: +-- https://spdx.github.io/spdx-spec/v3.0.1/ +-- + +local ucl = require("ucl") + +local spdx_version = "3.0.1" +local use_uri = "https://cgit.freebsd.org/ports" +local agent_id = "" +-- license_table contains all licenses in SPDX license format +-- license_spxd_id_table contains them in spdxId format +-- These are only have once every license nor many occurances +local license_table = {} +local license_spxd_id_table = {} +local freebsd_document_license = "FreeBSD-DOC" +local freebsd_document_license_url = "https://spdx.org/licenses/FreeBSD-DOC.html" + +------------------------------------------------------------------------------- +-- get spdxId URI with part and id +-- It produces URI: start/part/id +-- @param part Prepresents part of SBOM like 'Package' or 'Relationship' +-- @param id Id is something unique id for this part like package name +-- @return URI: start/part/id or https://start/part/id +------------------------------------------------------------------------------- +function ports_spdx_get_spdxId(part, id) + rtn_string = use_uri .. "/" .. part .. "/" .. id + return rtn_string +end + +------------------------------------------------------------------------------- +-- Create basic table with correct structure to extend in +-- specific create functions +-- @param spdx_id spdxId for this SpdxDocument +-- @param obj_type Type of object +-- @param creation_info_id Which creation info we are using +-- @return table which holds object +------------------------------------------------------------------------------- +local function ports_spdx_create_table(spdx_id, obj_type, creation_info_id) + rtn_table = { + creationInfo = creation_info_id, + spdxId = spdx_id, + } + + rtn_table["@type"] = obj_type + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get /Core/SpdxDocument element +-- Note: There can be only one SpdxDocument in SPDX Lite 3.0.1 document +-- @param spdx_id spdxId for this SpdxDocument +-- @param creation_info_id Which creation info we are using +-- @return table which holds object +------------------------------------------------------------------------------- +function ports_spdx_core_spdx_document(spdx_id, creation_info_id) + rtn_table = ports_spdx_create_table(spdx_id, "SpdxDocument", creation_info_id) + + rtn_table["rootElement"] = {} + rtn_table["element"] = {} + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get /Classes/Sbom/ element which holds one package information +-- @param spdx_id spdxId for this SpdxDocument +-- @param creation_info_id Which creation info we are using +-- @param sbom_type_str mainly 'build' but see documenation for extra info +-- @return table which holds object +------------------------------------------------------------------------------- +function ports_spdx_software_sbom(spdx_id, creation_info_id, sbom_type_str) + rtn_table = ports_spdx_create_table(spdx_id, "software_Sbom", creation_info_id) + + rtn_table["rootElement"] = {} + rtn_table["element"] = {} + rtn_table["sbom_type"] = { sbom_type_str } + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get /SimpleLicensing/LicenseExpression element which holds SPDX license name +-- @param creation_info_id Which creation info we are using +-- @param license SPDX license expression +-- @return table which holds object +------------------------------------------------------------------------------- +function ports_spdx_simplelicensing_license_expression(creation_info_id, license) + spdx_id = ports_spdx_get_spdxId("simplelicensing_LicenseExpression", string.lower(license)) + + rtn_table = ports_spdx_create_table(spdx_id, "simplelicensing_LicenseExpression", creation_info_id) + + rtn_table["license_expression"] = license + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get /Software/Package element which holds one package information +-- There can be lot of extra information but this holds only bare minimum +-- @param creation_info_id Which creation info we are using +-- @param agent_id Some agent information +-- @param package_name Name of package +-- @param package_version Package version number +-- @return table which holds object +------------------------------------------------------------------------------- +function ports_spdx_software_package(creation_info_id, agent_id, package_name, package_version) + spdx_id = ports_spdx_get_spdxId("software_Package", package_name) + + rtn_table = ports_spdx_create_table(spdx_id, "software_Package", creation_info_id) + + rtn_table["originatedBy"] = { agent_id } + rtn_table["name"] = package_name + rtn_table["software_copyrightText"] = "NOASSERTION" + rtn_table["software_packageVersion"] = package_version + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get /Core/Relationship element which holds somekind of relationship. +-- @param spdx_id spdxId for relationship +-- @param creation_info_id Which creation info we are using +-- @param from_id From this spdxId +-- @param to_id To this spdxId +-- @param relationship_type which kind of relation ship. See documentation. +-- @return table which holds object +------------------------------------------------------------------------------- +function ports_spdx_core_relationship(spdx_id, creation_info_id, from_id, to_id, relationship_type) + rtn_table = ports_spdx_create_table(spdx_id, "Relationship", creation_info_id) + + rtn_table["from"] = from_id + rtn_table["to"] = { to_id } + rtn_table["relationshipType"] = relationship_type + + rtn_table["@type"] = "Relationship" + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get /Core/Agent element which is actor in system. +-- @param creation_info_id Which creation info we are using +-- @param name Name of actor +-- @return table which holds object +------------------------------------------------------------------------------- +function ports_spdx_core_agent(creation_info_id, name) + -- assert(type(name) ~= "string", "Name must be string, got: %s.", type(name)) + spdxId = ports_spdx_get_spdxId("Agent", string.lower(name):gsub(" ", "_")) + + rtn_table = ports_spdx_create_table(spdx_id, "Agent", creation_info_id) + + rtn_table["name"] = name + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get /Core/CreationInfo element which is creation info for this document. +-- @param creation_id Which creation info we are using +-- @param agent_id Which agent have made this document +-- @return table which holds object +------------------------------------------------------------------------------- +function ports_spdx_core_creation_info(creation_id, agent_id) + local now = os.time() + local formatted_date = os.date("%FT%TZ", now) + + rtn_table = { + created = formatted_date, + created_by = { agent_id }, + created_using = "FreeBSD Port SPDX tool 1.0.0", + spec_version = spdx_version, + } + + rtn_table["@type"] = "CreationInfo" + rtn_table["@id"] = creation_id + + return rtn_table +end + +------------------------------------------------------------------------------- +-- get JSON-LD table for creating RDF +-- @param graph Holds table for @graph +-- @return JSON-LD table +------------------------------------------------------------------------------- +function ports_spdx_json_ld(graph) + rtn_table = {} + + rtn_table["@context"] = "https://spdx.org/rdf/3.0.1/spdx-context.jsonld" + + rtn_table["@graph"] = graph + + return rtn_table +end + +------------------------------------------------------------------------------- +-- Add to object 'element' or 'rootElement' +-- @param object_table Object table +-- @param spdx_id spdxId to add +-- @return is_root if false then add to 'element' and if true 'rootElement' +------------------------------------------------------------------------------- +local function ports_spdx_add_to_element(object_table, spdx_id, is_root) + if is_root then + table.insert(object_table.rootElement, spdx_id) + else + table.insert(object_table.element, spdx_id) + end +end + +local function ports_spdx_add_to_graph(root_graph, object_table) + table.insert(root_graph, object_table) +end + +function ports_spdx_add_liceses(root_graph, spdx_document, creation_info) + for key, license_str in pairs(license_spxd_id_table) do + if license_str ~= "" then + license_table = ports_spdx_simplelicensing_license_expression(creation_info["@id"], license_str) + ports_spdx_add_to_element(spdx_document, license_table.spdxId, false) + ports_spdx_add_to_graph(root_graph, license_table) + end + end +end + +------------------------------------------------------------------------------- +-- Add relationship to Graph. It makes all necesery adds to SBOM and +-- spdxDocument +-- @param root_graph Graph object +-- @param package_name Package name from we want to make relationship +-- @param from From spdxId +-- @param to to spdxId +-- @param to_string This is what comes at last in spdxId +-- @param relationship_type What kind of relationship is this +-- @param software_sbom Current software SBOM +-- @param spdx_document Current SPDX document +-- @param creation_info Current creation info +-- @return Relationship table +------------------------------------------------------------------------------- +function ports_spdx_add_relationship( + root_graph, + package_name, + from, + to, + to_string, + relationship_type, + software_sbom, + spdx_document, + creation_info +) + relation_spdx_id_str = package_name .. "/" .. relationship_type .. "/" .. string.lower(to_string) + relation_spdx_id = ports_spdx_get_spdxId("Relationship", relation_spdx_id_str) + + relation_table = ports_spdx_core_relationship(relation_spdx_id, creation_info["@id"], from, to, relationship_type) + + ports_spdx_add_to_element(spdx_document, relation_table.spdxId, false) + ports_spdx_add_to_element(software_sbom, relation_table.spdxId, false) + ports_spdx_add_to_graph(root_graph, relation_table) + + return relation_table +end + +------------------------------------------------------------------------------- +-- Add SBOM to Graph. It makes all necesery adds to SBOM and +-- spdxDocument +-- @param root_graph Graph object +-- @param package_name Package name +-- @param package_version Package version +-- @param package_license Package license array (Comes from JSON) +-- @param spdx_document Current SPDX document +-- @param creation_info Current creation info +-- @param agent Current agent +-- @param type Type of SBOM +-- @return SBOM table and package table +------------------------------------------------------------------------------- +function ports_spdx_create_sbom( + root_graph, + package_name, + package_version, + package_license, + spdx_document, + creation_info, + agent, + type +) + logger:debug("Create SBOM with package name: '" .. package_name .. "' and version: '" .. package_version) + + software_sbom = + ports_spdx_software_sbom(ports_spdx_get_spdxId("software_Sbom", package_name), creation_info["@id"], type) + + package = ports_spdx_software_package(creation_info["@id"], default_agent.spdxId, package_name, package_version) + + ports_spdx_add_to_element(spdx_document, software_sbom.spdxId, false) + ports_spdx_add_to_element(spdx_document, package.spdxId, false) + ports_spdx_add_to_element(spdx_document, software_sbom.spdxId, true) + ports_spdx_add_to_element(software_sbom, package.spdxId, true) + + ports_spdx_add_to_graph(root_graph, software_sbom) + ports_spdx_add_to_graph(root_graph, package) + + for _, license_str in ipairs(package_license) do + license_spdx_id = ports_spdx_get_spdxId("simplelicensing_LicenseExpression", string.lower(license_str)) + + -- If we don't have this kind of license then just create one + -- otherwise bail out + if license_spxd_id_table[license_str] == nil then + logger:debug("SBOM package license: " .. license_str) + license_spxd_id_table[license_str] = license_str + end + + license = ports_spdx_simplelicensing_license_expression(creation_info["@id"], license_str) + + ports_spdx_add_relationship( + root_graph, + package_name, + package.spdxId, + license_spdx_id, + license_str, + "hasDeclaredLicense", + software_sbom, + spdx_document, + creation_info + ) + ports_spdx_add_relationship( + root_graph, + package_name, + package.spdxId, + license_spdx_id, + license_str, + "hasConcludedLicense", + software_sbom, + spdx_document, + creation_info + ) + end + + return software_sbom, package +end + +------------------------------------------------------------------------------- +-- Add SpdxDocument, Agent and creationInfo to Graph. It makes all necesery +-- adds to SBOM and spdxDocument +-- @param root_graph Graph object +-- @return Agent table, creationInfo Table, spdxDocument table +------------------------------------------------------------------------------- +function ports_spdx_create_root(root_graph) + default_agent = ports_spdx_core_agent("_:creationinfo_1", "Default agent") + creation_info = ports_spdx_core_creation_info("_:creationinfo_1", default_agent.spdxId) + spdx_document = ports_spdx_core_spdx_document(ports_spdx_get_spdxId("SpdxDocument", "core"), creation_info["@id"]) + + ports_spdx_add_to_element(spdx_document, default_agent.spdxId, false) + ports_spdx_add_to_graph(root_graph, spdx_document) + ports_spdx_add_to_graph(root_graph, creation_info) + ports_spdx_add_to_graph(root_graph, default_agent) + + return default_agent, creation_info, spdx_document +end diff --git a/Mk/bsd.commands.mk b/Mk/bsd.commands.mk --- a/Mk/bsd.commands.mk +++ b/Mk/bsd.commands.mk @@ -55,6 +55,7 @@ ID?= /usr/bin/id IDENT?= /usr/bin/ident JOT?= /usr/bin/jot +LUA?= /usr/libexec/flua LDCONFIG?= /sbin/ldconfig LHA_CMD?= ${LOCALBASE}/bin/lha LN?= /bin/ln diff --git a/Mk/bsd.port.mk b/Mk/bsd.port.mk --- a/Mk/bsd.port.mk +++ b/Mk/bsd.port.mk @@ -613,6 +613,8 @@ # config-recursive # - Configure options for this port for a port and all its # dependencies. +# sbom - Create SPDX Lite 3.x compatible Software Bill Of material +# From current package. # showconfig - Display options config for this port. # showconfig-recursive # - Display options config for this port and all its @@ -1012,6 +1014,7 @@ SRC_BASE?= /usr/src USESDIR?= ${PORTSDIR}/Mk/Uses SCRIPTSDIR?= ${PORTSDIR}/Mk/Scripts +LUASCRIPTSDIR?= ${PORTSDIR}/Mk/LuaScripts LIB_DIRS?= /lib /usr/lib ${LOCALBASE}/lib STAGEDIR?= ${WRKDIR}/stage NOTPHONY?= @@ -5139,6 +5142,17 @@ . endif . endif # config-conditional +# Package (recursive runtime) dependency list. Print out both directory names +# and package names. + +sbom: +. if !exists(/usr/lib/flua/ucl.so) && !exists(${LOCALBASE}/lib/lua/5.4/ucl.so) + @${ECHO_MSG} "===> Lua UCL library not found, cannot create SPDX Lite SBOM." + @${ECHO_MSG} "===> Please install textproc/libucl ports." +. else + @${SETENV} LUA_PATH="${LUASCRIPTSDIR}/?.lua;;" ${LUA} ${LUASCRIPTSDIR}/ports-spdx-traverse-deps.lua +. endif + . if !target(showconfig) && (make(*config*) || (!empty(.MAKEFLAGS:M-V) && !empty(.MAKEFLAGS:M*_DESC))) .include "${PORTSDIR}/Mk/bsd.options.desc.mk" MULTI_EOL= : you have to choose at least one of them