vulnctl 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vulnctl-0.2.0 → vulnctl-0.2.2}/CHANGELOG.md +29 -1
- {vulnctl-0.2.0 → vulnctl-0.2.2}/CLAUDE.md +1 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/PKG-INFO +1 -1
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/cli.md +6 -2
- {vulnctl-0.2.0 → vulnctl-0.2.2}/pyproject.toml +1 -1
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/cli.py +2 -0
- vulnctl-0.2.2/src/vulnctl/cli_help.py +191 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_cli.py +69 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/uv.lock +1 -1
- {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/ci.yml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/live-smoke.yml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/publish-testpypi.yml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/.github/workflows/release.yml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/.gitignore +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/.pre-commit-config.yaml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/.python-version +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/FRAMEWORK.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/LICENSE +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/README.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/ROADMAP.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/SPEC.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/announcement.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/case-study.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/context.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/demo.tape +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/exit-codes.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/output.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/releasing.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/schema.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/schema.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/docs/trees.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/examples/app.cdx.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/examples/ci/vulnctl-gate.yml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/examples/context.yaml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/scripts/build_exploit_index.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/scripts/case_study_stats.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/__init__.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/__init__.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/base.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/epss.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/exploits.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/ghsa.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/kev.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/nvd.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/adapters/osv.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/cache.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/commands.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/context.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/__init__.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/epss_snapshot.csv.gz +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/exploit_index.json.gz +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/data/kev_snapshot.json.gz +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/__init__.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/cve_list.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/cyclonedx.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ingest/grype.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/models.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/__init__.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/explain.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/json_out.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/markdown.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/progress.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/render.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/sarif.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/output/table.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/pipeline.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/py.typed +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/__init__.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/engine.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/tree.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/src/vulnctl/ssvc/trees/cisa-deployer-v1.yaml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/conftest.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/epss/batch.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/epss/malformed.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/epss/missing.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/advisory-by-cve.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/advisory-by-ghsa-id.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/malformed.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/not-found-empty-list.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/ghsa/rate-limited.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/golden/enrich.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/golden/enrich.md +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/grype/duplicate-layers.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/grype/ghsa-only.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/grype/npm-app.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/kev/catalog.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/cve-2021-44228.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/multiple-cvss.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/not-found.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/rejected.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/nvd/v2-only.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/cve-2021-44228.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/malformed.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/not-found.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/querybatch-npm-app.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/querybatch.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-cve-record.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-ghsa-no-cve.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-ghsa-with-cve.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-go.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/osv/vuln-pypi.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sarif/sarif-schema-2.1.0.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sbom/npm-app-1.6.cdx.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sbom/npm-app.cdx.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/sbom/py-app.cdx.json +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/fixtures/trees/toy.yaml +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/live/test_live_smoke.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_epss.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_exploits.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_ghsa.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_kev.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_nvd.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapter_osv.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_adapters_base.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_cache.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_context.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ingest_cve_list.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ingest_cyclonedx.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ingest_grype.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_models.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_explain.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_json.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_markdown.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_progress.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_sarif.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_output_table.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_pipeline.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ssvc_cisa_tree.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ssvc_engine.py +0 -0
- {vulnctl-0.2.0 → vulnctl-0.2.2}/tests/test_ssvc_tree.py +0 -0
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] - 2026-08-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Root help type column now matches Typer's own spelling.** The generated
|
|
15
|
+
"Options by command" panel derived its type column itself (`PATH`, `TEXT`),
|
|
16
|
+
which disagreed with the per-command panels on Typer 0.27+ — what a fresh
|
|
17
|
+
`pipx install vulnctl` resolves — where Typer prints `<path>`, `<str>`. It
|
|
18
|
+
now delegates to Click's metavar, so both spellings track the installed
|
|
19
|
+
version. Arguments drop the column entirely: their metavar already names
|
|
20
|
+
what they take.
|
|
21
|
+
|
|
22
|
+
## [0.2.1] - 2026-08-01
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Self-contained `vulnctl --help`**: the root help now ends with an
|
|
27
|
+
**Options by command** panel — every command's arguments and flags, what
|
|
28
|
+
each takes, and what it does — and an **Examples** panel covering the
|
|
29
|
+
offline, SBOM, CI-gating, filtering, and `explain` workflows. Previously the
|
|
30
|
+
root help named the three commands and nothing else, so discovering a flag
|
|
31
|
+
meant running `--help` once per command. The summary is generated by walking
|
|
32
|
+
the registered parameters, so it cannot fall out of sync with the real
|
|
33
|
+
signatures. Per-command `--help` is unchanged and remains the place for
|
|
34
|
+
defaults, value ranges, and exit codes.
|
|
35
|
+
|
|
10
36
|
## [0.2.0] - 2026-08-01
|
|
11
37
|
|
|
12
38
|
Makes the output actionable and readable, and accepts GHSA identifiers
|
|
@@ -116,6 +142,8 @@ that produced it.
|
|
|
116
142
|
sign with keyless cosign (OIDC), and publish to PyPI via trusted publishing
|
|
117
143
|
(no stored token).
|
|
118
144
|
|
|
119
|
-
[Unreleased]: https://github.com/NokiGuard/vulnctl/compare/v0.2.
|
|
145
|
+
[Unreleased]: https://github.com/NokiGuard/vulnctl/compare/v0.2.2...HEAD
|
|
146
|
+
[0.2.2]: https://github.com/NokiGuard/vulnctl/compare/v0.2.1...v0.2.2
|
|
147
|
+
[0.2.1]: https://github.com/NokiGuard/vulnctl/compare/v0.2.0...v0.2.1
|
|
120
148
|
[0.2.0]: https://github.com/NokiGuard/vulnctl/compare/v0.1.0...v0.2.0
|
|
121
149
|
[0.1.0]: https://github.com/NokiGuard/vulnctl/releases/tag/v0.1.0
|
|
@@ -38,6 +38,7 @@ All four (pytest, ruff check, ruff format, mypy) must pass before any commit.
|
|
|
38
38
|
```
|
|
39
39
|
src/vulnctl/
|
|
40
40
|
├── cli.py # Typer app wiring + cache commands; thin — no business logic here
|
|
41
|
+
├── cli_help.py # root --help extras: generated flag summary + examples panels
|
|
41
42
|
├── commands.py # enrich/explain command signatures; dispatch only
|
|
42
43
|
├── models.py # Pydantic: Finding, Enrichment, Verdict, DecisionPath
|
|
43
44
|
├── ingest/ # cve_list.py, cyclonedx.py, spdx.py, grype.py, trivy.py
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# vulnctl CLI reference
|
|
2
2
|
|
|
3
|
-
Every command, argument, and option. `vulnctl --help`
|
|
4
|
-
|
|
3
|
+
Every command, argument, and option. `vulnctl --help` prints the same thing
|
|
4
|
+
from the installed version — an **Options by command** panel covering every
|
|
5
|
+
command's flags, plus worked **Examples** — and `--help` on a subcommand
|
|
6
|
+
prints that one command in full, including defaults and value ranges. The
|
|
7
|
+
root summary is generated from the registered parameters, so it cannot drift
|
|
8
|
+
from the real signatures.
|
|
5
9
|
|
|
6
10
|
```
|
|
7
11
|
vulnctl [OPTIONS] COMMAND [ARGS]...
|
|
@@ -11,10 +11,12 @@ from rich.table import Table
|
|
|
11
11
|
|
|
12
12
|
from vulnctl import commands
|
|
13
13
|
from vulnctl.cache import Cache
|
|
14
|
+
from vulnctl.cli_help import HelpfulGroup
|
|
14
15
|
from vulnctl.commands import console
|
|
15
16
|
|
|
16
17
|
app = typer.Typer(
|
|
17
18
|
name="vulnctl",
|
|
19
|
+
cls=HelpfulGroup, # root --help also lists every command's flags + examples
|
|
18
20
|
help="Auditable, SSVC-based vulnerability prioritization.",
|
|
19
21
|
no_args_is_help=True,
|
|
20
22
|
)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Extra panels for the root ``vulnctl --help``.
|
|
2
|
+
|
|
3
|
+
Typer's root help lists the commands but none of their flags, so a first-time
|
|
4
|
+
user has to run ``--help`` once per command before they know what the tool can
|
|
5
|
+
do. :class:`HelpfulGroup` appends two panels to the root help only: every
|
|
6
|
+
command's parameters — walked from the registered Click objects, so the
|
|
7
|
+
summary cannot drift from the real signatures — and worked examples of the
|
|
8
|
+
common workflows.
|
|
9
|
+
|
|
10
|
+
Per-command ``--help`` is untouched; it remains the place for defaults,
|
|
11
|
+
required-ness, and env vars.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from rich import box
|
|
20
|
+
from rich.console import Console, Group, RenderableType
|
|
21
|
+
from rich.padding import Padding
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
from typer.core import TyperGroup
|
|
26
|
+
|
|
27
|
+
# Mirror Typer's help palette (typer.rich_utils) so the extra panels read as
|
|
28
|
+
# part of the same page rather than as something bolted on.
|
|
29
|
+
_STYLE_OPTION = "bold cyan"
|
|
30
|
+
_STYLE_SWITCH = "bold green"
|
|
31
|
+
_STYLE_TYPES = "bold yellow"
|
|
32
|
+
_STYLE_BORDER = "dim"
|
|
33
|
+
|
|
34
|
+
# One example per workflow the tool is built around: ad-hoc IDs, SBOM triage,
|
|
35
|
+
# CI gating, filtering, and single-finding forensics.
|
|
36
|
+
_EXAMPLES: tuple[tuple[str, str], ...] = (
|
|
37
|
+
(
|
|
38
|
+
"Rank two CVEs with no network, showing the path behind each verdict",
|
|
39
|
+
"vulnctl enrich CVE-2021-44228 CVE-2019-0708 --offline --show-path",
|
|
40
|
+
),
|
|
41
|
+
(
|
|
42
|
+
"Rank an SBOM against your org context, as JSON for jq",
|
|
43
|
+
"vulnctl enrich --sbom app.cdx.json --context context.yaml -f json",
|
|
44
|
+
),
|
|
45
|
+
(
|
|
46
|
+
"Gate CI on a Grype scan: exit 2 if anything lands on Attend or worse",
|
|
47
|
+
"grype my-image:latest -o json | vulnctl enrich --grype - --fail-on attend",
|
|
48
|
+
),
|
|
49
|
+
(
|
|
50
|
+
"Triage the top of the pile: KEV-listed findings only",
|
|
51
|
+
"vulnctl enrich --sbom app.cdx.json --only-kev --limit 10",
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
"Ask why one finding got its verdict, and what would change it",
|
|
55
|
+
"vulnctl explain CVE-2021-44228",
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _metavar(param: Any, ctx: Any) -> str:
|
|
61
|
+
"""What a parameter takes, spelled the way Typer's own help spells it.
|
|
62
|
+
|
|
63
|
+
Delegates to Click rather than deriving it, so the appended panel agrees
|
|
64
|
+
with the per-command panels on whatever version is installed — Typer 0.26
|
|
65
|
+
renders ``PATH``, 0.27 renders ``<path>``. Click gained the ``ctx``
|
|
66
|
+
argument in 8.2; older versions take none.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
return str(param.make_metavar(ctx=ctx))
|
|
70
|
+
except TypeError:
|
|
71
|
+
return str(param.make_metavar())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _visible_params(cmd: Any, kind: str) -> list[Any]:
|
|
75
|
+
return [
|
|
76
|
+
param
|
|
77
|
+
for param in cmd.params
|
|
78
|
+
if param.param_type_name == kind and not getattr(param, "hidden", False)
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _usage(path: str, cmd: Any, ctx: Any) -> str:
|
|
83
|
+
"""The one-line invocation shape, e.g. ``vulnctl enrich [OPTIONS] [VULN_ID...]``."""
|
|
84
|
+
parts = [path]
|
|
85
|
+
if _visible_params(cmd, "option"):
|
|
86
|
+
parts.append("[OPTIONS]")
|
|
87
|
+
parts.extend(_metavar(param, ctx) for param in _visible_params(cmd, "argument"))
|
|
88
|
+
return " ".join(parts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _param_rows(cmd: Any, ctx: Any) -> Iterator[tuple[Text, Text, str]]:
|
|
92
|
+
"""Yield ``(name, value hint, help)`` for one command's arguments and options."""
|
|
93
|
+
for param in _visible_params(cmd, "argument"):
|
|
94
|
+
# No type column: an argument's metavar already names what it takes.
|
|
95
|
+
yield (
|
|
96
|
+
Text(f" {_metavar(param, ctx)}", style=_STYLE_OPTION),
|
|
97
|
+
Text(""),
|
|
98
|
+
str(getattr(param, "help", "") or ""),
|
|
99
|
+
)
|
|
100
|
+
for param in _visible_params(cmd, "option"):
|
|
101
|
+
switch = bool(getattr(param, "is_flag", False))
|
|
102
|
+
hint = "" if switch else _metavar(param, ctx)
|
|
103
|
+
yield (
|
|
104
|
+
Text(" " + " ".join(param.opts), style=_STYLE_SWITCH if switch else _STYLE_OPTION),
|
|
105
|
+
Text(hint, style=_STYLE_TYPES),
|
|
106
|
+
str(getattr(param, "help", "") or ""),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _leaf_commands(group: Any, prefix: str) -> Iterator[tuple[str, str, Any]]:
|
|
111
|
+
"""Walk the command tree depth-first, yielding ``(label, invocation, command)``.
|
|
112
|
+
|
|
113
|
+
``label`` is the command as the user types it after the program name
|
|
114
|
+
(``enrich``, ``cache purge``); ``invocation`` includes the program name.
|
|
115
|
+
Subgroups recurse so nested commands are listed rather than hidden behind
|
|
116
|
+
another ``--help``.
|
|
117
|
+
"""
|
|
118
|
+
for name, cmd in group.commands.items():
|
|
119
|
+
if getattr(cmd, "hidden", False):
|
|
120
|
+
continue
|
|
121
|
+
label = f"{prefix} {name}".strip()
|
|
122
|
+
subcommands = getattr(cmd, "commands", None)
|
|
123
|
+
if subcommands:
|
|
124
|
+
yield from _leaf_commands(cmd, label)
|
|
125
|
+
else:
|
|
126
|
+
yield label, name, cmd
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _flags_panel(group: Any, prog: str, ctx: Any) -> Panel:
|
|
130
|
+
"""Every command's parameters, generated from the registered Click objects."""
|
|
131
|
+
table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 1))
|
|
132
|
+
table.add_column(no_wrap=True) # command / flag
|
|
133
|
+
table.add_column(overflow="fold") # what it takes
|
|
134
|
+
table.add_column() # help
|
|
135
|
+
|
|
136
|
+
for index, (label, _name, cmd) in enumerate(_leaf_commands(group, "")):
|
|
137
|
+
if index:
|
|
138
|
+
table.add_row("", "", "")
|
|
139
|
+
table.add_row(
|
|
140
|
+
Text(label, style="bold"),
|
|
141
|
+
"",
|
|
142
|
+
Text(_usage(f"{prog} {label}", cmd, ctx), style="dim"),
|
|
143
|
+
)
|
|
144
|
+
for name, hint, help_text in _param_rows(cmd, ctx):
|
|
145
|
+
table.add_row(name, hint, help_text)
|
|
146
|
+
|
|
147
|
+
return Panel(
|
|
148
|
+
table,
|
|
149
|
+
title="Options by command",
|
|
150
|
+
title_align="left",
|
|
151
|
+
border_style=_STYLE_BORDER,
|
|
152
|
+
box=box.ROUNDED,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _examples_panel() -> Panel:
|
|
157
|
+
"""Worked invocations, one per supported workflow."""
|
|
158
|
+
lines: list[RenderableType] = []
|
|
159
|
+
for comment, command in _EXAMPLES:
|
|
160
|
+
if lines:
|
|
161
|
+
lines.append(Text(""))
|
|
162
|
+
lines.append(Text(f"# {comment}", style="dim"))
|
|
163
|
+
lines.append(Text(command, style=_STYLE_OPTION))
|
|
164
|
+
return Panel(
|
|
165
|
+
Group(*lines),
|
|
166
|
+
title="Examples",
|
|
167
|
+
title_align="left",
|
|
168
|
+
border_style=_STYLE_BORDER,
|
|
169
|
+
box=box.ROUNDED,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class HelpfulGroup(TyperGroup):
|
|
174
|
+
"""Root group whose ``--help`` also lists every command's flags and examples."""
|
|
175
|
+
|
|
176
|
+
def format_help(self, ctx: Any, formatter: Any) -> None:
|
|
177
|
+
super().format_help(ctx, formatter)
|
|
178
|
+
prog = str(ctx.find_root().info_name or "vulnctl")
|
|
179
|
+
console = Console()
|
|
180
|
+
console.print(_flags_panel(self, prog, ctx))
|
|
181
|
+
console.print(_examples_panel())
|
|
182
|
+
console.print(
|
|
183
|
+
Padding(
|
|
184
|
+
Text.assemble(
|
|
185
|
+
("Run ", "dim"),
|
|
186
|
+
(f"{prog} COMMAND --help", "bold"),
|
|
187
|
+
(" for one command in full: defaults, value ranges, exit codes.", "dim"),
|
|
188
|
+
),
|
|
189
|
+
(0, 1),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import re
|
|
6
7
|
from importlib.metadata import version as pkg_version
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
@@ -12,6 +13,7 @@ from typer.testing import CliRunner
|
|
|
12
13
|
|
|
13
14
|
from vulnctl.cache import Cache
|
|
14
15
|
from vulnctl.cli import app
|
|
16
|
+
from vulnctl.cli_help import _EXAMPLES
|
|
15
17
|
|
|
16
18
|
runner = CliRunner()
|
|
17
19
|
|
|
@@ -37,6 +39,73 @@ def test_no_args_shows_help() -> None:
|
|
|
37
39
|
assert "Usage" in result.output
|
|
38
40
|
|
|
39
41
|
|
|
42
|
+
def _subcommand_flags() -> set[str]:
|
|
43
|
+
"""Every option flag registered under a subcommand, walked independently.
|
|
44
|
+
|
|
45
|
+
Deliberately does not reuse ``cli_help``'s own walk — the point is to catch
|
|
46
|
+
a flag the generated root summary would miss. The root's own options are
|
|
47
|
+
excluded: they render in Typer's help panel, which forces colour under
|
|
48
|
+
GITHUB_ACTIONS (see ``test_completion_options_present``).
|
|
49
|
+
"""
|
|
50
|
+
flags: set[str] = set()
|
|
51
|
+
stack = list(get_command(app).commands.values())
|
|
52
|
+
while stack:
|
|
53
|
+
cmd = stack.pop()
|
|
54
|
+
stack.extend(getattr(cmd, "commands", {}).values())
|
|
55
|
+
flags.update(
|
|
56
|
+
opt
|
|
57
|
+
for param in cmd.params
|
|
58
|
+
if param.param_type_name == "option" # arguments carry a name, not a flag
|
|
59
|
+
for opt in param.opts
|
|
60
|
+
)
|
|
61
|
+
return flags
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_root_help_lists_every_subcommand_flag() -> None:
|
|
65
|
+
# `vulnctl --help` on its own has to be usable: Typer's root help names the
|
|
66
|
+
# commands but none of their flags, so cli_help appends a generated summary.
|
|
67
|
+
result = runner.invoke(app, ["--help"])
|
|
68
|
+
assert result.exit_code == 0
|
|
69
|
+
assert sorted(flag for flag in _subcommand_flags() if flag not in result.output) == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_root_help_shows_usage_shapes_and_examples() -> None:
|
|
73
|
+
result = runner.invoke(app, ["--help"])
|
|
74
|
+
assert "vulnctl enrich [OPTIONS] [VULN_ID...]" in result.output
|
|
75
|
+
assert "vulnctl explain [OPTIONS] VULN_ID" in result.output
|
|
76
|
+
assert "vulnctl cache purge [OPTIONS]" in result.output
|
|
77
|
+
assert "vulnctl enrich CVE-2021-44228 CVE-2019-0708 --offline --show-path" in result.output
|
|
78
|
+
assert "vulnctl COMMAND --help" in result.output # pointer to per-command detail
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_root_help_type_column_matches_per_command_help() -> None:
|
|
82
|
+
# The type column is Click's own metavar, not a hand-derived one, so it
|
|
83
|
+
# tracks whatever spelling the installed Typer uses (0.26 prints `PATH`,
|
|
84
|
+
# 0.27 prints `<path>`) instead of disagreeing with the panels above it.
|
|
85
|
+
def plain(text: str) -> str:
|
|
86
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", text) # CI forces colour on Typer's console
|
|
87
|
+
|
|
88
|
+
def option_row(output: str) -> str:
|
|
89
|
+
# The flag also occurs inside other params' help text; take the row it
|
|
90
|
+
# heads, panel border and indent stripped.
|
|
91
|
+
rows = [ln.lstrip("│ ") for ln in plain(output).splitlines()]
|
|
92
|
+
(row,) = [ln for ln in rows if ln.startswith("--sbom")]
|
|
93
|
+
return row
|
|
94
|
+
|
|
95
|
+
metavar = option_row(runner.invoke(app, ["enrich", "--help"]).output).split()[1]
|
|
96
|
+
assert metavar.strip("<>").lower() == "path"
|
|
97
|
+
assert metavar in option_row(runner.invoke(app, ["--help"]).output)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_help_examples_use_real_flags() -> None:
|
|
101
|
+
"""Worked examples rot silently; assert every flag in them still exists."""
|
|
102
|
+
known = _subcommand_flags()
|
|
103
|
+
for _comment, command in _EXAMPLES:
|
|
104
|
+
tail = command.split("vulnctl", 1)[1] # skip flags belonging to piped-in tools
|
|
105
|
+
used = {token for token in tail.split() if token.startswith("-") and token != "-"}
|
|
106
|
+
assert used <= known, f"unknown flag(s) in example: {sorted(used - known)}"
|
|
107
|
+
|
|
108
|
+
|
|
40
109
|
def test_enrich_offline_renders_table_from_snapshots() -> None:
|
|
41
110
|
"""End-to-end offline run: bundled snapshots only, zero network."""
|
|
42
111
|
result = runner.invoke(app, ["enrich", "--offline", "cve-2021-44228", "CVE-2019-0708"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|