Skip to content

Commit c3709af

Browse files
authored
Merge pull request #247 from CycloneDX/feat/conda-support
FEATURE: Add Conda Support
2 parents a030392 + da6772b commit c3709af

File tree

6 files changed

+105
-100
lines changed

6 files changed

+105
-100
lines changed

README.md

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
This project provides a runnable Python-based application for generating CycloneDX bill-of-material documents from either:
1616
1. Your current Python Environment
1717
2. Your project's manifest (e.g. `Pipfile.lock`, `poetry.lock` or `requirements.txt`)
18+
3. Conda as a Package Manager
1819

1920
The BOM will contain an aggregate of all your current project's dependencies, or those defined by the manifest you supply.
2021

@@ -39,43 +40,42 @@ poetry add cyclonedx-bom
3940
Once installed, you can access the full documentation by running `--help`:
4041

4142
```
42-
$ cyclonedx-py --help
43-
usage: client.py [-h] (-e | -p | -r) [-pf FILE_PATH] [-rf FILE_PATH]
43+
$ cyclonedx-bom --help
44+
usage: cyclonedx-bom [-h] (-c | -cj | -e | -p | -pip | -r) [-i FILE_PATH]
4445
[--format {json,xml}] [--schema-version {1.3,1.2,1.1,1.0}]
4546
[-o FILE_PATH] [-F] [-X]
4647
4748
CycloneDX SBOM Generator
4849
4950
optional arguments:
5051
-h, --help show this help message and exit
52+
-c, --conda Build a SBOM based on the output from `conda list
53+
--explicit` or `conda list --explicit --md5`
54+
-cj, --conda-json Build a SBOM based on the output from `conda list
55+
--json`
5156
-e, --e, --environment
5257
Build a SBOM based on the packages installed in your
5358
current Python environment (default)
5459
-p, --p, --poetry Build a SBOM based on a Poetry poetry.lock's contents.
5560
Use with -pf to specify absolute pathto a
5661
`poetry.lock` you wish to use, else we'll look for one
5762
in the current working directory.
63+
-pip, --pip Build a SBOM based on a PipEnv Pipfile.lock's
64+
contents. Use with --pip-file to specify absolute
65+
pathto a `Pipefile.lock` you wish to use, else we'll
66+
look for one in the current working directory.
5867
-r, --r, --requirements
5968
Build a SBOM based on a requirements.txt's contents.
6069
Use with -rf to specify absolute pathto a
6170
`requirements.txt` you wish to use, else we'll look
6271
for one in the current working directory.
6372
-X Enable debug output
6473
65-
Poetry:
66-
Additional optional arguments if you are setting the input type to
67-
`poetry`
74+
Input Method:
75+
Flags to determine how `cyclonedx-bom` obtains it's input
6876
69-
-pf FILE_PATH, --pf FILE_PATH, --poetry-file FILE_PATH
70-
Path to a the `poetry.lock` file you wish to parse
71-
72-
Requirements:
73-
Additional optional arguments if you are setting the input type to
74-
`requirements`.
75-
76-
-rf FILE_PATH, --rf FILE_PATH, --requirements-file FILE_PATH
77-
Path to a the `requirements.txt` file you wish to
78-
parse
77+
-i FILE_PATH, --in-file FILE_PATH
78+
File to read input from, or STDIN if not specified
7979
8080
SBOM Output Configuration:
8181
Choose the output format and schema version
@@ -96,32 +96,43 @@ SBOM Output Configuration:
9696
This will produce the most accurate and complete CycloneDX BOM as it will include all transitive dependencies required
9797
by the packages defined in your project's manifest (think `requriements.txt`).
9898

99-
When using _Environment_ as the source, any license information avaialble from the installed packages will also be
99+
When using _Environment_ as the source, any license information available from the installed packages will also be
100100
included in the generated CycloneDX BOM.
101101

102102
Simply run:
103103

104104
```
105-
cyclonedx-py -e -o -
105+
cyclonedx-bom -e -o -
106106
```
107107

108108
This will generate a CycloneDX including all packages installed in your current Python environment and output to STDOUT
109109
in XML using the latest schema version `1.3` by default.
110110

111111

112-
### Building CycloneDX from your Manifest
112+
### Building CycloneDX from your Manifest / Package Manager
113113

114114
_Note: Manifest scanning limits the amount of information available. Each manifest type contains different information
115115
but all are significantly less complete than scanning your actual Python Environment._
116116

117+
#### Conda
118+
119+
We support parsing output from Conda in various formats:
120+
- Explict output (run `conda list --explicit` or `conda list --explicit --md5`)
121+
- JSON output (run `conda list --json`)
122+
123+
As example:
124+
```
125+
conda list --explicit --md5 | cyclonedx-bom -c -o cyclonedx.xml
126+
```
127+
117128
#### Poetry
118129

119130
We support parsing your `poetry.lock` file which should be committed along with your `pyrpoject.toml` and details
120131
exact pinned versions.
121132

122-
You can then run `cyclonedx-py` as follows:
133+
You can then run `cyclonedx-bom` as follows:
123134
```
124-
cyclonedx-py -p -pf PATH/TO/poetry.lock -o sbom.xml
135+
cyclonedx-bom -p -i PATH/TO/poetry.lock -o sbom.xml
125136
```
126137

127138
#### Pip / Requirements
@@ -135,13 +146,13 @@ pip freeze > requirements.txt
135146

136147
You can then run `cyclonedx-py` as follows:
137148
```
138-
cyclonedx-py -r -rf PATH/TO/requirements.txt -o sbom.xml
149+
cyclonedx-bom -r -i PATH/TO/requirements.txt -o sbom.xml
139150
```
140151

141152
This will generate a CycloneDX and output to STDOUT in XML using the latest schema version `1.3` by default.
142153

143-
**Note:** If you failed to freeze your dependencies before passing the `requirements.txt` data to `cyclonedx-py`, you'll
144-
be warned about this and the dependencies that do not have pinned versions WILL NOT be included in the resulting
154+
**Note:** If you failed to freeze your dependencies before passing the `requirements.txt` data to `cyclonedx-bom`,
155+
you'll be warned about this and the dependencies that do not have pinned versions WILL NOT be included in the resulting
145156
CycloneDX output.
146157

147158
```

