Skip to content

Commit 95c9373

Browse files
colinmoynesCopilot
andauthored
Ceng 728 add command to return vulnerability results (#272)
Added vulnerabilities (-vuln) command to a retrieve security scan results against a package Summary View (Default): Displays a high-level count of vulnerabilities broken down by severity (Critical, High, Medium, Low, Unknown). Assessment View --show-assessment (-A): Provides a detailed breakdown where vulnerabilities are: Grouped by the specific affected upstream package / dependency. Sorted by severity (Critical first). Richly formatted tables. Filtering Capabilities: By Severity: --severity Show only specific levels (e.g., just Critical and High). By Status: --fixable | --non-fixable Filter to show only "Fixable" vulnerabilities (where a patch exists) or "Non-Fixable" ones. Supports --output-format json | pretty_json for programmatic usage General changes: Added "rich" to the setup.py which is used for rich formatting of tables. New rich_print_table() function added to utils.py. Test cases added. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 7dc1064 commit 95c9373

File tree

10 files changed

+532
-1
lines changed

10 files changed

+532
-1
lines changed

.envrc

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ fi
3535
if [ ! -d ".venv" ]; then
3636
if "$PYTHON_BIN" -c 'import uv' >/dev/null 2>&1; then
3737
# uv accepts a path for --python; avoids version mismatch
38-
"$PYTHON_BIN" -m uv venv .venv --python "$PYTHON_BIN"
38+
"$PYTHON_BIN" -m uv venv .venv --python "$PYTHON_BIN" --seed
3939
else
4040
"$PYTHON_BIN" -m venv .venv
4141
fi
@@ -51,8 +51,11 @@ python -m pip install -U uv
5151
# --- Install the project in editable mode (prefer uv pip; fallback to pip)
5252
if python -m uv --help >/dev/null 2>&1; then
5353
python -m uv pip install -e .
54+
# Install dev dependencies
55+
[ -f requirements.in ] && python -m uv pip install -r requirements.in
5456
else
5557
pip install -e .
58+
[ -f requirements.in ] && pip install -r requirements.in
5659
fi
5760

5861
# --- Load environment variables from .env (create from example if missing)

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99
- Added `--tag` option to `download` command for filtering packages by tags
1010
- Added download command documentation to README with comprehensive usage examples
1111

12+
## [1.14.0] - 2026-03-11
13+
14+
### Added
15+
16+
- Added `vulnerabilities` command to retrieve security scan results for a package
17+
- Summary View (Default): Displays a high-level count of vulnerabilities broken down by severity (Critical, High, Medium, Low, Unknown).
18+
- Assessment View `--show-assessment` (`-A`): Provides a detailed breakdown where vulnerabilities are:
19+
- Grouped by the specific affected upstream package / dependency.
20+
- Sorted by severity (Critical first).
21+
- Richly formatted tables.
22+
- Filtering Capabilities:
23+
- By Severity: `--severity` Show only specific levels (e.g., just Critical and High).
24+
- By Status: `--fixable | --non-fixable` Filter to show only "Fixable" vulnerabilities (where a patch exists) or "Non-Fixable" ones.
25+
- Supports `--output-format json | pretty_json` for programmatic usage
26+
27+
1228
## [1.13.0] - 2026-02-16
1329

1430
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ The CLI currently supports the following commands (and sub-commands):
114114
- `rpm`: Manage rpm upstreams for a repository.
115115
- `ruby`: Manage ruby upstreams for a repository.
116116
- `swift`: Manage swift upstreams for a repository.
117+
- `vulnerabilities`: Retrieve vulnerability results for a package.
117118
- `whoami`: Retrieve your current authentication status.
118119

119120
## Installation

cloudsmith_cli/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@
2626
tags,
2727
tokens,
2828
upstream,
29+
vulnerabilities,
2930
whoami,
3031
)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""CLI/Commands - Vulnerabilities."""
2+
3+
import click
4+
5+
from ...core.api.vulnerabilities import (
6+
_print_vulnerabilities_assessment_table,
7+
_print_vulnerabilities_summary_table,
8+
get_package_scan_result,
9+
)
10+
from .. import decorators, utils, validators
11+
from ..exceptions import handle_api_exceptions
12+
from .main import main
13+
14+
15+
@main.command()
16+
@decorators.common_cli_config_options
17+
@decorators.common_cli_output_options
18+
@decorators.common_api_auth_options
19+
@decorators.initialise_api
20+
@click.argument(
21+
"owner_repo_package",
22+
metavar="OWNER/REPO/PACKAGE",
23+
callback=validators.validate_owner_repo_package,
24+
)
25+
@click.option(
26+
"-A",
27+
"--show-assessment",
28+
is_flag=True,
29+
help="Show assessment with vulnerability details.",
30+
)
31+
@click.option(
32+
"--fixable/--non-fixable",
33+
is_flag=True,
34+
default=None, # Changed to allow None (Show All) vs True/False
35+
help="Filter by fixable status (only fixable vs only non-fixable).",
36+
)
37+
@click.option(
38+
"--severity",
39+
"severity_filter",
40+
help="Filter by severities (e.g., 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW').",
41+
)
42+
@click.pass_context
43+
def vulnerabilities(
44+
ctx, opts, owner_repo_package, show_assessment, fixable, severity_filter
45+
):
46+
"""
47+
Retrieve vulnerability scan results for a package.
48+
49+
\b
50+
Usage:
51+
cloudsmith vulnerabilities myorg/repo/pkg_identifier [flags]
52+
53+
\b
54+
Aliases:
55+
vulnerabilities, vuln
56+
57+
Examples:
58+
59+
\b
60+
# Display the vulnerability summary
61+
cloudsmith vulnerabilities myorg/repo/pkg_identifier
62+
63+
\b
64+
# Display detailed vulnerability assessment
65+
cloudsmith vulnerabilities myorg/repo/pkg_identifier -A / --show-assessment
66+
67+
\b
68+
# Filter the result by severity
69+
cloudsmith vulnerabilities myorg/repo/pkg_identifier --severity critical,high
70+
71+
\b
72+
# Filter by fixable or non-fixable vulnerabilities
73+
cloudsmith vulnerabilities myorg/repo/pkg_identifier --fixable / --non-fixable
74+
75+
76+
"""
77+
use_stderr = utils.should_use_stderr(opts)
78+
79+
owner, repo, slug = owner_repo_package
80+
81+
total_filtered_vulns = 0
82+
83+
context_msg = "Failed to retrieve vulnerability report!"
84+
with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg):
85+
with utils.maybe_spinner(opts):
86+
data = get_package_scan_result(
87+
opts=opts,
88+
owner=owner,
89+
repo=repo,
90+
package=slug,
91+
show_assessment=show_assessment,
92+
severity_filter=severity_filter,
93+
fixable=fixable,
94+
)
95+
96+
click.secho("OK", fg="green", err=use_stderr)
97+
98+
# Filter results if severity or fixable flags are active
99+
if severity_filter or fixable is not None:
100+
scans = getattr(data, "scans", [])
101+
102+
allowed_severities = (
103+
[s.strip().lower() for s in severity_filter.split(",")]
104+
if severity_filter
105+
else None
106+
)
107+
108+
for scan in scans:
109+
results = getattr(scan, "results", [])
110+
111+
# 1. Filter by Severity
112+
if allowed_severities:
113+
results = [
114+
res
115+
for res in results
116+
if getattr(res, "severity", "unknown").lower() in allowed_severities
117+
]
118+
119+
# 2. Filter by Fixable Status
120+
# fixable=True: Keep only if has fix_version
121+
# fixable=False: Keep only if NO fix_version
122+
if fixable is not None:
123+
results = [
124+
res
125+
for res in results
126+
if bool(
127+
getattr(res, "fix_version", getattr(res, "fixed_version", None))
128+
)
129+
is fixable
130+
]
131+
132+
scan.results = results
133+
total_filtered_vulns += len(results)
134+
135+
if utils.maybe_print_as_json(opts, data):
136+
return
137+
138+
_print_vulnerabilities_summary_table(data, severity_filter, total_filtered_vulns)
139+
140+
if show_assessment:
141+
_print_vulnerabilities_assessment_table(data, severity_filter)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import unittest
2+
from unittest.mock import patch
3+
4+
from click.testing import CliRunner
5+
6+
from cloudsmith_cli.cli.commands.vulnerabilities import vulnerabilities
7+
8+
9+
class TestVulnerabilitiesCommand(unittest.TestCase):
10+
def setUp(self):
11+
self.runner = CliRunner()
12+
13+
@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
14+
def test_vulnerabilities_basic(self, mock_get_scan):
15+
"""Test basic vulnerabilities command invocation."""
16+
result = self.runner.invoke(
17+
vulnerabilities,
18+
[
19+
"testorg/testrepo/pkg-slug",
20+
],
21+
)
22+
23+
self.assertEqual(result.exit_code, 0)
24+
mock_get_scan.assert_called_once()
25+
26+
# Verify args passed to core logic
27+
args = mock_get_scan.call_args[1]
28+
self.assertEqual(args["owner"], "testorg")
29+
self.assertEqual(args["repo"], "testrepo")
30+
self.assertEqual(args["package"], "pkg-slug")
31+
self.assertFalse(args["show_assessment"])
32+
self.assertIsNone(args["severity_filter"])
33+
34+
@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
35+
def test_vulnerabilities_show_assessment(self, mock_get_scan):
36+
"""Test vulnerabilities command with --show-assessment flag."""
37+
result = self.runner.invoke(
38+
vulnerabilities,
39+
[
40+
"testorg/testrepo/pkg-slug",
41+
"--show-assessment",
42+
],
43+
)
44+
45+
self.assertEqual(result.exit_code, 0)
46+
args = mock_get_scan.call_args[1]
47+
self.assertTrue(args["show_assessment"])
48+
49+
@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
50+
def test_vulnerabilities_alias_flags(self, mock_get_scan):
51+
"""Test vulnerabilities command with short flags (-A)."""
52+
result = self.runner.invoke(
53+
vulnerabilities,
54+
[
55+
"testorg/testrepo/pkg-slug",
56+
"-A",
57+
],
58+
)
59+
60+
self.assertEqual(result.exit_code, 0)
61+
args = mock_get_scan.call_args[1]
62+
self.assertTrue(args["show_assessment"])
63+
64+
@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
65+
def test_vulnerabilities_severity_filter(self, mock_get_scan):
66+
"""Test vulnerabilities command with --severity filter."""
67+
result = self.runner.invoke(
68+
vulnerabilities,
69+
[
70+
"testorg/testrepo/pkg-slug",
71+
"--severity",
72+
"CRITICAL,HIGH",
73+
],
74+
)
75+
76+
self.assertEqual(result.exit_code, 0)
77+
args = mock_get_scan.call_args[1]
78+
self.assertEqual(args["severity_filter"], "CRITICAL,HIGH")
79+
80+
@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
81+
def test_vulnerabilities_fixable_filter(self, mock_get_scan):
82+
"""Test vulnerabilities command with --fixable filter."""
83+
result = self.runner.invoke(
84+
vulnerabilities,
85+
[
86+
"testorg/testrepo/pkg-slug",
87+
"--fixable",
88+
],
89+
)
90+
91+
self.assertEqual(result.exit_code, 0)
92+
args = mock_get_scan.call_args[1]
93+
self.assertTrue(args["fixable"])
94+
95+
@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
96+
def test_vulnerabilities_non_fixable_filter(self, mock_get_scan):
97+
"""Test vulnerabilities command with --non-fixable filter."""
98+
result = self.runner.invoke(
99+
vulnerabilities,
100+
[
101+
"testorg/testrepo/pkg-slug",
102+
"--non-fixable",
103+
],
104+
)
105+
106+
self.assertEqual(result.exit_code, 0)
107+
args = mock_get_scan.call_args[1]
108+
self.assertFalse(args["fixable"])
109+
110+
111+
if __name__ == "__main__":
112+
unittest.main()

cloudsmith_cli/cli/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import click
99
from click_spinner import spinner
10+
from rich.console import Console
11+
from rich.table import Table
1012

1113
from ..core.api.version import get_version as get_api_version
1214
from ..core.version import get_version as get_cli_version
@@ -89,6 +91,28 @@ def pretty_print_row(styled, plain):
8991
pretty_print_row(row, table.plain_rows[k])
9092

9193

94+
def rich_print_table(headers, rows, title=None, show_lines=False):
95+
"""Rich table from headers and rows."""
96+
console = Console()
97+
table = Table(title=title, show_lines=show_lines)
98+
99+
for header in headers:
100+
if isinstance(header, dict):
101+
table.add_column(
102+
header.get("header", ""),
103+
justify=header.get("justify", "left"),
104+
style=header.get("style", "none"),
105+
no_wrap=header.get("no_wrap", False),
106+
)
107+
else:
108+
table.add_column(str(header))
109+
110+
for row in rows:
111+
table.add_row(*row)
112+
113+
console.print(table)
114+
115+
92116
def print_rate_limit_info(opts, rate_info):
93117
"""Tell the user when we're being rate limited."""
94118
if not rate_info:

0 commit comments

Comments
 (0)