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>
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
import datetime
from glob import glob
import importlib.metadata
from os.path import basename
from urllib.parse import unquote
import argparse
import json
import sys, configparser, re, codecs
import uuid
# PkgInfo represents information about a package
......@@ -494,21 +497,120 @@ def fmt_bom(bom): # -> str
return ''.join(outv)
def main():
if len(sys.argv) != 3 or sys.argv[1] not in ('software', 'node'):
print(__doc__, file=sys.stderr)
sys.exit(2)
def fmt_bom_cyclonedx_json(bom, software_path):
# possible future extensions:
# - 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)
return bom_json
what, arg = sys.argv[1:]
if what == 'software':
bom = bom_software(arg)
elif what == 'node':
bom = bom_node(arg)
def main():
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
# TODO also consider emitting machine-readable format, e.g. json if run with --json
# TODO for json, consider schema from https://cyclonedx.org/specification/overview/
print(fmt_bom(bom))
if args.format == 'text':
print(fmt_bom(bom), file=args.output)
else:
assert args.format == 'cyclonedx-json'
json.dump(fmt_bom_cyclonedx_json(bom, args.software_path), args.output, indent=True)
if __name__ == '__main__':
......
......@@ -471,20 +471,30 @@ scons-local 2.3.1 https://prdownloads.sourceforge.net/scon
""")
@pytest.mark.parametrize('build,bomok', testv)
def test_bom_software(tmpdir, build, bomok):
tmpdir = str(tmpdir)
def populate_software_directory_from_build(tmpdir, build):
build = '-- /ROOT/.installed.cfg --\n' + build
build = build.replace('/ROOT', tmpdir)
build = build.replace('/BASE', tmpdir+'/base')
build = build.replace('/ROOT', str(tmpdir))
build = build.replace('/BASE', str(tmpdir / 'base'))
ar = txtar_parse(build)
assert ar.comment == ''
for f, data in ar.files.items():
assert f.startswith(tmpdir)
assert f.startswith(str(tmpdir))
os.makedirs(dirname(f), exist_ok=True)
with open(f, 'w') as _:
_.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):
with pytest.raises(type(bomok)) as e:
nxdbom.bom_software(tmpdir)
......@@ -492,6 +502,66 @@ def test_bom_software(tmpdir, build, bomok):
else:
bom = nxdbom.bom_software(tmpdir)
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
......
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