Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ae90e5a
initial
colinmoynes Mar 5, 2026
fb33d81
added severity_filter flag
colinmoynes Mar 5, 2026
48b2548
added html prototype flag
colinmoynes Mar 5, 2026
080aa69
improvied html report formatting
colinmoynes Mar 5, 2026
571fcf6
Added fixable/non-fixable filter flags
colinmoynes Mar 5, 2026
30a36d1
added used filters to report output
colinmoynes Mar 5, 2026
e4e9adf
fixed html
colinmoynes Mar 5, 2026
4312cc7
filtering improvements and output
colinmoynes Mar 10, 2026
d048d18
show assessment flag now shows tablular and parsed view. accepts seve…
colinmoynes Mar 10, 2026
54a7ec2
improved url formatting
colinmoynes Mar 10, 2026
93f2cfb
update html field for scan date
colinmoynes Mar 10, 2026
9ffd10d
Added unit tests for vulnerabilities command
colinmoynes Mar 10, 2026
8012fcd
added affected version to results
colinmoynes Mar 10, 2026
e6d4106
moved out the html report generation for this release
colinmoynes Mar 10, 2026
73e3c7a
remove vulnerability html testcase
colinmoynes Mar 10, 2026
b145e01
Updated changelog and readme
colinmoynes Mar 10, 2026
5a914c6
updated changelog to 1.14.0 release
colinmoynes Mar 10, 2026
9d2c6cb
minor formatting and informational text updates
colinmoynes Mar 11, 2026
bcfec2c
Implemented rich table formatting. New rich table function in utils. …
colinmoynes Mar 11, 2026
8b830c6
removed reference url and added version operator
colinmoynes Mar 11, 2026
01fbf1c
Fixed changelog
colinmoynes Mar 11, 2026
7327499
fix testcase
colinmoynes Mar 11, 2026
ea0d99e
updated requirements.txt and test case issue
colinmoynes Mar 11, 2026
c481473
test cases updated
colinmoynes Mar 11, 2026
653446e
Update CHANGELOG.md
colinmoynes Mar 11, 2026
9305f03
Update cloudsmith_cli/cli/commands/vulnerabilities.py
colinmoynes Mar 11, 2026
79ddb28
restored and corrected requirements.txt
colinmoynes Mar 12, 2026
bf5cbda
Fixed readme, moved print logic from core/api to command.
colinmoynes Mar 13, 2026
7c1af0f
updated envrc to remove comment
colinmoynes Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .envrc
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ fi
if [ ! -d ".venv" ]; then
if "$PYTHON_BIN" -c 'import uv' >/dev/null 2>&1; then
# uv accepts a path for --python; avoids version mismatch
"$PYTHON_BIN" -m uv venv .venv --python "$PYTHON_BIN"
"$PYTHON_BIN" -m uv venv .venv --python "$PYTHON_BIN" --seed
else
"$PYTHON_BIN" -m venv .venv
fi
Expand All @@ -51,8 +51,11 @@ python -m pip install -U uv
# --- Install the project in editable mode (prefer uv pip; fallback to pip)
if python -m uv --help >/dev/null 2>&1; then
python -m uv pip install -e .
# Install dev dependencies
[ -f requirements.in ] && python -m uv pip install -r requirements.in
else
pip install -e .
[ -f requirements.in ] && pip install -r requirements.in
fi

# --- Load environment variables from .env (create from example if missing)
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Added `--tag` option to `download` command for filtering packages by tags
- Added download command documentation to README with comprehensive usage examples

## [1.14.0] - 2026-03-11

### Added

- Added `vulnerabilities` command to retrieve security scan results for 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


## [1.13.0] - 2026-02-16

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ The CLI currently supports the following commands (and sub-commands):
- `rpm`: Manage rpm upstreams for a repository.
- `ruby`: Manage ruby upstreams for a repository.
- `swift`: Manage swift upstreams for a repository.
- `vulnerabilities`: Retrieve vulnerability results for a package.
- `whoami`: Retrieve your current authentication status.

