Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ the same command against a newer tag with `--force`.
icebergsca scan ./myproject
icebergsca scan ./myproject --format json --output report.json
icebergsca scan ./myproject --include-dev
icebergsca scan ./myproject --ecosystem pypi,npm --exclude 'fixtures/**'
icebergsca scan ./myproject --ecosystem pypi --ecosystem npm --exclude 'fixtures/**'
icebergsca scan ./requirements.txt # a single file works too

icebergsca sbom ./myproject # CycloneDX 1.6, components only
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ classifiers = [
"Topic :: Software Development :: Quality Assurance",
]
dependencies = [
"typer>=0.15",
# 0.26 is where Typer vendored Click. Below that floor an unrelated Click
# upgrade in the same environment can change how this CLI parses and exits.
"typer>=0.26",
"rich>=13.0",
"httpx>=0.27",
"packaging>=24.0",
Expand Down
9 changes: 6 additions & 3 deletions src/icebergsca/.agents/skills/icebergsca/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,15 @@ icebergsca scan . --include-dev # everything
icebergsca scan . --scope runtime # only what ships
```

`--scope` and `--ecosystem` take one value each and are repeated to widen the
selection: `--scope runtime --scope build`.

## Narrowing a scan

```bash
icebergsca scan . --ecosystem pypi,npm # restrict ecosystems
icebergsca scan . --exclude 'tests/fixtures/**' # skip paths
icebergsca scan ./requirements.txt # a single file
icebergsca scan . --ecosystem pypi --ecosystem npm # restrict ecosystems
icebergsca scan . --exclude 'tests/fixtures/**' # skip paths
icebergsca scan ./requirements.txt # a single file
```

Deliberately vulnerable test fixtures are a common source of noise. Exclude them rather than
Expand Down
65 changes: 26 additions & 39 deletions src/icebergsca/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from contextlib import contextmanager
from enum import IntEnum
from pathlib import Path
from typing import Annotated, NoReturn, TypeVar
from typing import Annotated, NoReturn

import typer

Expand All @@ -35,8 +35,9 @@ class ExitCode(IntEnum):
vulnerabilities has done its job and exits :attr:`OK`; severity gating arrives
later as an explicit ``--fail-on`` flag.

:attr:`USAGE` is 2 rather than 1 because Click reserves that code for usage
errors, and remapping it would mean overriding framework internals for nothing.
:attr:`USAGE` is 2 rather than 1 because Typer's vendored Click reserves that
code for usage errors, and remapping it would mean overriding framework
internals for nothing.
"""

OK = 0
Expand All @@ -51,12 +52,13 @@ class ExitCode(IntEnum):
help="Software composition analysis: find dependencies, check them against OSV.",
no_args_is_help=True,
add_completion=False,
# Stated rather than left to the default so that help output does not change
# shape depending on what else happens to be installed.
rich_markup_mode="rich",
)

logger = logging.getLogger("icebergsca")

EnumT = TypeVar("EnumT", Scope, EcosystemId)


def _fail(message: str) -> NoReturn:
typer.secho(f"error: {message}", fg=typer.colors.RED, err=True)
Expand All @@ -77,25 +79,6 @@ def _configure_logging(verbosity: int, quiet: bool) -> None:
)


def _parse_csv_option(
value: str | None, name: str, choices: type[EnumT]
) -> list[EnumT] | None:
"""Split a comma-separated flag into enum members, reporting bad values clearly."""
if not value:
return None
parsed: list[EnumT] = []
valid = {member.value for member in choices}
for item in (part.strip() for part in value.split(",")):
if not item:
continue
if item not in valid:
raise typer.BadParameter(
f"unknown {name} '{item}' — choose from: {', '.join(sorted(valid))}"
)
parsed.append(choices(item))
return parsed or None


def _version_callback(value: bool) -> None:
if value:
typer.echo(f"icebergsca {__version__}")
Expand Down Expand Up @@ -142,15 +125,15 @@ def scan_command(
),
] = False,
scopes: Annotated[
str | None,
list[Scope] | None,
typer.Option(
"--scope",
help="Comma-separated scopes to include, overriding --include-dev.",
help="Scope to include, overriding --include-dev. Repeatable.",
),
] = None,
ecosystems: Annotated[
str | None,
typer.Option("--ecosystem", help="Comma-separated ecosystems to restrict to."),
list[EcosystemId] | None,
typer.Option("--ecosystem", help="Ecosystem to restrict to. Repeatable."),
] = None,
exclude: Annotated[
list[str] | None,
Expand Down Expand Up @@ -202,11 +185,8 @@ def scan_command(
"""Scan a project for dependencies and known vulnerabilities."""
_configure_logging(verbose, quiet)

selected_scopes = _parse_csv_option(scopes, "scope", Scope)
selected_ecosystems = _parse_csv_option(ecosystems, "ecosystem", EcosystemId)

if selected_scopes is not None:
scope_set = frozenset(selected_scopes)
if scopes:
scope_set = frozenset(scopes)
elif include_dev:
scope_set = frozenset(Scope)
else:
Expand All @@ -220,7 +200,7 @@ def scan_command(
exclude=tuple(exclude or ()),
max_depth=max_depth,
follow_symlinks=follow_symlinks,
ecosystems=frozenset(selected_ecosystems) if selected_ecosystems else None,
ecosystems=frozenset(ecosystems) if ecosystems else None,
),
scopes=scope_set,
offline=offline,
Expand Down Expand Up @@ -297,13 +277,17 @@ def sbom_command(
"and no network calls to OSV.",
),
] = False,
verbose: Annotated[int, typer.Option("-v", "--verbose", count=True)] = 0,
quiet: Annotated[bool, typer.Option("-q", "--quiet")] = False,
verbose: Annotated[
int, typer.Option("-v", "--verbose", count=True, help="Increase log verbosity.")
] = 0,
quiet: Annotated[
bool, typer.Option("-q", "--quiet", help="Only log errors.")
] = False,
) -> None:
"""Emit a CycloneDX 1.6 SBOM.

Equivalent to ``scan --format cyclonedx``, except that it defaults to components
only — an SBOM is often wanted for inventory rather than for findings.
Equivalent to [bold]scan --format cyclonedx[/bold], except that it defaults to
components only — an SBOM is often wanted for inventory rather than for findings.
"""
_configure_logging(verbose, quiet)

Expand Down Expand Up @@ -340,7 +324,10 @@ def sbom_command(
# cache
# ---------------------------------------------------------------------------

cache_app = typer.Typer(help="Inspect and manage the on-disk OSV cache.")
cache_app = typer.Typer(
help="Inspect and manage the on-disk OSV cache.",
no_args_is_help=True,
)
app.add_typer(cache_app, name="cache")


Expand Down
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import re
from pathlib import Path

import pytest
Expand All @@ -14,6 +15,19 @@

runner = CliRunner()

_ANSI = re.compile(r"\x1b\[[0-9;]*m")


def plain(text: str) -> str:
"""Strip the styling Rich applies when it thinks it is writing to a terminal.

Rich treats CI as a terminal, so help output is bare locally but styled under
GitHub Actions — and it styles each choice in a metavar separately, which puts
escape codes between every value. Anything asserting on rendered help has to
strip them or it passes only off CI.
"""
return _ANSI.sub("", text)


def project(tmp_path: Path) -> Path:
(tmp_path / "requirements.txt").write_text("requests==2.31.0\nflask>=2.0\n")
Expand Down Expand Up @@ -99,6 +113,29 @@ def test_ecosystem_filter(tmp_path: Path) -> None:
assert {m["ecosystem"] for m in document["manifests"]} == {"PyPI"}


def test_scope_flag_is_repeatable(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "x"\nversion = "1"\ndependencies = ["httpx>=0.27"]\n\n'
'[project.optional-dependencies]\ndev = ["ruff>=0.9"]\n'
)
result = runner.invoke(
app,
["scan", str(tmp_path), "--format", "json", "--scope", "runtime"]
+ ["--scope", "dev"],
)
names = {e["package"]["name"] for e in json.loads(result.stdout)["dependencies"]}
assert names == {"httpx", "ruff"}


def test_enum_choices_are_listed_in_help() -> None:
"""A bare ``<str>`` metavar tells a reader nothing about what is accepted."""
# Wide enough that Rich does not wrap the metavar column mid-value.
result = runner.invoke(app, ["scan", "--help"], env={"COLUMNS": "200"})
rendered = plain(result.stdout)
assert "<pypi|npm|maven|go|cargo|nuget|rubygems>" in rendered
assert "<runtime|dev|test|build|optional>" in rendered


def test_exclude_glob(tmp_path: Path) -> None:
project(tmp_path)
(tmp_path / "sub").mkdir()
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions website/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ icebergsca scan <path>

| Flag | Effect |
|---|---|
| `--ecosystem <list>` | Comma-separated ecosystems to restrict to: `pypi`, `npm`, `maven`, `go`, `cargo`, `nuget`, `rubygems` |
| `--ecosystem <name>` | Ecosystem to restrict to: `pypi`, `npm`, `maven`, `go`, `cargo`, `nuget`, `rubygems`. Repeatable |
| `--exclude <glob>` | Glob to skip. Repeatable |
| `--max-depth <n>` | Maximum directory depth to walk |
| `--follow-symlinks` | Follow symlinked directories |
| `--include-dev` | Include dev and test dependencies, which are excluded by default |
| `--scope <list>` | Comma-separated scopes to include, overriding `--include-dev`: `runtime`, `dev`, `test`, `build`, `optional` |
| `--scope <name>` | Scope to include, overriding `--include-dev`: `runtime`, `dev`, `test`, `build`, `optional`. Repeatable |

```bash
icebergsca scan . --ecosystem pypi,npm --exclude 'tests/fixtures/**'
icebergsca scan . --ecosystem pypi --ecosystem npm --exclude 'tests/fixtures/**'
icebergsca scan . --scope runtime # only what ships
icebergsca scan ./requirements.txt # a single file
```
Expand Down
4 changes: 2 additions & 2 deletions website/docs/ecosystems.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ filename, so a file only has to be where a build tool would put it.
| .NET | `nuget` | `*.csproj`, `packages.config` | `packages.lock.json` | yes |
| Ruby | `rubygems` | `Gemfile`, `*.gemspec` | `Gemfile.lock` | yes |

Restrict a scan with a comma-separated list:
Restrict a scan by repeating the flag:

```bash
icebergsca scan . --ecosystem pypi,npm
icebergsca scan . --ecosystem pypi --ecosystem npm
```

!!! note "Three vocabularies that genuinely disagree"
Expand Down