limbowatch-cli 0.1.0a1__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.
@@ -0,0 +1,29 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ *.db
13
+ *.sqlite
14
+ .env
15
+ .DS_Store
16
+
17
+ # research caches (raw API responses, cloned lockfiles)
18
+ research/**/.cache/
19
+ # research intermediates: contain advisory text and author/credit data; never commit
20
+ research/**/data/**/*.json
21
+ !research/**/data/**/summary.json
22
+ !research/**/data/**/followup-*.json
23
+
24
+ # tracker runtime output (state lives on the tracker-data branch; site is built in CI)
25
+ tracker/state/
26
+ tracker/site/
27
+
28
+ # Site build output (written by CI before docker build)
29
+ deploy/site/public/
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: limbowatch-cli
3
+ Version: 0.1.0a1
4
+ Summary: Find dependencies affected by security advisories not yet in GitHub's reviewed database or OSV
5
+ Project-URL: Homepage, https://limbo.watch
6
+ Project-URL: Source, https://github.com/limbowatch/limbowatch
7
+ Project-URL: Documentation, https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md
8
+ Project-URL: Issues, https://github.com/limbowatch/limbowatch/issues
9
+ Project-URL: Changelog, https://github.com/limbowatch/limbowatch/blob/main/CHANGELOG.md
10
+ Project-URL: Tracker, https://limbo.watch/tracker/
11
+ Author: Limbowatch contributors
12
+ License-Expression: Apache-2.0
13
+ Keywords: advisories,cyclonedx,dependencies,ghsa,osv,sarif,sca,security,supply-chain,vulnerabilities
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Information Technology
18
+ Classifier: Intended Audience :: System Administrators
19
+ Classifier: Operating System :: MacOS
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Security
26
+ Classifier: Topic :: Software Development :: Quality Assurance
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.12
29
+ Requires-Dist: limbowatch==0.1.0a1
30
+ Requires-Dist: typer>=0.12
31
+ Description-Content-Type: text/markdown
32
+
33
+ # limbowatch-cli
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/limbowatch-cli)](https://pypi.org/project/limbowatch-cli/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/limbowatch-cli)](https://pypi.org/project/limbowatch-cli/)
37
+ [![CI](https://github.com/limbowatch/limbowatch/actions/workflows/ci.yml/badge.svg)](https://github.com/limbowatch/limbowatch/actions/workflows/ci.yml)
38
+ [![License](https://img.shields.io/pypi/l/limbowatch-cli)](https://github.com/limbowatch/limbowatch/blob/main/LICENSE)
39
+
40
+ `limbowatch scan` finds the dependencies in your project that are affected by security advisories **in limbo**: published by maintainers on GitHub, often with a fix, but not yet in GitHub's reviewed Advisory Database or OSV. Dependabot, npm audit, pip-audit and OSV-Scanner read those databases, so they don't report these advisories yet. [limbo.watch](https://limbo.watch) tracks them every day.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ pipx install limbowatch-cli==0.1.0a1 # the current alpha; or: uv tool install limbowatch-cli==0.1.0a1
46
+ ```
47
+
48
+ It reads lockfiles with [OSV-Scanner](https://google.github.io/osv-scanner/installation/) 2.7.0, which must be on `PATH`. Python 3.12 or later.
49
+
50
+ ## Use
51
+
52
+ ```sh
53
+ limbowatch scan . # a table per lockfile, what to upgrade, and PASSED or FAILED
54
+ limbowatch scan . --fail-on high # fail only for high and critical findings
55
+ limbowatch scan . -o table -o sarif=limbowatch.sarif -o junit=junit.xml -o html=report.html
56
+ limbowatch scan --sbom bom.cdx.json -f json
57
+ limbowatch inventory . # what would be checked, and what can't be
58
+ ```
59
+
60
+ ```
61
+ web/package-lock.json
62
+ SEVERITY PACKAGE INSTALLED FIXED IN ADVISORY CVE
63
+ HIGH undici 7.30.1 7.30.2 GHSA-mqg3-3v4r-cm32 CVE-2026-116592
64
+
65
+ To fix: upgrade 1 package
66
+ undici 7.30.1 → 7.30.2 clears 1 advisory web/package-lock.json:879
67
+
68
+ FAILED 1 finding at or above low (--fail-on low): 1 high.
69
+ ```
70
+
71
+ ## Output formats
72
+
73
+ One scan can write any number of reports: `-o FORMAT=FILE`, repeated.
74
+
75
+ | Format | For |
76
+ |---|---|
77
+ | `table` | Terminals and CI logs (coloured; `--color`, `NO_COLOR`) |
78
+ | `json` | Automation: findings, upgrades, summary and gate result (`schema_version` 1) |
79
+ | `markdown`, `html` | Pull request comments, job summaries, CI artifacts |
80
+ | `sarif` | GitHub code scanning and other SARIF 2.1.0 tools |
81
+ | `codequality` | GitLab Code Quality in merge requests (every tier) |
82
+ | `junit` | Test summaries in GitLab, Jenkins, Azure DevOps, CircleCI |
83
+ | `csv` | Spreadsheets |
84
+ | `cyclonedx` | CycloneDX 1.6 with vulnerabilities |
85
+ | `github` | GitHub Actions annotations, no code scanning needed |
86
+
87
+ ## Exit codes
88
+
89
+ | Code | |
90
+ |---|---|
91
+ | 0 | Passed: nothing meets `--fail-on` |
92
+ | 1 | Failed: a finding meets `--fail-on` (default `low`) |
93
+ | 2 | Error: the scan couldn't run |
94
+
95
+ ## In CI
96
+
97
+ - **GitLab:** the [CI/CD component](https://gitlab.com/limbowatch/limbowatch-component): Code Quality, a test summary and a report link in every merge request.
98
+ - **GitHub, Gitea, Forgejo:** the [action](https://github.com/limbowatch/limbowatch/tree/main/integrations/action): annotations, SARIF, job summary and outputs.
99
+
100
+ Both can open and update one issue per finding (`--issues gitlab|gitea`). Guide: [docs/WATCHER.md](https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md).
101
+
102
+ ## Good to know
103
+
104
+ - Nothing about your project is sent to limbo.watch: the feed is downloaded whole.
105
+ - Dependencies that can't be checked (range-only requirements, git installs, unreadable lockfiles) are listed with the reason, never dropped.
106
+ - Ranges are as entered by maintainers and haven't been reviewed; a range that can't be evaluated exactly is reported as "possibly affected".
107
+ - It's a head start: once GitHub reviews an advisory, your usual scanner reports it too.
108
+
109
+ Advisory data: GitHub repository security advisories and the GitHub Advisory Database (CC BY 4.0), via the limbo feed (CC BY 4.0). Code: Apache-2.0.
@@ -0,0 +1,77 @@
1
+ # limbowatch-cli
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/limbowatch-cli)](https://pypi.org/project/limbowatch-cli/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/limbowatch-cli)](https://pypi.org/project/limbowatch-cli/)
5
+ [![CI](https://github.com/limbowatch/limbowatch/actions/workflows/ci.yml/badge.svg)](https://github.com/limbowatch/limbowatch/actions/workflows/ci.yml)
6
+ [![License](https://img.shields.io/pypi/l/limbowatch-cli)](https://github.com/limbowatch/limbowatch/blob/main/LICENSE)
7
+
8
+ `limbowatch scan` finds the dependencies in your project that are affected by security advisories **in limbo**: published by maintainers on GitHub, often with a fix, but not yet in GitHub's reviewed Advisory Database or OSV. Dependabot, npm audit, pip-audit and OSV-Scanner read those databases, so they don't report these advisories yet. [limbo.watch](https://limbo.watch) tracks them every day.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pipx install limbowatch-cli==0.1.0a1 # the current alpha; or: uv tool install limbowatch-cli==0.1.0a1
14
+ ```
15
+
16
+ It reads lockfiles with [OSV-Scanner](https://google.github.io/osv-scanner/installation/) 2.7.0, which must be on `PATH`. Python 3.12 or later.
17
+
18
+ ## Use
19
+
20
+ ```sh
21
+ limbowatch scan . # a table per lockfile, what to upgrade, and PASSED or FAILED
22
+ limbowatch scan . --fail-on high # fail only for high and critical findings
23
+ limbowatch scan . -o table -o sarif=limbowatch.sarif -o junit=junit.xml -o html=report.html
24
+ limbowatch scan --sbom bom.cdx.json -f json
25
+ limbowatch inventory . # what would be checked, and what can't be
26
+ ```
27
+
28
+ ```
29
+ web/package-lock.json
30
+ SEVERITY PACKAGE INSTALLED FIXED IN ADVISORY CVE
31
+ HIGH undici 7.30.1 7.30.2 GHSA-mqg3-3v4r-cm32 CVE-2026-116592
32
+
33
+ To fix: upgrade 1 package
34
+ undici 7.30.1 → 7.30.2 clears 1 advisory web/package-lock.json:879
35
+
36
+ FAILED 1 finding at or above low (--fail-on low): 1 high.
37
+ ```
38
+
39
+ ## Output formats
40
+
41
+ One scan can write any number of reports: `-o FORMAT=FILE`, repeated.
42
+
43
+ | Format | For |
44
+ |---|---|
45
+ | `table` | Terminals and CI logs (coloured; `--color`, `NO_COLOR`) |
46
+ | `json` | Automation: findings, upgrades, summary and gate result (`schema_version` 1) |
47
+ | `markdown`, `html` | Pull request comments, job summaries, CI artifacts |
48
+ | `sarif` | GitHub code scanning and other SARIF 2.1.0 tools |
49
+ | `codequality` | GitLab Code Quality in merge requests (every tier) |
50
+ | `junit` | Test summaries in GitLab, Jenkins, Azure DevOps, CircleCI |
51
+ | `csv` | Spreadsheets |
52
+ | `cyclonedx` | CycloneDX 1.6 with vulnerabilities |
53
+ | `github` | GitHub Actions annotations, no code scanning needed |
54
+
55
+ ## Exit codes
56
+
57
+ | Code | |
58
+ |---|---|
59
+ | 0 | Passed: nothing meets `--fail-on` |
60
+ | 1 | Failed: a finding meets `--fail-on` (default `low`) |
61
+ | 2 | Error: the scan couldn't run |
62
+
63
+ ## In CI
64
+
65
+ - **GitLab:** the [CI/CD component](https://gitlab.com/limbowatch/limbowatch-component): Code Quality, a test summary and a report link in every merge request.
66
+ - **GitHub, Gitea, Forgejo:** the [action](https://github.com/limbowatch/limbowatch/tree/main/integrations/action): annotations, SARIF, job summary and outputs.
67
+
68
+ Both can open and update one issue per finding (`--issues gitlab|gitea`). Guide: [docs/WATCHER.md](https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md).
69
+
70
+ ## Good to know
71
+
72
+ - Nothing about your project is sent to limbo.watch: the feed is downloaded whole.
73
+ - Dependencies that can't be checked (range-only requirements, git installs, unreadable lockfiles) are listed with the reason, never dropped.
74
+ - Ranges are as entered by maintainers and haven't been reviewed; a range that can't be evaluated exactly is reported as "possibly affected".
75
+ - It's a head start: once GitHub reviews an advisory, your usual scanner reports it too.
76
+
77
+ Advisory data: GitHub repository security advisories and the GitHub Advisory Database (CC BY 4.0), via the limbo feed (CC BY 4.0). Code: Apache-2.0.
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "limbowatch-cli"
3
+ version = "0.1.0a1"
4
+ description = "Find dependencies affected by security advisories not yet in GitHub's reviewed database or OSV"
5
+ license = "Apache-2.0"
6
+ requires-python = ">=3.12"
7
+ readme = "README.md"
8
+ authors = [{ name = "Limbowatch contributors" }]
9
+ keywords = ["security", "advisories", "vulnerabilities", "ghsa", "osv", "sca", "dependencies", "supply-chain", "sarif", "cyclonedx"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: Information Technology",
15
+ "Intended Audience :: System Administrators",
16
+ "Operating System :: MacOS",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Security",
23
+ "Topic :: Software Development :: Quality Assurance",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["limbowatch==0.1.0a1", "typer>=0.12"]
27
+
28
+ [project.scripts]
29
+ limbowatch = "limbowatch_cli.main:app"
30
+
31
+ [project.urls]
32
+ Homepage = "https://limbo.watch"
33
+ Source = "https://github.com/limbowatch/limbowatch"
34
+ Documentation = "https://github.com/limbowatch/limbowatch/blob/main/docs/WATCHER.md"
35
+ Issues = "https://github.com/limbowatch/limbowatch/issues"
36
+ Changelog = "https://github.com/limbowatch/limbowatch/blob/main/CHANGELOG.md"
37
+ Tracker = "https://limbo.watch/tracker/"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/limbowatch_cli"]
@@ -0,0 +1 @@
1
+ """Limbowatch command line."""
@@ -0,0 +1,488 @@
1
+ """`limbowatch scan` checks a project against advisories in limbo; `limbowatch inventory` lists what a project
2
+ depends on and what can't be checked; `limbowatch track`, `publish` and `report` run the live tracker."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Annotated, Any
18
+
19
+ import typer
20
+ from limbowatch.ecosystems import info
21
+ from limbowatch.http import HttpClient
22
+ from limbowatch.inventory import osv_scanner, sbom
23
+ from limbowatch.models import Inventory, Unchecked
24
+ from limbowatch.publish import all_events, dumps, items, metrics, repos_with_advisories, watchlist
25
+ from limbowatch.publish import feed as feed_mod
26
+ from limbowatch.publish import report as report_mod
27
+ from limbowatch.publish import site as site_mod
28
+ from limbowatch.sources import osv as osv_source
29
+ from limbowatch.store import Store
30
+ from limbowatch.tracker import Tracker, TrackerConfig, import_cve_list
31
+ from limbowatch.versions import norm_name
32
+ from limbowatch.watch import issues as watch_issues
33
+ from limbowatch.watch import output, trackers
34
+ from limbowatch.watch import scan as watch_scan
35
+ from limbowatch.watch import state as watch_state
36
+
37
+ app = typer.Typer(
38
+ no_args_is_help=True,
39
+ add_completion=False,
40
+ help="Find dependencies affected by security advisories in limbo: published by maintainers on GitHub, not yet "
41
+ "in the databases scanners read. https://limbo.watch",
42
+ )
43
+
44
+
45
+ def _version(value: bool) -> None:
46
+ if value:
47
+ from limbowatch import __version__
48
+
49
+ typer.echo(f"limbowatch {__version__}")
50
+ raise typer.Exit()
51
+
52
+
53
+ @app.callback()
54
+ def _main(
55
+ version: Annotated[
56
+ bool | None, typer.Option("--version", callback=_version, is_eager=True, help="Show the version and exit.")
57
+ ] = None,
58
+ ) -> None:
59
+ """Find dependencies affected by security advisories in limbo. https://limbo.watch"""
60
+
61
+
62
+ def _color(mode: str) -> tuple[bool, bool]:
63
+ """(colour, links) for the terminal table. auto: colour on a terminal and in GitHub Actions and GitLab CI,
64
+ whose logs render it; NO_COLOR turns it off and FORCE_COLOR on. Links (OSC 8) only on a real terminal."""
65
+ tty = sys.stdout.isatty()
66
+ if mode == "never" or (mode == "auto" and os.environ.get("NO_COLOR")):
67
+ return False, False
68
+ ci = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("GITLAB_CI") or os.environ.get("FORCE_COLOR"))
69
+ on = mode == "always" or tty or ci
70
+ return on, on and tty and not os.environ.get("CI")
71
+
72
+
73
+ StateDir = Annotated[Path, typer.Option(help="Directory of JSONL state files (read, then rewritten).")]
74
+
75
+
76
+ def _token() -> str | None:
77
+ tok = os.environ.get("LIMBOWATCH_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
78
+ if tok:
79
+ return tok
80
+ try:
81
+ return (
82
+ subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, check=True).stdout.strip() or None
83
+ )
84
+ except (OSError, subprocess.CalledProcessError):
85
+ return None
86
+
87
+
88
+ def _load(state: Path) -> Store:
89
+ store = Store()
90
+ if state.exists():
91
+ store.import_jsonl(state)
92
+ return store
93
+
94
+
95
+ @app.command()
96
+ def track(
97
+ state: StateDir = Path("tracker/state"),
98
+ seed: Annotated[list[Path] | None, typer.Option(help="Seed file(s): one owner/repo per line.")] = None,
99
+ budget: Annotated[int, typer.Option(help="Max GitHub requests this run (304s are free).")] = 4000,
100
+ minutes: Annotated[float, typer.Option(help="Wall-clock limit for the run.")] = 50,
101
+ nvd: Annotated[bool, typer.Option(help="Look up NVD status for published CVEs.")] = True,
102
+ max_nvd: Annotated[int, typer.Option(help="NVD lookups per run.")] = 150,
103
+ cve_list: Annotated[
104
+ Path | None, typer.Option(help="Bootstrap from a cvelistV5 bulk release (*_all_CVEs_at_midnight.zip.zip).")
105
+ ] = None,
106
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
107
+ ) -> None:
108
+ """Run the tracker once: sync reviews and CVEs, poll repos within the budget, record changes."""
109
+ logging.basicConfig(level=logging.INFO if verbose else logging.WARNING, format="%(levelname)s %(name)s %(message)s")
110
+ store = _load(state)
111
+ cfg = TrackerConfig(seeds=seed or [], nvd=nvd, max_nvd=max_nvd, checkpoint=lambda: store.export_jsonl(state))
112
+
113
+ if cve_list is not None:
114
+ with tempfile.TemporaryDirectory() as tmp:
115
+ n = import_cve_list(store, cve_list, Path(tmp))
116
+ typer.echo(f"imported {n:,} CVE records that reference a GHSA from {cve_list.name}")
117
+
118
+ async def go() -> dict[str, object]:
119
+ async with HttpClient(
120
+ _token(),
121
+ github_budget=budget,
122
+ deadline=time.monotonic() + minutes * 60,
123
+ nvd_api_key=os.environ.get("NVD_API_KEY"),
124
+ ) as http:
125
+ return (await Tracker(store, http, cfg).run()).__dict__
126
+
127
+ try:
128
+ report = asyncio.run(go())
129
+ finally:
130
+ store.export_jsonl(state) # keep progress even if the run fails
131
+ events = report.pop("events")
132
+ typer.echo(json.dumps(report, indent=1, default=str))
133
+ for e in events[:40]: # type: ignore[index]
134
+ typer.echo(f" {e}")
135
+
136
+
137
+ @app.command()
138
+ def publish(
139
+ state: StateDir = Path("tracker/state"),
140
+ out: Annotated[Path, typer.Option(help="Output directory for the site and feed.")] = Path("tracker/site"),
141
+ assets: Annotated[Path, typer.Option(help="Brand assets to copy (favicon, avatar).")] = Path("brand/assets"),
142
+ screenshots: Annotated[Path, typer.Option(help="Screenshots for the scan page (PNG).")] = Path("docs/screenshots"),
143
+ analytics_id: Annotated[
144
+ str | None,
145
+ typer.Option(
146
+ envvar="LIMBOWATCH_GA_ID", help="GA4 measurement ID (G-…); adds Google Analytics behind a consent banner."
147
+ ),
148
+ ] = None,
149
+ ) -> None:
150
+ """Write the site, feed (all.zip, index.json, one JSON per record) and metrics.json from the state."""
151
+ store = _load(state)
152
+ now = datetime.now(UTC)
153
+ all_items = items(store)
154
+ m = metrics(store, now)
155
+ modified = m["generated_at"]
156
+ records, zipped = feed_mod.build(all_items, modified)
157
+ (out / "feed").mkdir(parents=True, exist_ok=True)
158
+ (out / "feed" / "all.zip").write_bytes(zipped)
159
+ (out / "feed" / "index.json").write_text(
160
+ dumps(
161
+ {
162
+ "generated_at": modified,
163
+ "count": len(records),
164
+ "ids": [r["id"] for r in records],
165
+ "attribution": m["attribution"],
166
+ }
167
+ )
168
+ )
169
+ # The same records per ecosystem, in OSV's download layout, for OSV-Scanner (FINDINGS F-073, F-074).
170
+ # nginx sends each zip's CRC-32C as X-Goog-Hash, from the map written here (deploy/site/nginx.conf).
171
+ zips = feed_mod.by_ecosystem(records)
172
+ hashes = []
173
+ for eco, data in zips.items():
174
+ (out / "osv" / eco).mkdir(parents=True, exist_ok=True)
175
+ (out / "osv" / eco / "all.zip").write_bytes(data)
176
+ hashes.append(f'"/osv/{eco}/all.zip" "{feed_mod.goog_hash(data)}";\n')
177
+ (out / "osv" / "ecosystems.txt").write_text("".join(f"{e}\n" for e in zips))
178
+ (out / "osv" / "crc32c.map").write_text("".join(hashes))
179
+ (out / "osv" / "limbo-db.zip").write_bytes(feed_mod.bundle(zips))
180
+ (out / "feed" / "repos.txt").write_text(repos_with_advisories(store, modified))
181
+ (out / "feed" / "watchlist.json").write_text(json.dumps(watchlist(store, modified), separators=(",", ":")))
182
+ (out / "metrics.json").write_text(dumps(m))
183
+ for r in records:
184
+ (out / "feed" / f"{r['id']}.json").write_text(dumps(r))
185
+ # the feed page shows a real record: the newest pending one with a machine-readable range
186
+ pending = {i.adv.ghsa_id: i for i in all_items if not i.assessment.likely_permanent}
187
+ with_ranges = [r for r in records if r["id"] in pending and any("ranges" in a for a in r["affected"])]
188
+ sample = max(with_ranges, key=lambda r: r.get("published") or "", default=None)
189
+ for path, text in site_mod.build(
190
+ all_items, m, now, events=all_events(store), sample=sample, analytics_id=analytics_id or None
191
+ ).items():
192
+ (out / path).parent.mkdir(parents=True, exist_ok=True)
193
+ (out / path).write_text(text)
194
+ for name, target in (
195
+ ("favicon.svg", "favicon.svg"),
196
+ ("avatar-500.png", "avatar-500.png"),
197
+ ("social-preview-1280x640.png", "assets/social.png"), # og:image on every page
198
+ ("wordmark-on-dark.png", "press/kit/wordmark-on-dark.png"),
199
+ ("wordmark-on-light.png", "press/kit/wordmark-on-light.png"),
200
+ ("mark-on-light.svg", "press/kit/mark.svg"),
201
+ ("avatar-1024.png", "press/kit/avatar.png"),
202
+ ("contact.png", "assets/contact.png"), # the press email as an image (brand/make_contact.py)
203
+ ):
204
+ if (assets / name).exists():
205
+ (out / target).parent.mkdir(parents=True, exist_ok=True)
206
+ shutil.copyfile(assets / name, out / target)
207
+ for shot in sorted(screenshots.glob("*.png")) if screenshots.is_dir() else []:
208
+ (out / "assets/screens").mkdir(parents=True, exist_ok=True)
209
+ shutil.copyfile(shot, out / "assets/screens" / shot.name)
210
+ typer.echo(f"published {len(records)} advisories in limbo to {out}")
211
+
212
+
213
+ @app.command()
214
+ def report(
215
+ state: StateDir = Path("tracker/state"),
216
+ out: Annotated[Path, typer.Option(help="Directory for reports (YYYY-MM-DD.md and latest.md).")] = Path(
217
+ "tracker/reports"
218
+ ),
219
+ ) -> None:
220
+ """Write the daily Markdown report: current numbers and everything that changed since the last report."""
221
+ store = _load(state)
222
+ rep = report_mod.build(store)
223
+ out.mkdir(parents=True, exist_ok=True)
224
+ (out / f"{rep.date}.md").write_text(rep.markdown)
225
+ (out / "latest.md").write_text(rep.markdown)
226
+ report_mod.commit(store, rep)
227
+ store.export_jsonl(state)
228
+ typer.echo(str(out / f"{rep.date}.md"))
229
+
230
+
231
+ def load_inventory(path: Path, sbom_file: Path | None, binary: str, include_ignored: bool = False) -> Inventory:
232
+ """A project's dependencies, from an SBOM or by running OSV-Scanner offline over its tree."""
233
+ if sbom_file is not None:
234
+ return sbom.parse(sbom_file.read_text(errors="replace"), sbom_file.name)
235
+ if shutil.which(binary) is None:
236
+ raise typer.BadParameter(
237
+ f"{binary} not found. Install OSV-Scanner {osv_scanner.TESTED_VERSION} "
238
+ "(https://google.github.io/osv-scanner/installation/), or pass --sbom.",
239
+ param_hint="--osv-scanner",
240
+ )
241
+ found = osv_scanner.version(binary)
242
+ if found != osv_scanner.TESTED_VERSION:
243
+ typer.echo(f"warning: tested with osv-scanner {osv_scanner.TESTED_VERSION}, found {found}", err=True)
244
+ try:
245
+ return osv_scanner.run(path, binary, include_ignored=include_ignored)
246
+ except osv_scanner.InventoryError as e:
247
+ raise typer.Exit(_fail(str(e))) from e
248
+
249
+
250
+ def _fail(msg: str) -> int:
251
+ typer.echo(f"error: {msg}", err=True)
252
+ return 2
253
+
254
+
255
+ @app.command()
256
+ def inventory(
257
+ path: Annotated[Path, typer.Argument(help="Project directory.")] = Path("."),
258
+ sbom_file: Annotated[Path | None, typer.Option("--sbom", help="CycloneDX or SPDX JSON instead of a tree.")] = None,
259
+ binary: Annotated[str, typer.Option("--osv-scanner", help="OSV-Scanner executable.")] = "osv-scanner",
260
+ fmt: Annotated[str, typer.Option("--format", help="table or json.")] = "table",
261
+ ) -> None:
262
+ """List the dependencies Limbowatch would check, and every one it can't, with the reason."""
263
+ inv = load_inventory(path, sbom_file, binary)
264
+ if fmt == "json":
265
+ typer.echo(inv.model_dump_json(indent=1))
266
+ return
267
+ typer.echo(f"{len(inv.dependencies):,} dependencies from {len(inv.sources):,} files ({inv.tool})")
268
+ for d in inv.dependencies:
269
+ note = f" ({d.note})" if d.note else ""
270
+ typer.echo(f" {d.ecosystem:<9} {d.name} {d.version} {d.source}:{d.line}{note}")
271
+ if inv.unresolved:
272
+ typer.echo(f"\n{len(inv.unresolved):,} can't be checked:")
273
+ for u in inv.unresolved:
274
+ whole = u.reason in (Unchecked.LOCKFILE_UNREADABLE, Unchecked.NO_LOCKFILES)
275
+ what = f"{u.name} {u.version}".strip() or ("(whole file)" if whole else "(no package name)")
276
+ detail = f": {u.detail}" if u.detail else ""
277
+ typer.echo(f" {u.source}:{u.line} {what} {u.reason.value}{detail}")
278
+
279
+
280
+ def _base(path: Path) -> str:
281
+ """The scanned directory relative to the working directory (the repo root in CI), or "" if outside it."""
282
+ try:
283
+ rel = path.resolve().relative_to(Path.cwd().resolve()).as_posix()
284
+ except ValueError:
285
+ return ""
286
+ return "" if rel == "." else rel
287
+
288
+
289
+ @app.command()
290
+ def scan(
291
+ path: Annotated[Path, typer.Argument(help="Project directory.")] = Path("."),
292
+ sbom_file: Annotated[Path | None, typer.Option("--sbom", help="CycloneDX or SPDX JSON instead of a tree.")] = None,
293
+ fmt: Annotated[
294
+ str | None,
295
+ typer.Option("--format", "-f", help=f"Format on stdout [default: table]: {', '.join(output.FORMATS)}."),
296
+ ] = None,
297
+ outputs: Annotated[
298
+ list[str] | None,
299
+ typer.Option(
300
+ "--output",
301
+ "-o",
302
+ help="FORMAT or FORMAT=FILE, repeatable: one scan, many reports, e.g. -o table -o sarif=out.sarif "
303
+ "-o junit=junit.xml. FORMAT alone (or FILE '-') writes to stdout.",
304
+ ),
305
+ ] = None,
306
+ color: Annotated[
307
+ str, typer.Option(help="Colour the table: auto, always or never (NO_COLOR is honoured).")
308
+ ] = "auto",
309
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Nothing on stdout; files and exit code only.")] = False,
310
+ state_file: Annotated[Path | None, typer.Option("--state", help="JSON state file: report what changed.")] = None,
311
+ fail_on: Annotated[str, typer.Option(help="Fail at or above: critical, high, medium, low, or none.")] = "low",
312
+ fail_on_unknown: Annotated[bool, typer.Option(help="Fail on findings with no severity.")] = True,
313
+ ignore: Annotated[list[str] | None, typer.Option(help="Accept an advisory by GHSA or CVE ID; repeatable.")] = None,
314
+ ignore_file: Annotated[Path | None, typer.Option(help="File of accepted IDs, one per line, # comments.")] = None,
315
+ upstream: Annotated[bool, typer.Option(help="Also check source repos the tracker doesn't cover.")] = True,
316
+ cache: Annotated[Path | None, typer.Option(help="Where the feed is cached [default: ~/.cache/limbowatch].")] = None,
317
+ binary: Annotated[str, typer.Option("--osv-scanner", help="OSV-Scanner executable.")] = "osv-scanner",
318
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show the evidence for every finding.")] = False,
319
+ scan_ignored: Annotated[bool, typer.Option(help="Also read lockfiles a .gitignore excludes.")] = False,
320
+ issues: Annotated[str | None, typer.Option(help="Open and update issues: gitlab or gitea (also Forgejo).")] = None,
321
+ issues_url: Annotated[str | None, typer.Option(help="Tracker base URL [default: $CI_SERVER_URL].")] = None,
322
+ issues_project: Annotated[
323
+ str | None, typer.Option(help="GitLab project or Gitea owner/repo [default: $CI_PROJECT_PATH].")
324
+ ] = None,
325
+ issues_min_severity: Annotated[str, typer.Option(help="Open issues at or above this severity.")] = "low",
326
+ issues_public: Annotated[bool, typer.Option(help="Allow issues anyone can read (public Gitea repos).")] = False,
327
+ issues_confidential: Annotated[bool, typer.Option(help="GitLab: open confidential issues.")] = True,
328
+ issues_dry_run: Annotated[bool, typer.Option(help="Print the issue changes instead of making them.")] = False,
329
+ ) -> None:
330
+ """Check a project's dependencies against advisories in limbo.
331
+
332
+ Exit codes: 0 passed, 1 a finding at or above --fail-on, 2 an error (nothing was judged).
333
+ """
334
+ if fail_on not in (*output.SEVERITIES, "none"):
335
+ raise typer.BadParameter(f"--fail-on wants one of {', '.join((*output.SEVERITIES, 'none'))}, not {fail_on!r}")
336
+ if color not in ("auto", "always", "never"):
337
+ raise typer.BadParameter("--color wants auto, always or never")
338
+ stdout_fmt, targets = _outputs(fmt, outputs or [])
339
+ accepted = set(ignore or [])
340
+ if ignore_file is not None:
341
+ accepted |= {ln.split("#", 1)[0].strip() for ln in ignore_file.read_text().splitlines()} - {""}
342
+ cache = cache or Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "limbowatch"
343
+ inv = load_inventory(path, sbom_file, binary, scan_ignored)
344
+ for skipped in inv.skipped:
345
+ typer.echo(f"limbowatch: skipped {skipped}: a report from an earlier Limbowatch scan, not an SBOM", err=True)
346
+ old = json.loads(state_file.read_text()) if state_file and state_file.exists() else None
347
+ now = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
348
+
349
+ tracker_token = os.environ.get("LIMBOWATCH_ISSUES_TOKEN")
350
+ if issues is not None and issues not in ("gitlab", "gitea"):
351
+ raise typer.BadParameter("--issues wants gitlab or gitea")
352
+ if issues and not tracker_token and not issues_dry_run:
353
+ raise typer.BadParameter("set LIMBOWATCH_ISSUES_TOKEN to a token that can write issues", param_hint="--issues")
354
+ changes: list[watch_state.Change] | None = None
355
+ log_lines: list[str] = []
356
+
357
+ async def run() -> tuple[watch_scan.ScanResult, str | None]:
358
+ nonlocal changes
359
+ async with HttpClient(_token()) as http:
360
+ feed = await watch_scan.fetch(http, watch_scan.FEED_URL, cache)
361
+ wl_doc = json.loads(await watch_scan.fetch(http, watch_scan.WATCHLIST_URL, cache))
362
+ res = await watch_scan.scan(inv, http, feed_zip=feed, watchlist=dict(wl_doc.get("repos") or {}),
363
+ now=datetime.now(UTC), upstream_check=upstream) # fmt: skip
364
+ gone = sorted(watch_state.vanished(old, res.findings))
365
+ seen = await asyncio.gather(*(osv_source.present(http, g) for g in gone))
366
+ visible = {g for g, v in zip(gone, seen, strict=True) if v}
367
+ if state_file is not None:
368
+ new_state, changes = watch_state.diff(old, res.findings, now, visible)
369
+ state_file.parent.mkdir(parents=True, exist_ok=True)
370
+ state_file.write_text(json.dumps(new_state, indent=1, sort_keys=True) + "\n")
371
+ if issues:
372
+ log_lines.extend(await _sync_issues(http, res, inv, changes or [], issues, tracker_token or "",
373
+ issues_url, issues_project, issue_policy, issues_confidential,
374
+ issues_dry_run)) # fmt: skip
375
+ return res, wl_doc.get("generated_at")
376
+
377
+ issue_policy = watch_issues.Policy(issues_min_severity, issues_public)
378
+ try:
379
+ res, feed_at = asyncio.run(run())
380
+ except (RuntimeError, OSError) as e:
381
+ raise typer.Exit(_fail(str(e))) from e
382
+ base = _base(path) if sbom_file is None else ""
383
+ rep = output.Report(res.findings, res.unresolved, res.stats, inv.tool, feed_at, changes, accepted, base,
384
+ fail_on, fail_on_unknown, now, str(sbom_file or path.resolve().name or path)) # fmt: skip
385
+
386
+ for name, dest in targets:
387
+ dest.parent.mkdir(parents=True, exist_ok=True)
388
+ dest.write_text(output.FORMATS[name](rep))
389
+ for line in log_lines:
390
+ typer.echo(line, err=True)
391
+ if stdout_fmt == "table" and not quiet:
392
+ on, links = _color(color)
393
+ # click strips ANSI codes when stdout isn't a terminal unless told otherwise: CI logs are such a case
394
+ typer.echo(output.table(rep, verbose, output.Paint(on, links)), nl=False, color=on)
395
+ elif not quiet:
396
+ typer.echo(output.FORMATS[stdout_fmt](rep), nl=False)
397
+ if stdout_fmt != "table" or quiet: # the verdict still reaches the CI log
398
+ failed, why = output.verdict(rep)
399
+ typer.echo(f"limbowatch: {'FAILED' if failed else 'PASSED'}: {why}", err=True)
400
+ if rep.failed:
401
+ raise typer.Exit(1)
402
+
403
+
404
+ def _outputs(fmt: str | None, specs: list[str]) -> tuple[str, list[tuple[str, Path]]]:
405
+ """The stdout format and the FORMAT=FILE targets from --format and repeated --output."""
406
+ known = ", ".join(output.FORMATS)
407
+ stdout = [fmt] if fmt else []
408
+ files: list[tuple[str, Path]] = []
409
+ for spec in specs:
410
+ name, eq, file = spec.partition("=")
411
+ if name not in output.FORMATS or (eq and not file):
412
+ raise typer.BadParameter(f"--output wants FORMAT or FORMAT=FILE, with FORMAT one of {known}; got {spec!r}")
413
+ if not eq or file == "-":
414
+ stdout.append(name)
415
+ else:
416
+ files.append((name, Path(file)))
417
+ if fmt is not None and fmt not in output.FORMATS:
418
+ raise typer.BadParameter(f"--format wants one of {known}; got {fmt!r}")
419
+ if len(stdout) > 1:
420
+ raise typer.BadParameter(
421
+ f"only one format can go to stdout; got {', '.join(stdout)}. Send the others to files."
422
+ )
423
+ return (stdout[0] if stdout else "table"), files
424
+
425
+
426
+ async def _sync_issues(
427
+ http: HttpClient,
428
+ res: watch_scan.ScanResult,
429
+ inv: Inventory,
430
+ changes: list[watch_state.Change],
431
+ kind: str,
432
+ token: str,
433
+ url: str | None,
434
+ project: str | None,
435
+ policy: watch_issues.Policy,
436
+ confidential: bool,
437
+ dry_run: bool,
438
+ ) -> list[str]:
439
+ """Open, comment on and close issues for this scan's findings (watch.issues)."""
440
+ url = url or os.environ.get("CI_SERVER_URL") or os.environ.get("GITHUB_SERVER_URL")
441
+ project = project or os.environ.get("CI_PROJECT_PATH") or os.environ.get("GITHUB_REPOSITORY")
442
+ if not url or not project:
443
+ raise RuntimeError("--issues needs --issues-url and --issues-project (or CI_SERVER_URL and CI_PROJECT_PATH)")
444
+ tracker: trackers.GitLab | trackers.Gitea = (
445
+ trackers.GitLab(http, project, token, url, confidential)
446
+ if kind == "gitlab"
447
+ else trackers.Gitea(http, project, token, url)
448
+ )
449
+ await tracker.prepare()
450
+ if tracker.public and not policy.allow_public:
451
+ note = f"{tracker.name} issues in {project} are public; not opening new ones (--issues-public allows it)"
452
+ else:
453
+ note = ""
454
+ existing = await tracker.issues()
455
+ current = watch_state.entries(res.findings, "")
456
+ one: dict[str, dict[str, Any]] = {}
457
+ for f in res.findings:
458
+ one.setdefault(f.key, f.model_dump(mode="json"))
459
+ installed: dict[tuple[str, str], list[str]] = {}
460
+ for d in inv.dependencies:
461
+ installed.setdefault((d.ecosystem, norm_name(d.ecosystem, d.name)), []).append(d.version)
462
+ gone: dict[str, watch_issues.Gone] = {}
463
+ for key, issue in existing.items():
464
+ if not issue.open or key in current:
465
+ continue
466
+ meta = issue.meta
467
+ eco = str(meta.get("ecosystem", ""))
468
+ versions = installed.get((eco, norm_name(eco, str(meta.get("package", "")))), [])
469
+ osv_eco = info(eco).osv
470
+ ids: list[set[str] | None] = []
471
+ if versions and osv_eco and await osv_source.present(http, str(meta.get("ghsa_id"))):
472
+ ids = list(await asyncio.gather(*(osv_source.affecting(http, osv_eco, str(meta["package"]), v)
473
+ for v in versions))) # fmt: skip
474
+ verdict = watch_issues.still_affected(meta, versions, ids) if versions else False
475
+ if verdict is False:
476
+ gone[key] = watch_issues.Gone.FIXED
477
+ elif verdict is True and ids:
478
+ gone[key] = watch_issues.Gone.VISIBLE
479
+ actions = watch_issues.plan(current, one, existing, changes, gone, policy, tracker.public)
480
+ lines = [note] if note else []
481
+ if dry_run:
482
+ return [*lines, *(f"would {a.kind}: {a.title or ('#' + str(a.issue.number) if a.issue else a.key)}"
483
+ for a in actions)] # fmt: skip
484
+ return [*lines, *await watch_issues.apply(tracker, actions)]
485
+
486
+
487
+ if __name__ == "__main__":
488
+ app()
File without changes