## Installation
Expand Down
1 change: 1 addition & 0 deletions cloudsmith_cli/cli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@
tags,
tokens,
upstream,
vulnerabilities,
whoami,
)
141 changes: 141 additions & 0 deletions cloudsmith_cli/cli/commands/vulnerabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""CLI/Commands - Vulnerabilities."""

import click

from ...core.api.vulnerabilities import (
_print_vulnerabilities_assessment_table,
_print_vulnerabilities_summary_table,
get_package_scan_result,
)
from .. import decorators, utils, validators
from ..exceptions import handle_api_exceptions
from .main import main


@main.command()
@decorators.common_cli_config_options
@decorators.common_cli_output_options
@decorators.common_api_auth_options
@decorators.initialise_api
@click.argument(
"owner_repo_package",
metavar="OWNER/REPO/PACKAGE",
callback=validators.validate_owner_repo_package,
)
@click.option(
"-A",
"--show-assessment",
is_flag=True,
help="Show assessment with vulnerability details.",
)
@click.option(
"--fixable/--non-fixable",
is_flag=True,
default=None, # Changed to allow None (Show All) vs True/False
help="Filter by fixable status (only fixable vs only non-fixable).",
)
@click.option(
"--severity",
"severity_filter",
help="Filter by severities (e.g., 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW').",
)
@click.pass_context
def vulnerabilities(
ctx, opts, owner_repo_package, show_assessment, fixable, severity_filter
):
"""
Retrieve vulnerability scan results for a package.

\b
Usage:
cloudsmith vulnerabilities myorg/repo/pkg_identifier [flags]

\b
Aliases:
vulnerabilities, vuln

Examples:

\b
# Display the vulnerability summary
cloudsmith vulnerabilities myorg/repo/pkg_identifier

\b
# Display detailed vulnerability assessment
cloudsmith vulnerabilities myorg/repo/pkg_identifier -A / --show-assessment

\b
# Filter the result by severity
cloudsmith vulnerabilities myorg/repo/pkg_identifier --severity critical,high

\b
# Filter by fixable or non-fixable vulnerabilities
cloudsmith vulnerabilities myorg/repo/pkg_identifier --fixable / --non-fixable


"""
use_stderr = utils.should_use_stderr(opts)

owner, repo, slug = owner_repo_package

total_filtered_vulns = 0

context_msg = "Failed to retrieve vulnerability report!"
with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg):
with utils.maybe_spinner(opts):
data = get_package_scan_result(
opts=opts,
owner=owner,
repo=repo,
package=slug,
show_assessment=show_assessment,
severity_filter=severity_filter,
fixable=fixable,
)

click.secho("OK", fg="green", err=use_stderr)

# Filter results if severity or fixable flags are active
if severity_filter or fixable is not None:
scans = getattr(data, "scans", [])

allowed_severities = (
[s.strip().lower() for s in severity_filter.split(",")]
if severity_filter
else None
)

for scan in scans:
results = getattr(scan, "results", [])

# 1. Filter by Severity
if allowed_severities:
results = [
res
for res in results
if getattr(res, "severity", "unknown").lower() in allowed_severities
]

# 2. Filter by Fixable Status
# fixable=True: Keep only if has fix_version
# fixable=False: Keep only if NO fix_version
if fixable is not None:
results = [
res
for res in results
if bool(
getattr(res, "fix_version", getattr(res, "fixed_version", None))
)
is fixable
]

scan.results = results
total_filtered_vulns += len(results)

if utils.maybe_print_as_json(opts, data):
return

_print_vulnerabilities_summary_table(data, severity_filter, total_filtered_vulns)

if show_assessment:
_print_vulnerabilities_assessment_table(data, severity_filter)
112 changes: 112 additions & 0 deletions cloudsmith_cli/cli/tests/commands/test_vulnerabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import unittest
from unittest.mock import patch

from click.testing import CliRunner

from cloudsmith_cli.cli.commands.vulnerabilities import vulnerabilities


class TestVulnerabilitiesCommand(unittest.TestCase):
def setUp(self):
self.runner = CliRunner()

@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
def test_vulnerabilities_basic(self, mock_get_scan):
"""Test basic vulnerabilities command invocation."""
result = self.runner.invoke(
vulnerabilities,
[
"testorg/testrepo/pkg-slug",
],
)

self.assertEqual(result.exit_code, 0)
mock_get_scan.assert_called_once()

# Verify args passed to core logic
args = mock_get_scan.call_args[1]
self.assertEqual(args["owner"], "testorg")
self.assertEqual(args["repo"], "testrepo")
self.assertEqual(args["package"], "pkg-slug")
self.assertFalse(args["show_assessment"])
self.assertIsNone(args["severity_filter"])

@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
def test_vulnerabilities_show_assessment(self, mock_get_scan):
"""Test vulnerabilities command with --show-assessment flag."""
result = self.runner.invoke(
vulnerabilities,
[
"testorg/testrepo/pkg-slug",
"--show-assessment",
],
)

self.assertEqual(result.exit_code, 0)
args = mock_get_scan.call_args[1]
self.assertTrue(args["show_assessment"])

@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
def test_vulnerabilities_alias_flags(self, mock_get_scan):
"""Test vulnerabilities command with short flags (-A)."""
result = self.runner.invoke(
vulnerabilities,
[
"testorg/testrepo/pkg-slug",
"-A",
],
)

self.assertEqual(result.exit_code, 0)
args = mock_get_scan.call_args[1]
self.assertTrue(args["show_assessment"])

@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
def test_vulnerabilities_severity_filter(self, mock_get_scan):
"""Test vulnerabilities command with --severity filter."""
result = self.runner.invoke(
vulnerabilities,
[
"testorg/testrepo/pkg-slug",
"--severity",
"CRITICAL,HIGH",
],
)

self.assertEqual(result.exit_code, 0)
args = mock_get_scan.call_args[1]
self.assertEqual(args["severity_filter"], "CRITICAL,HIGH")

@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
def test_vulnerabilities_fixable_filter(self, mock_get_scan):
"""Test vulnerabilities command with --fixable filter."""
result = self.runner.invoke(
vulnerabilities,
[
"testorg/testrepo/pkg-slug",
"--fixable",
],
)

self.assertEqual(result.exit_code, 0)
args = mock_get_scan.call_args[1]
self.assertTrue(args["fixable"])

@patch("cloudsmith_cli.cli.commands.vulnerabilities.get_package_scan_result")
def test_vulnerabilities_non_fixable_filter(self, mock_get_scan):
"""Test vulnerabilities command with --non-fixable filter."""
result = self.runner.invoke(
vulnerabilities,
[
"testorg/testrepo/pkg-slug",
"--non-fixable",
],
)

self.assertEqual(result.exit_code, 0)
args = mock_get_scan.call_args[1]
self.assertFalse(args["fixable"])


if __name__ == "__main__":
unittest.main()
24 changes: 24 additions & 0 deletions cloudsmith_cli/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import click
from click_spinner import spinner
from rich.console import Console
from rich.table import Table

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


def rich_print_table(headers, rows, title=None, show_lines=False):
"""Rich table from headers and rows."""
console = Console()
table = Table(title=title, show_lines=show_lines)

for header in headers:
if isinstance(header, dict):
table.add_column(
header.get("header", ""),
justify=header.get("justify", "left"),
style=header.get("style", "none"),
no_wrap=header.get("no_wrap", False),
)
else:
table.add_column(str(header))

for row in rows:
table.add_row(*row)

console.print(table)


def print_rate_limit_info(opts, rate_info):
"""Tell the user when we're being rate limited."""
if not rate_info:
Expand Down
Loading
Loading