patchsense 0.1.2

Prioritize vulnerabilities from lockfiles using OSV, EPSS, and CISA KEV
Documentation
# PatchSense

[![crates.io](https://img.shields.io/crates/v/patchsense.svg)](https://crates.io/crates/patchsense)
[![license](https://img.shields.io/crates/l/patchsense.svg)](https://github.com/Widyasa/patchsense#license)

PatchSense reads your lockfiles, looks up every pinned package in [OSV](https://osv.dev), and ranks each advisory from 0 to 100 using CISA KEV (known exploited), FIRST EPSS (exploit probability), CVSS, whether the package is a direct dependency, and whether a fix exists. Instead of a flat list of CVEs you get three tiers, **Critical**, **Review**, and **Defer**, so you know what to patch first. It runs locally, caches data in SQLite, works offline from that cache, writes table, JSON, SARIF, Markdown, and GitLab reports, can post a pull-request or merge-request comment, and ships an MCP server for coding agents.

![Scan of log4j-core 2.14.1 showing CVE-2021-44228 as Critical](https://raw.githubusercontent.com/Widyasa/patchsense/master/docs/demo.gif)

## Install

```bash
cargo install patchsense --locked
```

Requires Rust 1.99 or newer. Other install methods are listed in the [GitHub repository](https://github.com/Widyasa/patchsense).

## Quick start

```bash
patchsense scan .                         # scan every lockfile under the current directory
patchsense scan . --fail-on review        # exit 1 if any finding is Review or Critical
patchsense scan . -f json -o report.json  # save a JSON report
patchsense init                           # write an example patchsense.toml
patchsense scan . --offline               # rescan from the local cache, no network
```

Example output:

```text
┌───────┬──────────┬─────────────────────────────────────┬─────────┬──────────────────────────────────────┬─────┬────────────────────────────┬──────┬────────┐
│ Score ┆ Tier     ┆ Package                             ┆ Version ┆ Advisory/CVE                         ┆ KEV ┆ EPSS                       ┆ CVSS ┆ Fix    │
╞═══════╪══════════╪═════════════════════════════════════╪═════════╪══════════════════════════════════════╪═════╪════════════════════════════╪══════╪════════╡
│ 100   ┆ Critical ┆ org.apache.logging.log4j:log4j-core ┆ 2.14.1  ┆ GHSA-jfh8-c2jp-5v3q (CVE-2021-44228) ┆ yes ┆ 1.0000 (percentile 1.0000) ┆ 10   ┆ 2.15.0 │
│ 62    ┆ Review   ┆ org.apache.logging.log4j:log4j-core ┆ 2.14.1  ┆ GHSA-p6xc-xr62-6r2g (CVE-2021-45105) ┆ no  ┆ 1.0000 (percentile 0.9999) ┆ 8.6  ┆ 2.17.0 │
│ 34    ┆ Defer    ┆ org.apache.logging.log4j:log4j-core ┆ 2.14.1  ┆ GHSA-6hg6-v5c8-fphq (CVE-2026-34477) ┆ no  ┆ 0.0056 (percentile 0.4647) ┆ 6.3  ┆ 2.25.4 │
└───────┴──────────┴─────────────────────────────────────┴─────────┴──────────────────────────────────────┴─────┴────────────────────────────┴──────┴────────┘
Critical: 2  Review: 2  Defer: 4
```

## Command reference

```text
patchsense <COMMAND>

  scan     Scan lockfiles and print matching advisories
  cache    Inspect or delete the local SQLite cache
  init     Write an example config, agent instructions, or MCP snippets
  comment  Create or update a pull-request comment from a JSON report
  mcp      Run the MCP server on stdio
  help     Print help for a subcommand

  -h, --help     Print help
  -V, --version  Print version
```

### `patchsense scan [OPTIONS] [PATH]`

`PATH` is a directory or a single lockfile. Default: the current directory.

| Option | Description |
|---|---|
| `--ecosystem <LIST>` | Ecosystems to scan, comma-separated (`npm,pypi`). Repeatable. Names: `npm`, `pypi`, `packagist` (or `composer`), `go`, `maven`, `cargo` (or `crates.io`). |
| `--no-dev` | Skip packages the parser marked as dev dependencies. |
| `--exclude <GLOB>` | Extra paths to skip. Repeatable. Added to `[scan] exclude`. |
| `--keep-going` | Continue after a lockfile fails to parse. This is the default. |
| `--no-keep-going` | Stop at the first lockfile that cannot be read or parsed. |
| `--fail-on <WHEN>` | Exit 1 when a finding reaches this bar: `critical` (default), `review`, `any`, `never`, or a score `0`–`100`. |
| `--min-score <N>` | Hide findings below this score in every output. Does not change the exit code. |
| `-f, --format <FORMAT>` | `table`, `json`, `sarif`, `markdown`, or `gitlab`. Repeatable, paired with `--output` in order. |
| `-o, --output <FILE>` | Write the paired `--format` to this file instead of stdout. Repeatable. Parent directories are created. |
| `--no-color` | Plain table. `NO_COLOR` is also honored. |
| `--offline` | Use only the local cache. No network calls. |
| `--strict-cache` | With `--offline`, exit 2 when an EPSS row or the KEV catalog is missing or past its TTL. |
| `--no-cache` | Do not read or write the cache. |
| `--cache-dir <DIR>` | Directory containing `cache.db`. Overrides `PATCHSENSE_CACHE_DIR`. |
| `--config <FILE>` | Path to `patchsense.toml`. Overrides discovery and `PATCHSENSE_CONFIG`. |
| `--explain` | Ask a local coding agent CLI to explain the top findings. |
| `--agent <AGENT>` | Agent for `--explain`: `auto`, `codex`, `claude`, or `cursor`. |
| `--explain-top <N>` | Number of findings sent to the agent. Default from `[explain] top` (10). |

Writing several reports in one run:

```bash
patchsense scan . --no-color \
  --format json     --output patchsense-report.json \
  --format sarif    --output patchsense.sarif \
  --format markdown --output patchsense-report.md
```

Rules for pairing: the n-th `--format` goes to the n-th `--output`. At most one format may be left without an `--output` (it goes to stdout). More `--output` values than `--format` values is an error.

### `patchsense cache [--cache-dir <DIR>] <info|clear>`

| Command | Description |
|---|---|
| `cache info` | Print the cache path, schema version, and row counts (`osv_vuln`, `osv_query`, `epss`, `kev`). |
| `cache clear` | Delete `cache.db`. |

`--cache-dir` works before or after the subcommand (`cache info --cache-dir DIR` or `cache --cache-dir DIR info`).

### `patchsense init [OPTIONS]`

| Option | Description |
|---|---|
| *(none)* | Write an example `patchsense.toml` in the current directory. An existing file is kept. |
| `--force` | Replace an existing `patchsense.toml`. |
| `--agent [<AGENT>]` | Also write a PatchSense instruction block for `all` (default when no value), `codex` (`AGENTS.md`), `claude` (`CLAUDE.md`), or `cursor` (`.cursor/rules/patchsense.mdc`). The block sits between `<!-- patchsense:start -->` and `<!-- patchsense:end -->` and is updated in place on rerun. |
| `--print-mcp-config` | Print MCP config snippets for Cursor, Claude Code, and Codex. Writes no files and no `patchsense.toml`. |

### `patchsense comment --report <FILE> [OPTIONS]`

Posts the Markdown summary of a JSON report as a pull-request comment (GitHub) or merge-request note (GitLab). A hidden `<!-- patchsense-report -->` marker means later runs edit the same comment instead of adding a new one.

| Option | Description |
|---|---|
| `--report <FILE>` | JSON report from `patchsense scan --format json`. Required. |
| `--provider <PROVIDER>` | `auto` (default), `github`, or `gitlab`. `auto` picks GitHub when `GITHUB_ACTIONS=true`, GitLab when `GITLAB_CI=true`. |
| `--top <N>` | Findings in the comment table. Default 20. |
| `--dry-run` | Print the Markdown and do not call any API. |

Environment it reads:

- GitHub: `GITHUB_TOKEN` with `pull-requests: write`, `GITHUB_REPOSITORY`, `GITHUB_EVENT_PATH`, optional `GITHUB_API_URL`. The comment is a pull-request conversation comment: `GET` and `POST /repos/{owner}/{repo}/issues/{pull_number}/comments`, and `PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}`. On a pull request, `pull-requests: write` authorizes those calls. `issues: write` is not required.
- GitLab: `GITLAB_TOKEN` (project access token with `api` scope; `CI_JOB_TOKEN` cannot write notes), `CI_API_V4_URL`, `CI_PROJECT_ID`, `CI_MERGE_REQUEST_IID`.

### `patchsense mcp`

Runs a Model Context Protocol server on stdio. Tools:

| Tool | Parameters | Returns |
|---|---|---|
| `list_supported_lockfiles` | none | Lockfile names and their OSV ecosystems |
| `scan_project` | `path`, `ecosystems`, `min_score`, `include_dev`, `offline` | Top 50 findings plus the full summary |
| `get_finding` | `id` (GHSA, CVE, or OSV id), `path` | One advisory |
| `get_fix_plan` | `path`, `tier` (`critical`, `review`, `defer`) | Minimum upgrade covering every advisory on a package |
| `explain_score` | `id`, `path` | Deterministic score breakdown (no AI call) |

`patchsense init --print-mcp-config` prints ready-to-paste snippets, for example Cursor's `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "patchsense": { "command": "patchsense", "args": ["mcp"] }
  }
}
```

Claude Code: `claude mcp add --transport stdio patchsense -- patchsense mcp`

## How the score works

Each finding gets 0–100 points:

| Signal | Default weight | Meaning |
|---|---:|---|
| KEV | 35 | CVE is in the CISA Known Exploited Vulnerabilities catalog |
| EPSS | 25 | FIRST exploit probability; full weight at probability 0.10 (`probability` mode) |
| CVSS | 20 | Highest usable CVSS base score / 10 |
| Direct | 10 | Package is a direct dependency |
| Fix available | 10 | OSV lists a fixed version |

Tiers: **Critical** ≥ 70, **Review** ≥ 40, otherwise **Defer**. Weights that do not sum to 100 are scaled to 100, with a warning. All of this is configurable.

## Configuration

PatchSense looks for `patchsense.toml` starting at the scan directory and walking up to the git root. `--config` or `PATCHSENSE_CONFIG` points at a specific file. Precedence: **flag > environment variable > file > built-in default**. Unknown keys are rejected. Every key is optional; this is the full file with defaults:

```toml
schema_version = 1                      # only 1 is accepted

[scan]
ecosystems = ["npm", "pypi", "packagist", "go", "maven", "cargo"]  # default: all
exclude = []                            # extra glob patterns to skip
include_dev = true                      # false = same as --no-dev
fail_on = "critical"                    # critical | review | any | never | 0-100

[scan.go_imports]                       # Go import check, see below
skip_vendor = true                      # skip directories named vendor
skip_testdata = true                    # skip directories named testdata
skip_tests = true                       # skip *_test.go

[score.weights]
kev = 35
epss = 25
cvss = 20
direct = 10
fix_available = 10

[score.epss]
mode = "probability"                    # probability | percentile
saturation = 0.10                       # probability that earns the full EPSS weight

[score.thresholds]
critical = 70
review = 40

[score.unknown_direct]
weight_fraction = 1.0                   # share of the direct weight when the parser cannot tell

[cache]
ttl_hours_osv = 24                      # whole hours
ttl_hours_epss = 24
ttl_hours_kev = 24
strict_cache = false                    # true = --strict-cache

[output]
top = 20                                # rows in the Markdown report

[explain]
agent = "auto"                          # auto | codex | claude | cursor
top = 10                                # findings sent to the agent
timeout_secs = 120

# Suppress an advisory. Repeat the table for more rules.
[[ignore]]
id = "GHSA-xxxx-xxxx-xxxx"              # OSV, GHSA, or CVE id (aliases match too)
reason = "Used only in a build script"  # required
expires = "2027-01-31"                  # optional, inclusive YYYY-MM-DD
```

Ignored findings are listed under `ignored` in the JSON report. An expired rule is dropped with a warning. `[explain] lang` from older configs is accepted but ignored (output is English only).

### Environment variables

| Variable | Effect |
|---|---|
| `PATCHSENSE_CONFIG` | Config file path (overridden by `--config`) |
| `PATCHSENSE_CACHE_DIR` | Cache directory (overridden by `--cache-dir`) |
| `PATCHSENSE_FAIL_ON` | Same values as `--fail-on` |
| `PATCHSENSE_AGENT` | Same values as `--agent` |
| `PATCHSENSE_OSV_URL` | OSV API base URL (default `https://api.osv.dev`) |
| `PATCHSENSE_EPSS_URL` | EPSS API base URL (default `https://api.first.org`) |
| `PATCHSENSE_KEV_URL` | Full URL of the CISA KEV JSON feed |
| `NO_COLOR` | Disable table colors |

## Output formats

| Format | Use |
|---|---|
| `table` | Human-readable priority table. Default when stdout is a terminal. |
| `json` | Full report (`schema_version`, `tool`, `target`, `lockfiles`, `data_sources`, `summary`, `findings`, `ignored`, `warnings`, …). Default when stdout is a pipe or file. Schema: [`schemas/report.v1.json`](https://github.com/Widyasa/patchsense/blob/master/schemas/report.v1.json). |
| `sarif` | SARIF 2.1.0 for GitHub code scanning and other SARIF viewers. |
| `markdown` | Summary and findings table with per-finding details. This is the PR/MR comment body. |
| `gitlab` | GitLab Dependency Scanning report (schema 15.2.5). |

Warnings (stale cache, parse failures, unsupported lockfiles) go to stderr and are also stored in the JSON and Markdown reports.

## Exit codes

| Code | Meaning |
|---|---|
| `0` | Scan finished and no finding reached the `--fail-on` bar |
| `1` | At least one finding reached the `--fail-on` bar |
| `2` | The scan could not run: bad config or flag value, no supported lockfile, unreadable input, every lockfile failed, offline OSV cache missing or expired, `--strict-cache` with missing or expired EPSS/KEV, or a `comment` API error |

`--fail-on never` always exits 0 when the scan runs. `--min-score` never changes the exit code.

## Offline mode and cache

Data is cached in `cache.db`:

| OS | Default location |
|---|---|
| Linux | `$XDG_CACHE_HOME/patchsense/` or `~/.cache/patchsense/` |
| macOS | `~/Library/Caches/patchsense/` (or `$XDG_CACHE_HOME/patchsense/` if set) |
| Windows | `%LOCALAPPDATA%\patchsense\` (or `$XDG_CACHE_HOME/patchsense/` if set) |

Online scans reuse rows younger than their TTL and refetch older ones. With `--offline`:

- **OSV** results must be in the cache and within `ttl_hours_osv`. A missing or expired row exits 2 and the error says which package and how old. No partial report is printed.
- **EPSS / KEV**: a missing row is a warning and that signal counts as absent. An expired row is still used, with a warning naming its age, so scores do not drop just because the cache aged.
- `--strict-cache` (or `[cache] strict_cache = true`) turns missing or expired EPSS/KEV into exit 2.

Typical CI pattern: run one online scan to warm the cache, then `--offline` for later runs.

```bash
patchsense scan .             # fills the cache
patchsense scan . --offline   # reuses it
patchsense cache info         # see what is stored
```

## CI usage

Any CI that has Rust:

```bash
cargo install patchsense --locked
patchsense scan . --no-color --fail-on critical \
  --format json  --output patchsense-report.json \
  --format sarif --output patchsense.sarif
```

GitHub Actions with a PR comment:

```yaml
permissions:
  contents: read
  pull-requests: write

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install patchsense --locked
      - run: patchsense scan . --no-color --format json --output patchsense-report.json
      - if: always() && github.event_name == 'pull_request'
        run: patchsense comment --report patchsense-report.json
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

`pull-requests: write` is the permission for that comment. `patchsense comment` writes a pull-request conversation comment through the issue-comment routes above, and GitHub authorizes those routes on a pull request with `pull-requests: write`. `issues: write` is not required. On GitLab, run the same scan with `--format gitlab --output gl-dependency-scanning-report.json` and `patchsense comment --provider gitlab` with a masked `GITLAB_TOKEN`.

## Supported ecosystems and lockfiles

| Ecosystem | Files |
|---|---|
| npm | `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock` |
| PyPI | `poetry.lock`, `requirements.txt` |
| Packagist (PHP) | `composer.lock` |
| Go | `go.sum` |
| Maven | `pom.xml` |
| crates.io | `Cargo.lock` |

Details:

- Directories named `node_modules`, `vendor`, `target`, `.git`, `.venv`, and `venv` are skipped during discovery.
- **requirements**: discovery also picks up `requirements-*.txt`, `requirements_*.txt`, `*.requirements.txt`, and any `.txt` inside a `requirements/` directory (case-insensitive). `-r`/`--requirement` and `-c`/`--constraint` includes are followed relative to the including file. A cycle or missing include is a warning; remote includes are skipped with a warning.
- **Composer**: `platform` constraints such as `php: ^8.3` are skipped; a concrete version such as `php: 8.3.35` is scanned.
- **Go import check**: when an OSV advisory lists affected import paths, PatchSense checks `.go` files under the scan root. If none of those packages is imported, the finding stays (same score and tier) but gets the note `imported package not seen` (JSON field `import_note`). Subpackages, aliases, dot, and blank imports count. See [`docs/go-import-reachability.md`](https://github.com/Widyasa/patchsense/blob/master/docs/go-import-reachability.md).
- **Not supported**: `bun.lock` / `bun.lockb` (a warning is printed when one is found).

## `--explain` (optional AI summary)

`patchsense scan . --explain` sends the top findings to a local, read-only coding agent CLI found on `PATH`: `codex`, `claude`, or Cursor's `agent` / `cursor-agent`. With `auto` they are tried in that order. The text is labeled `AI explanation, may be wrong`. A missing agent, a timeout (`[explain] timeout_secs`), or a bad response is only a warning and never changes the exit code.

## Troubleshooting / FAQ

**`error: no supported lockfile found in .`** — Run from the project root, pass the lockfile path directly, or check that the file is not under a skipped directory or `--exclude` / `[scan] exclude`.

**`error: offline cache is missing OSV results …`** — Run one scan without `--offline` first, using the same `--cache-dir` (or `PATCHSENSE_CACHE_DIR`). `--offline` together with `--no-cache` can never succeed.

**`offline cache expired …`** — Rerun online, or raise `[cache] ttl_hours_osv`.

**`more than one --format would be written to stdout`** — Give every `--format` except one an `--output`.

**`failed to parse fail_on`** — Use `critical`, `review`, `any`, `never`, or an integer `0`–`100`.

**`unknown field` in the config** — Keys are strict; compare with the configuration reference above.

**`could not detect a comment provider`** — Outside GitHub Actions or GitLab CI, pass `--provider github` or `--provider gitlab`, or use `--dry-run` to preview.

**A Go finding says `imported package not seen`.** — Your code does not import the vulnerable package of that module. The finding is kept because tests, vendored code, and build tags are not fully analysed; verify before ignoring it with an `[[ignore]]` rule.

**Is my code uploaded anywhere?** — No. Only package names and versions are sent to OSV, and CVE ids to EPSS; the KEV catalog is downloaded as a whole. `--offline` makes no network calls.

## Links

- Repository: <https://github.com/Widyasa/patchsense>
- Changelog: <https://github.com/Widyasa/patchsense/blob/master/CHANGELOG.md>
- Known limitations: <https://github.com/Widyasa/patchsense/blob/master/docs/limitations.md>
- Issues: <https://github.com/Widyasa/patchsense/issues>
- Library API docs: <https://docs.rs/patchsense>

## License

Licensed under either of [MIT](https://github.com/Widyasa/patchsense/blob/master/LICENSE-MIT) or [Apache-2.0](https://github.com/Widyasa/patchsense/blob/master/LICENSE-APACHE), at your option.