cyclonedx_py/client.py

Lines changed: 35 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@
2626
from cyclonedx.model.bom import Bom, Tool
2727
from cyclonedx.output import BaseOutput, get_instance, OutputFormat, SchemaVersion
2828
from cyclonedx.parser import BaseParser
29+
from cyclonedx.parser.conda import CondaListExplicitParser, CondaListJsonParser
2930
from cyclonedx.parser.environment import EnvironmentParser
30-
from cyclonedx.parser.pipenv import PipEnvFileParser
31-
from cyclonedx.parser.poetry import PoetryFileParser
32-
from cyclonedx.parser.requirements import RequirementsFileParser
31+
from cyclonedx.parser.pipenv import PipEnvParser
32+
from cyclonedx.parser.poetry import PoetryParser
33+
from cyclonedx.parser.requirements import RequirementsParser
3334

3435

3536
class CycloneDxCmd:
@@ -100,6 +101,16 @@ def get_arg_parser() -> argparse.ArgumentParser:
100101
arg_parser = argparse.ArgumentParser(description='CycloneDX SBOM Generator')
101102

102103
input_group = arg_parser.add_mutually_exclusive_group(required=True)
104+
input_group.add_argument(
105+
'-c', '--conda', action='store_true',
106+
help='Build a SBOM based on the output from `conda list --explicit` or `conda list --explicit --md5`',
107+
dest='input_from_conda_explicit'
108+
)
109+
input_group.add_argument(
110+
'-cj', '--conda-json', action='store_true',
111+
help='Build a SBOM based on the output from `conda list --json`',
112+
dest='input_from_conda_json'
113+
)
103114
input_group.add_argument(
104115
'-e', '--e', '--environment', action='store_true',
105116
help='Build a SBOM based on the packages installed in your current Python environment (default)',
@@ -124,34 +135,14 @@ def get_arg_parser() -> argparse.ArgumentParser:
124135
dest='input_from_requirements'
125136
)
126137

