Commit adc41b3d authored by Jérome Perrin's avatar Jérome Perrin

output basic cyclonedx-json

See https://cyclonedx.org/docs/1.5/json/ for a human readbable version
of the schema.

This is the simplest format, but already good enough to use with tools
such as https://github.com/DependencyTrack/dependency-track or
https://github.com/intel/cve-bin-tool .

Also introduce argparse now that we start to have complex arguments.

Reviewed-on: !5
parent d47f4462
...@@ -26,13 +26,16 @@ Usage: nxd-bom software <path-to-installed-software> ...@@ -26,13 +26,16 @@ Usage: nxd-bom software <path-to-installed-software>
An example of generated bill of material is provided in example/ors-bom.txt . An example of generated bill of material is provided in example/ors-bom.txt .
""" """
from __future__ import print_function
import sys, configparser, re, codecs
from os.path import basename
from glob import glob
from collections import namedtuple from collections import namedtuple
import datetime
from glob import glob
import importlib.metadata
from os.path import basename
from urllib.parse import unquote from urllib.parse import unquote
import argparse
import json
import sys, configparser, re, codecs
import uuid
# PkgInfo represents information about a package # PkgInfo represents information about a package
...@@ -494,21 +497,120 @@ def fmt_bom(bom): # -> str ...@@ -494,21 +497,120 @@ def fmt_bom(bom): # -> str
return ''.join(outv) return ''.join(outv)
def main(): def fmt_bom_cyclonedx_json(bom, software_path):
if len(sys.argv) != 3 or sys.argv[1] not in ('software', 'node'):
print(__doc__, file=sys.stderr) # possible future extensions:
sys.exit(2) # - describe patches applied to components (using components[*].pedigree.patches )
# - describe components download URL (using components[*].externalReferences[*].url
# and components[*].hashes )
# - for egg components, include metadata (licence, author, description) by reading
# EGG-INFO/PKG-INFO
cfgparser = configparser.ConfigParser()
cfgparser.read('%s/buildout.cfg' % software_path)
software_url = cfgparser.get('buildout', 'extends')
name = software_url.split('/')[-2] # slapos convention
bom_json = {
"serialNumber": f'urn:uuid:{uuid.uuid4()}',
"version": 1,
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"metadata": {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"component": {
"name": name,
"type": "application",
"externalReferences": [
{
"type": "build-meta",
"url": software_url,
}
]
},
"tools": {
"components": [
{
"type": "application",
"name": "nxdbom",
"version": importlib.metadata.version("nxdbom"),
"externalReferences": [
{
"type": "vcs",
"url": "https://lab.nexedi.com/nexedi/nxd-bom/"
}
]
}
]
}
}
}
components = bom_json["components"] = []
for _, pkginfo in sorted(bom.items()):
cpe = None
externalReferences = []
if pkginfo.url:
externalReferences.append(
{
'url': pkginfo.url,
'type': (
'vcs'
if pkginfo.kind == 'git'
else 'distribution'
),
}
)
purl_type = 'generic'
if pkginfo.kind == 'egg':
purl_type = 'pypi'
elif pkginfo.kind == 'gem':
purl_type = 'gem'
else:
cpe = f'cpe:2.3:*:*:{pkginfo.name}:{pkginfo.version}:*:*:*:*:*:*:*'
purl = f'pkg:{purl_type}/{pkginfo.name}@{pkginfo.version}'
component = {
'name': pkginfo.name,
'purl': purl,
'type': 'library',
'version': pkginfo.version,
}
if cpe:
component['cpe'] = cpe
if externalReferences:
component['externalReferences'] = externalReferences
components.append(component)
what, arg = sys.argv[1:] return bom_json
if what == 'software':
bom = bom_software(arg)
elif what == 'node': def main():
bom = bom_node(arg) parser = argparse.ArgumentParser(
prog=__name__,
description=__doc__
)
parser.add_argument('-f', '--format', choices=['text', 'cyclonedx-json'], default='text')
parser.add_argument('-o', '--output',
type=argparse.FileType('w', encoding='UTF-8'),
default=sys.stdout)
subparsers = parser.add_subparsers(dest='mode', title='Mode-specific commands', required=True)
software_parser = subparsers.add_parser('software', help="Generates BOM from an installed software")
software_parser.add_argument(dest="software_path")
node_parser = subparsers.add_parser('node', help="Generates BOM from a slapos deploy script")
node_parser.add_argument(dest="deploy_script_path")
args = parser.parse_args()
if args.mode == 'software':
bom = bom_software(args.software_path)
else:
assert args.mode == 'node'
bom = bom_node(args.deploy_script_path)
# print retrieved BOM # print retrieved BOM
# TODO also consider emitting machine-readable format, e.g. json if run with --json if args.format == 'text':
# TODO for json, consider schema from https://cyclonedx.org/specification/overview/ print(fmt_bom(bom), file=args.output)
print(fmt_bom(bom)) else:
assert args.format == 'cyclonedx-json'
json.dump(fmt_bom_cyclonedx_json(bom, args.software_path), args.output, indent=True)
if __name__ == '__main__': if __name__ == '__main__':
......
...@@ -471,20 +471,30 @@ scons-local 2.3.1 https://prdownloads.sourceforge.net/scon ...@@ -471,20 +471,30 @@ scons-local 2.3.1 https://prdownloads.sourceforge.net/scon
""") """)
@pytest.mark.parametrize('build,bomok', testv) def populate_software_directory_from_build(tmpdir, build):
def test_bom_software(tmpdir, build, bomok):
tmpdir = str(tmpdir)
build = '-- /ROOT/.installed.cfg --\n' + build build = '-- /ROOT/.installed.cfg --\n' + build
build = build.replace('/ROOT', tmpdir) build = build.replace('/ROOT', str(tmpdir))
build = build.replace('/BASE', tmpdir+'/base') build = build.replace('/BASE', str(tmpdir / 'base'))
ar = txtar_parse(build) ar = txtar_parse(build)
assert ar.comment == '' assert ar.comment == ''
for f, data in ar.files.items(): for f, data in ar.files.items():
assert f.startswith(tmpdir) assert f.startswith(str(tmpdir))
os.makedirs(dirname(f), exist_ok=True) os.makedirs(dirname(f), exist_ok=True)
with open(f, 'w') as _: with open(f, 'w') as _:
_.write(data) _.write(data)
buildout_cfg = (tmpdir / 'buildout.cfg')
if not buildout_cfg.exists():
buildout_cfg.write_text('''
[buildout]
extends = https://slapos.example.invalid/software/example/software.cfg
''',
'utf-8')
@pytest.mark.parametrize('build,bomok', testv)
def test_bom_software(tmpdir, build, bomok):
populate_software_directory_from_build(tmpdir, build)
bom = {}
if isinstance(bomok, Exception): if isinstance(bomok, Exception):
with pytest.raises(type(bomok)) as e: with pytest.raises(type(bomok)) as e:
nxdbom.bom_software(tmpdir) nxdbom.bom_software(tmpdir)
...@@ -492,6 +502,66 @@ def test_bom_software(tmpdir, build, bomok): ...@@ -492,6 +502,66 @@ def test_bom_software(tmpdir, build, bomok):
else: else:
bom = nxdbom.bom_software(tmpdir) bom = nxdbom.bom_software(tmpdir)
assert nxdbom.fmt_bom(bom) == bomok assert nxdbom.fmt_bom(bom) == bomok
assert nxdbom.fmt_bom_cyclonedx_json(bom, str(tmpdir))
def test_bom_cyclonedx_json(tmpdir):
build = """\
[libpng]
recipe = slapos.recipe.cmmi
url = http://download.sourceforge.net/libpng/libpng-1.6.37.tar.xz
[eggs]
recipe = zc.recipe.egg
_d = /ROOT/develop-eggs
_e = /ROOT/eggs
__buildout_installed__ =
eggs =
aaa
-- /ROOT/eggs/aaa-1.2.3.egg/x --
"""
populate_software_directory_from_build(tmpdir, build)
bom = nxdbom.bom_software(tmpdir)
cyclonedx = nxdbom.fmt_bom_cyclonedx_json(bom, tmpdir)
assert cyclonedx['bomFormat'] == 'CycloneDX'
assert cyclonedx['specVersion'] == '1.5'
assert cyclonedx['serialNumber']
assert cyclonedx['metadata']['timestamp']
assert cyclonedx['metadata']['component']['name'] == 'example'
assert cyclonedx['metadata']['component']['externalReferences'] == [
{
"type": "build-meta",
"url": "https://slapos.example.invalid/software/example/software.cfg"
}
]
assert [c['name'] for c in cyclonedx['metadata']['tools']['components']] == ['nxdbom']
assert cyclonedx['components'] == [
{
'externalReferences': [
{
'type': 'distribution',
'url': 'https://pypi.org/project/aaa/1.2.3/',
},
],
'name': 'aaa',
'purl': 'pkg:pypi/aaa@1.2.3',
'type': 'library',
'version': '1.2.3',
},
{
'cpe': 'cpe:2.3:*:*:libpng:1.6.37:*:*:*:*:*:*:*',
'externalReferences': [
{
'type': 'distribution',
'url': 'http://download.sourceforge.net/libpng/libpng-1.6.37.tar.xz',
},
],
'name': 'libpng',
'purl': 'pkg:generic/libpng@1.6.37',
'type': 'library',
'version': '1.6.37',
},
]
# loading non-existing .installed.cfg -> error # loading non-existing .installed.cfg -> error
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment