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.
- limbowatch_cli-0.1.0a1/.gitignore +29 -0
- limbowatch_cli-0.1.0a1/PKG-INFO +109 -0
- limbowatch_cli-0.1.0a1/README.md +77 -0
- limbowatch_cli-0.1.0a1/pyproject.toml +44 -0
- limbowatch_cli-0.1.0a1/src/limbowatch_cli/__init__.py +1 -0
- limbowatch_cli-0.1.0a1/src/limbowatch_cli/main.py +488 -0
- limbowatch_cli-0.1.0a1/src/limbowatch_cli/py.typed +0 -0
|
@@ -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
|
+
[](https://pypi.org/project/limbowatch-cli/)
|
|
36
|
+
[](https://pypi.org/project/limbowatch-cli/)
|
|
37
|
+
[](https://github.com/limbowatch/limbowatch/actions/workflows/ci.yml)
|
|
38
|
+
[](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
|
+
[](https://pypi.org/project/limbowatch-cli/)
|
|
4
|
+
[](https://pypi.org/project/limbowatch-cli/)
|
|
5
|
+
[](https://github.com/limbowatch/limbowatch/actions/workflows/ci.yml)
|
|
6
|
+
[](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
|