127-
req_input_group = arg_parser.add_argument_group(
128-
title='Poetry',
129-
description='Additional optional arguments if you are setting the input type to `poetry`'
130-
)
131-
req_input_group.add_argument(
132-
'-pf', '--pf', '--poetry-file', action='store', metavar='FILE_PATH', default='poetry.lock',
133-
help='Path to a the `poetry.lock` file you wish to parse',
134-
dest='input_poetry_file', required=False
135-
)
136-
137-
req_input_group = arg_parser.add_argument_group(
138-
title='PipEnv',
139-
description='Additional optional arguments if you are setting the input type to `pipenv`'
138+
input_method_group = arg_parser.add_argument_group(
139+
title='Input Method',
140+
description='Flags to determine how `cyclonedx-bom` obtains it\'s input'
140141
)
141-
req_input_group.add_argument(
142-
'--pip-file', action='store', metavar='FILE_PATH', default='Pipfile.lock',
143-
help='Path to a the `Pipfile.lock` file you wish to parse',
144-
dest='input_pipenv_file', required=False
145-
)
146-
147-
req_input_group = arg_parser.add_argument_group(
148-
title='Requirements',
149-
description='Additional optional arguments if you are setting the input type to `requirements`.'
150-
)
151-
req_input_group.add_argument(
152-
'-rf', '--rf', '--requirements-file', action='store', metavar='FILE_PATH', default='requirements.txt',
153-
help='Path to a the `requirements.txt` file you wish to parse',
154-
dest='input_requirements_file', required=False
142+
input_method_group.add_argument(
143+
'-i', '--in-file', action='store', metavar='FILE_PATH',
144+
type=argparse.FileType('r'), default=(None if sys.stdin.isatty() else sys.stdin),
145+
help='File to read input from, or STDIN if not specified', dest='input_source', required=False
155146
)
156147

157148
output_group = arg_parser.add_argument_group(
@@ -193,38 +184,26 @@ def _error_and_exit(message: str, exit_code: int = 1):
193184
def _get_input_parser(self) -> BaseParser:
194185
if self._arguments.input_from_environment:
195186
return EnvironmentParser()
187+
188+
# All other Parsers will require some input - grab it now!
189+
input_data_fh = self._arguments.input_source
190+
with input_data_fh:
191+
input_data = input_data_fh.read()
192+
input_data_fh.close()
193+
194+
if self._arguments.input_from_conda_explicit:
195+
return CondaListExplicitParser(conda_data=input_data)
196+
elif self._arguments.input_from_conda_json:
197+
return CondaListJsonParser(conda_data=input_data)
196198
elif self._arguments.input_from_pip:
197-
pipfile_lock_file = os.path.realpath(self._arguments.input_pipenv_file)
198-
if CycloneDxCmd._validate_file_exists(self._arguments.input_pipenv_file):
199-
# A Pipfile.lock path was provided
200-
return PipEnvFileParser(pipenv_lock_filename=pipfile_lock_file)
201-
else:
202-
self._error_and_exit(f'The provided file \'{pipfile_lock_file}\' does not exist')
199+
return PipEnvParser(pipenv_contents=input_data)
203200
elif self._arguments.input_from_poetry:
204-
poetry_lock_file = os.path.realpath(self._arguments.input_poetry_file)
205-
if CycloneDxCmd._validate_file_exists(self._arguments.input_poetry_file):
206-
# A poetry.lock path was provided
207-
return PoetryFileParser(poetry_lock_filename=poetry_lock_file)
208-
else:
209-
self._error_and_exit('The provided file \'{}\' does not exist'.format(
210-
poetry_lock_file
211-
))
201+
return PoetryParser(poetry_lock_contents=input_data)
212202
elif self._arguments.input_from_requirements:
213-
requirements_file = os.path.realpath(self._arguments.input_requirements_file)
214-
if CycloneDxCmd._validate_file_exists(self._arguments.input_requirements_file):
215-
# A requirements.txt path was provided
216-
return RequirementsFileParser(requirements_file=requirements_file)
217-
else:
218-
self._error_and_exit('The provided file \'{}\' does not exist'.format(
219-
requirements_file
220-
))
203+
return RequirementsParser(requirements_content=input_data)
221204
else:
222205
raise ValueError('Parser type could not be determined.')
223206

224-
@staticmethod
225-
def _validate_file_exists(file_path: str) -> bool:
226-
return os.path.exists(file_path)
227-
228207

229208
def main():
230209
parser = CycloneDxCmd.get_arg_parser()

poetry.lock

Lines changed: 6 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ include = [
1616

1717
[tool.poetry.dependencies]
1818
python = "^3.6"
19-
cyclonedx-python-lib = "^0.9.1"
19+
cyclonedx-python-lib = "^0.10.2"
2020

2121
[tool.poetry.dev-dependencies]
2222
tox = "^3.24.3"
2323
coverage = "^5.5"
2424
flake8 = "^3.9.2"
2525

2626
[tool.poetry.scripts]
27+
cyclonedx-bom = 'cyclonedx_py.client:main'
2728
cyclonedx-py = 'cyclonedx_py.client:main'
2829

2930
[build-system]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# This file may be used to create an environment using:
2+
# $ conda create --name <env> --file <this file>
3+
# platform: osx-64
4+
@EXPLICIT
5+
https://repo.anaconda.com/pkgs/main/osx-64/setuptools-52.0.0-py39hecd8cb5_0.conda#5c9e48476978303d04650c21ee55f365

tests/test_cyclonedx.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@
2020
import os.path
2121
import subprocess
2222
import tempfile
23-
from unittest.mock import mock_open, patch
24-
25-
from cyclonedx_py.client import CycloneDxCmd
2623

2724
from base import BaseXmlTestCase
2825

@@ -33,27 +30,38 @@
3330

3431
class TestCycloneDxXml(BaseXmlTestCase):
3532

36-
def test_run(self):
37-
parser = CycloneDxCmd.get_arg_parser()
33+
def test_environment(self):
3834
with tempfile.TemporaryDirectory() as dirname:
39-
args = parser.parse_args(args=('-r', '-o', os.path.join(dirname, 'sbom.xml')))
40-
with open(os.path.join(FIXTURES_DIRECTORY, 'requirements-simple.txt'), 'r') as req_file:
41-
with patch('builtins.open', mock_open(read_data=req_file.read())) as mock_req_file:
42-
with patch('cyclonedx_py.client.CycloneDxCmd._validate_file_exists') as mock_exists:
43-
CycloneDxCmd(args).execute()
35+
subprocess.check_output([
36+
'cyclonedx-py',
37+
'-e',
38+
'-o', os.path.join(dirname, 'sbom.xml'),
39+
])
4440

45-
mock_exists.assert_called_with('requirements.txt')
46-
mock_req_file.assert_called()
41+
def text_conda_list_explicit(self):
42+
with tempfile.TemporaryDirectory() as dirname:
43+
# Run command to generate latest 1.3 XML SBOM from Requirements File
44+
subprocess.check_output([
45+
'cyclonedx-bom',
46+
'-c',
47+
'-i', os.path.join(FIXTURES_DIRECTORY, 'conda-list-explicit-simple.txt'),
48+
'-o', os.path.join(dirname, 'sbom.xml'),
49+
])
4750

48-
req_file.close()
51+
with open(os.path.join(dirname, 'sbom.xml'), 'r') as f, \
52+
open(os.path.join(FIXTURES_DIRECTORY, 'bom_v1.3_setuptools.xml')) as expected:
53+
self.assertEqualXmlBom(f.read(), expected.read(),
54+
namespace='http://cyclonedx.org/schema/bom/1.3')
55+
f.close()
56+
expected.close()
4957

5058
def test_requirements_txt_file(self):
5159
with tempfile.TemporaryDirectory() as dirname:
5260
# Run command to generate latest 1.3 XML SBOM from Requirements File
5361
subprocess.check_output([
5462
'cyclonedx-py',
5563
'-r',
56-
'-rf', os.path.join(FIXTURES_DIRECTORY, 'requirements-simple.txt'),
64+
'-i', os.path.join(FIXTURES_DIRECTORY, 'requirements-simple.txt'),
5765
'-o', os.path.join(dirname, 'sbom.xml'),
5866
])
5967

@@ -79,7 +87,7 @@ def _do_test_requirements_txt_file_for_version(self, schema_version: str):
7987
subprocess.check_output([
8088
'cyclonedx-py',
8189
'-r',
82-
'-rf', os.path.join(FIXTURES_DIRECTORY, 'requirements-simple.txt'),
90+
'-i', os.path.join(FIXTURES_DIRECTORY, 'requirements-simple.txt'),
8391
'--schema-version', schema_version,
8492
'-o', os.path.join(dirname, 'sbom.xml'),
8593
])

0 commit comments

Comments
 (0)