osv-scanner-mcp 0.14.1 → 0.15.0

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.
package/README.md CHANGED
@@ -18,6 +18,7 @@ An MCP server that wraps Google's [OSV-Scanner](https://github.com/google/osv-sc
18
18
  - **Java project scan**: `scan_java_project` scans only the Java (Maven / Gradle) manifests and returns a formatted report
19
19
  - **JAR/WAR scan**: `scan_java_artifact` detects known vulnerabilities from the metadata inside JAR/WAR archives, for projects without a lockfile or with only shaded/fat JARs (best-effort identification, stated explicitly in `coverage`)
20
20
  - **SBOM scan**: `scan_sbom` checks the dependencies recorded in a CycloneDX/SPDX JSON SBOM. It states explicitly that the SBOM's completeness and its match with the actual artifacts are not verified
21
+ - **Scan history (opt-in)**: With `OSV_MCP_HISTORY=1`, each scan is recorded locally, and `scan_history` lists past runs and shows what changed since the previous run (new and resolved vulnerabilities, version changes, coverage changes)
21
22
  - **Severity-sorted reports**: Vulnerabilities are grouped by package and sorted by CVSS score, with five severity labels (critical / high / medium / low / unknown) and summary counts
22
23
  - **Fixed versions**: Each vulnerability includes `fixed_versions`, sorted correctly by Maven version precedence (including non-semver forms such as `2.17.1-RELEASE`), Semantic Versioning precedence for npm, Go, and Rust, and PEP 440 for PyPI
23
24
  - **Security first**: No shell execution, a fixed argument allowlist, path normalization and boundary checks, scanning private copies instead of the original files, and timeouts and output size limits are built in
@@ -130,6 +131,9 @@ npm run build
130
131
  | `OSV_MCP_PREFER_DOWNLOAD` | If `1` or `true`, always use the checksum-verified downloaded binary instead of osv-scanner on `PATH` (protects against a fake binary planted on `PATH`; an explicit `OSV_SCANNER_PATH` still takes precedence) |
131
132
  | `OSV_MCP_NO_CANDIDATE_CHECK` | If `1` or `true`, `suggest_fix` does not query OSV about recommended versions (`candidate_check` is `disabled`, and recommended versions are not checked for known vulnerabilities that do not affect the current version) |
132
133
  | `OSV_MCP_NO_REMOTE_RESOLUTION` | If `1` or `true`, transitive dependencies of `pom.xml` and `requirements.txt` are not resolved through deps.dev. **This stops only the requests to deps.dev; package names and versions are still sent to `api.osv.dev` for the vulnerability lookup.** Vulnerabilities in transitive dependencies are then not detected, and responses say so in `dependency_resolution.warning`. See [Network destinations and privacy](#network-destinations-and-privacy) |
134
+ | `OSV_MCP_HISTORY` | If `1` or `true`, records each scan in the local scan history (disabled by default). See [`scan_history`](#scan_history) |
135
+ | `OSV_MCP_HISTORY_DIR` | Absolute path of the history directory (default `$XDG_STATE_HOME/osv-scanner-mcp/history`, or `~/.local/state/osv-scanner-mcp/history`) |
136
+ | `OSV_MCP_HISTORY_MAX_ENTRIES` | Number of runs kept (default `500`, maximum `5000`). Older runs are deleted, and the total size is also capped at 100 MiB |
133
137
 
134
138
  > **Recommendation**: The server works without `OSV_MCP_ALLOWED_ROOT`, but then any absolute path can be scanned. To prevent malicious instructions (prompt injection) from scanning unintended directories, set it to the root of your projects (for example `~/projects`). Pass it in the client configuration, for example `"env": {"OSV_MCP_ALLOWED_ROOT": "/Users/you/projects"}` (in Codex CLI's TOML, use a `[mcp_servers.osv-scanner.env]` section).
135
139
 
@@ -172,6 +176,7 @@ As an additional layer of defense, you can limit what the server can read and wr
172
176
  - Read access: the server itself (the directory containing `dist` and `node_modules`), the scan targets (`OSV_MCP_ALLOWED_ROOT`), the temporary directory, and the osv-scanner binary
173
177
  - The temporary directory (`os.tmpdir()`, `$TMPDIR` on macOS) needs read access under **both the symlinked and the resolved path** (on macOS, `/var/folders/...` is a symbolic link to `/private/var/folders/...`). Write access is needed on the resolved path
174
178
  - If you use the automatic download, also allow reading and writing the cache (`$XDG_CACHE_HOME/osv-scanner-mcp`, by default `~/.cache/osv-scanner-mcp`). Otherwise, set `OSV_SCANNER_PATH`
179
+ - If you enable the scan history (`OSV_MCP_HISTORY=1`), also allow reading and writing the history directory (`OSV_MCP_HISTORY_DIR`, or the default under `~/.local/state`). The server warns at startup if it is not allowed
175
180
  - `--allow-child-process` is required to run osv-scanner. **osv-scanner runs as a child process and is not restricted by the permission model** (Node itself warns that this flag weakens the permission model). The impact is limited because osv-scanner only receives verified private copies, but use an OS-level mechanism such as a container if you need stronger isolation
176
181
  - If a required permission is missing, the server prints a warning to stderr at startup, and scans return `permission_denied` (naming the missing permission and the path)
177
182
 
@@ -466,6 +471,44 @@ For npm, Go, PyPI, and Rust, the "same release line" is the range a caret (`^`)
466
471
  - Ecosystems without recommendation support (such as RubyGems from an SBOM) return `verification: "unsupported_ecosystem"`, and current versions that cannot be parsed (such as npm git or local path dependencies) return `verification: "unparseable_version"`. In both cases each CVE gets `tier: "unsupported"` and is not counted as unfixed (whether a fixed version exists is not evaluated; check `fixed_versions` in `scan_project` or `explain_vulnerability`).
467
472
  - `coverage` is the same as in `scan_project`. If a manifest has no lockfile or a file was excluded, `complete` is `false`, and those dependencies are not included in the suggestions. The `skipped_manifests` / `scope_warning` fields of v0.4.2 and earlier were merged into `coverage.skipped_files` / `coverage.warning`.
468
473
 
474
+ ### `scan_history`
475
+
476
+ Lists past runs of the scan tools (`scan_project`, `suggest_fix`, `scan_java_project`, `scan_java_artifact`, `scan_sbom`) and shows what changed between the last two runs. No scan is run.
477
+
478
+ **Recording is disabled by default**, because the history keeps package names and paths on disk. Enable it with `OSV_MCP_HISTORY=1` in the server's `env`; runs are recorded from then on. Without it, this tool returns `history_disabled`.
479
+
480
+ **Input**
481
+
482
+ | Parameter | Type | Description |
483
+ |---|---|---|
484
+ | `project_path` | string (optional) | A scanned target (project directory, lockfile, JAR/WAR, or SBOM). Filters the runs and adds `changes` |
485
+ | `limit` | number (optional) | Number of runs to list (default 10, maximum 50) |
486
+ | `run_id` | string (optional) | An `id` from `runs`. Returns the details of that run instead of the list |
487
+
488
+ **Output**
489
+
490
+ - `history`: the directory, `max_entries`, the number of matching runs (`recorded_runs`), and `skipped_records` if some files could not be read or were invalid
491
+ - `runs`: newest first, with `id`, `started_at`, `tool`, the target, `status` (`ok` or `error` with `error_kind`), `coverage_complete`, vulnerability counts, and `duration_ms`
492
+ - `changes` (with `project_path`): for each kind of scan (`project` for `scan_project` and `suggest_fix`, `java_project`, `artifact`, `sbom`), a comparison of the **last two successful runs**:
493
+ - `new_vulnerabilities` / `resolved_vulnerabilities`: compared per package (ecosystem and name). A vulnerability is the same if its ID or any alias matches, so a change of the primary ID in OSV is not reported as new. A vulnerability that remains after a version change is neither new nor resolved
494
+ - `reason` of a resolved vulnerability: `version_changed` (the package is still reported, at another version: `current_versions`), `package_not_reported` (the package is no longer in the results: it was upgraded to a version without known vulnerabilities, removed, or is no longer scanned; scan results list only vulnerable packages, so these cannot be told apart), or `same_version_not_reported` (same version, no longer reported, for example because the OSV data changed)
495
+ - `version_changes`: packages whose versions changed, with `still_affected` (the vulnerabilities that remain)
496
+ - `coverage_changes`: changes of `coverage.complete` and added or removed manifests
497
+ - `incomplete`: set when the comparison is partial (a run exceeded the record size limit or the findings limit)
498
+ - `run` (with `run_id`): the details of one run, including its manifests, coverage gaps, severity breakdown, and the recorded packages and vulnerability IDs (up to 500 packages)
499
+
500
+ > **Note**: Changes are computed from the OSV data at the time of each scan, so they can also appear when only the vulnerability database changed.
501
+
502
+ **What is recorded**: one JSON file per run, with the tool, time, server and pinned OSV-Scanner versions, the target paths, coverage, counts, and the packages with their vulnerability IDs and aliases (and, for `suggest_fix`, the recommended versions). Vulnerability summaries, details, and error messages are not recorded. A run whose record would exceed 1 MiB keeps only the summary (`findings_omitted`), and if that is still too large, only part of its manifest list (`manifests_truncated`; manifest changes are then not compared). Target paths longer than 4,096 characters (longer than any path that can exist) are truncated and marked `target_truncated`.
503
+
504
+ **Storage and safety**:
505
+
506
+ - The directory is created with mode `0700` and files with `0600`. The server refuses a directory that is a symbolic link or is owned by another user (scans still succeed; a warning is printed to stderr)
507
+ - Each run is written to a temporary file and renamed, so concurrent server processes do not corrupt each other's records. Recording failures never fail the scan
508
+ - History files are treated as untrusted input: each is read with a size limit, validated, and its strings are sanitized before being returned
509
+ - When `OSV_MCP_ALLOWED_ROOT` is set, only runs whose target is inside the allowed root are returned, and a `project_path` outside it is refused with `path_outside_allowed_root`
510
+ - To delete the history, remove the directory
511
+
469
512
  ### `explain_vulnerability`
470
513
 
471
514
  Returns the details of a vulnerability, given its GHSA or CVE ID, **fetched directly from the OSV database API (api.osv.dev)** (no scan is run). IDs from the scan results can be passed as they are. This is especially useful for vulnerabilities published after the client LLM's knowledge cutoff.
@@ -520,6 +563,9 @@ Returns JSON with a machine-readable `kind`, together with `isError: true`:
520
563
  | `invalid_vulnerability_id` | The vulnerability ID is malformed |
521
564
  | `vulnerability_not_found` | No vulnerability with the ID exists in the OSV database |
522
565
  | `api_request_failed` | The request to the OSV API failed (network, timeout, or a non-2xx response) |
566
+ | `history_disabled` | The scan history is disabled (`scan_history`; set `OSV_MCP_HISTORY=1`) |
567
+ | `history_unavailable` | The history directory cannot be used (for example a symbolic link or owned by another user) |
568
+ | `history_run_not_found` | No recorded run with the given `run_id` (or the ID is malformed) |
523
569
  | `internal_error` | An unexpected error (no internal details are returned) |
524
570
 
525
571
  ## Security design
@@ -566,6 +612,7 @@ Design notes and open items are in [docs/DESIGN_TODO.md](docs/DESIGN_TODO.md) (i
566
612
  - [x] English tool descriptions and messages
567
613
  - [x] `scan_project` for Rust (`Cargo.lock`, crates.io crates only) and RustSec informational advisories
568
614
  - [x] `suggest_fix` and direct/transitive dependencies for Rust
615
+ - [x] Scan history and changes between runs (`scan_history`, opt-in)
569
616
  - [ ] Direct and transitive dependencies for `gradle.lockfile` and the YAML/TOML lockfiles
570
617
 
571
618
  ## License
@@ -0,0 +1,124 @@
1
+ /**
2
+ * 同じ対象・同じ種類の2回のスキャンの比較(docs/DESIGN_TODO.md「スキャンの実行履歴」)。
3
+ *
4
+ * - 脆弱性は、IDか別名が1つでも重なれば同じとみなす(OSVの代表IDが入れ替わっても新規・解消と誤らない)
5
+ * - パッケージ(エコシステムと名前)単位で比べる。版が変わっても残っている脆弱性は、新規にも解消にも数えない
6
+ * - スキャン結果には脆弱なパッケージしか載らないため、結果から消えたパッケージが「更新された」のか
7
+ * 「削除された」のかは区別できない。解消の理由はその限界に合わせて分ける
8
+ * - 差分はスキャンの時点のOSVのデータに依存する(コードを変えていなくてもデータの更新で増減する)
9
+ */
10
+ const MAX_LISTED = 500;
11
+ function packageKey(f) {
12
+ return JSON.stringify([f.ecosystem, f.name]);
13
+ }
14
+ function identities(v) {
15
+ return [v.id, ...v.aliases];
16
+ }
17
+ /** パッケージの全ての版の脆弱性のIDと別名の集合(照合を件数に比例する時間で行うため) */
18
+ function identitySet(findings) {
19
+ const ids = new Set();
20
+ for (const f of findings)
21
+ for (const v of f.vulnerabilities)
22
+ for (const id of identities(v))
23
+ ids.add(id);
24
+ return ids;
25
+ }
26
+ /** IDか別名のどれかが集合に含まれるか */
27
+ function overlaps(ids, target) {
28
+ return identities(target).some((id) => ids.has(id));
29
+ }
30
+ function versionsOf(findings) {
31
+ return [...new Set(findings.map((f) => f.version))].sort();
32
+ }
33
+ function byPackage(findings) {
34
+ const map = new Map();
35
+ for (const f of findings) {
36
+ const key = packageKey(f);
37
+ const list = map.get(key) ?? [];
38
+ list.push(f);
39
+ map.set(key, list);
40
+ }
41
+ return map;
42
+ }
43
+ function change(f, v) {
44
+ return { ecosystem: f.ecosystem, name: f.name, version: f.version, id: v.id, aliases: v.aliases, ...(v.informational !== undefined ? { informational: v.informational } : {}) };
45
+ }
46
+ export function compareHistoryRecords(older, newer) {
47
+ const result = {
48
+ from: { id: older.id, started_at: older.started_at, tool: older.tool },
49
+ to: { id: newer.id, started_at: newer.started_at, tool: newer.tool },
50
+ new_vulnerabilities: [],
51
+ resolved_vulnerabilities: [],
52
+ version_changes: [],
53
+ coverage_changes: {},
54
+ };
55
+ if (older.coverage_complete !== newer.coverage_complete) {
56
+ result.coverage_changes.complete = { from: older.coverage_complete, to: newer.coverage_complete };
57
+ }
58
+ const reasons = [];
59
+ if (older.manifests_truncated || newer.manifests_truncated) {
60
+ reasons.push("one of the runs recorded only part of its manifest list (the record size limit was reached), so manifest changes are not shown");
61
+ }
62
+ else if (older.manifests !== undefined && newer.manifests !== undefined) {
63
+ const before = new Set(older.manifests.map((m) => m.path));
64
+ const after = new Set(newer.manifests.map((m) => m.path));
65
+ const added = [...after].filter((p) => !before.has(p)).sort();
66
+ const removed = [...before].filter((p) => !after.has(p)).sort();
67
+ if (added.length > 0)
68
+ result.coverage_changes.manifests_added = added.slice(0, MAX_LISTED);
69
+ if (removed.length > 0)
70
+ result.coverage_changes.manifests_removed = removed.slice(0, MAX_LISTED);
71
+ }
72
+ if (older.findings === undefined || newer.findings === undefined) {
73
+ reasons.unshift("One of the runs has no recorded findings (it exceeded the record size limit), so vulnerabilities cannot be compared");
74
+ result.incomplete = reasons.join("; ");
75
+ return result;
76
+ }
77
+ if (older.findings_truncated || newer.findings_truncated)
78
+ reasons.push("one of the runs recorded only part of its findings (the findings limit was reached)");
79
+ // パッケージ(エコシステムと名前)単位で比べる。版が変わっても残っている脆弱性は新規・解消のどちらにも数えない
80
+ const before = byPackage(older.findings);
81
+ const after = byPackage(newer.findings);
82
+ const empty = [];
83
+ for (const [key, newList] of after) {
84
+ const oldList = before.get(key) ?? empty;
85
+ const oldIds = identitySet(oldList);
86
+ let stillAffected = 0;
87
+ for (const f of newList) {
88
+ for (const v of f.vulnerabilities) {
89
+ if (overlaps(oldIds, v))
90
+ stillAffected++;
91
+ else
92
+ result.new_vulnerabilities.push(change(f, v));
93
+ }
94
+ }
95
+ const fromVersions = versionsOf(oldList);
96
+ const toVersions = versionsOf(newList);
97
+ if (oldList.length > 0 && JSON.stringify(fromVersions) !== JSON.stringify(toVersions)) {
98
+ result.version_changes.push({ ecosystem: newList[0].ecosystem, name: newList[0].name, from: fromVersions, to: toVersions, still_affected: stillAffected });
99
+ }
100
+ }
101
+ for (const [key, oldList] of before) {
102
+ const newList = after.get(key) ?? empty;
103
+ const newIds = identitySet(newList);
104
+ const currentVersions = versionsOf(newList);
105
+ const reason = newList.length === 0 ? "package_not_reported"
106
+ : JSON.stringify(versionsOf(oldList)) === JSON.stringify(currentVersions) ? "same_version_not_reported" : "version_changed";
107
+ for (const f of oldList) {
108
+ for (const v of f.vulnerabilities) {
109
+ if (overlaps(newIds, v))
110
+ continue;
111
+ result.resolved_vulnerabilities.push({ ...change(f, v), reason, ...(reason === "version_changed" ? { current_versions: currentVersions } : {}) });
112
+ }
113
+ }
114
+ }
115
+ for (const list of [result.new_vulnerabilities, result.resolved_vulnerabilities, result.version_changes]) {
116
+ if (list.length > MAX_LISTED) {
117
+ reasons.push(`only the first ${MAX_LISTED} entries of a list are shown`);
118
+ list.length = MAX_LISTED;
119
+ }
120
+ }
121
+ if (reasons.length > 0)
122
+ result.incomplete = [...new Set(reasons)].join("; ");
123
+ return result;
124
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * 実行履歴の1件の内容(要約と差分用の識別子)。ツールの応答(JSON)から作り、読み出し時に形式を確かめる。
3
+ * 結果の全文・エラーメッセージは保存しない(docs/DESIGN_TODO.md「スキャンの実行履歴」)。
4
+ */
5
+ import { asArray, asRecord, asString, asStrings } from "../utils/unknownJson.js";
6
+ import { MAX_RECORD_BYTES } from "./historyStore.js";
7
+ export const HISTORY_TOOLS = ["scan_project", "suggest_fix", "scan_java_project", "scan_java_artifact", "scan_sbom"];
8
+ export function scanKind(tool) {
9
+ switch (tool) {
10
+ case "scan_project":
11
+ case "suggest_fix":
12
+ return "project";
13
+ case "scan_java_project":
14
+ return "java_project";
15
+ case "scan_java_artifact":
16
+ return "artifact";
17
+ case "scan_sbom":
18
+ return "sbom";
19
+ }
20
+ }
21
+ const MAX_FINDING_PACKAGES = 5_000;
22
+ const MAX_FINDING_VULNERABILITIES = 20_000;
23
+ const MAX_ALIASES = 20;
24
+ const MAX_MANIFESTS = 1_000;
25
+ const MAX_TEXT = 512;
26
+ const MAX_SEVERITY_KEYS = 16;
27
+ /** 対象のパスの長さの上限(LinuxのPATH_MAX。これより長いパスは実在しえず、スキャンは失敗する) */
28
+ const MAX_PATH = 4096;
29
+ function text(value) {
30
+ const s = asString(value);
31
+ return s === null ? null : s.slice(0, MAX_TEXT);
32
+ }
33
+ /** 対象のパス。照合に使うため、MAX_TEXTより長いパスもそのまま残す */
34
+ function pathText(value) {
35
+ const s = asString(value);
36
+ return s === null ? null : { path: s.slice(0, MAX_PATH), truncated: s.length > MAX_PATH };
37
+ }
38
+ function count(value) {
39
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
40
+ }
41
+ function vulnerabilitiesOf(raw, budget) {
42
+ const out = [];
43
+ for (const item of asArray(raw)) {
44
+ const vuln = asRecord(item);
45
+ const id = text(vuln?.id);
46
+ if (id === null)
47
+ continue;
48
+ if (budget.remaining <= 0)
49
+ return null;
50
+ budget.remaining--;
51
+ const aliases = [...new Set([...asStrings(vuln.aliases), ...(text(vuln.cve) !== null ? [text(vuln.cve)] : [])])]
52
+ .filter((a) => a !== id).slice(0, MAX_ALIASES).map((a) => a.slice(0, MAX_TEXT));
53
+ const informational = text(vuln.informational);
54
+ out.push({ id, aliases, ...(informational !== null ? { informational } : {}) });
55
+ }
56
+ return out;
57
+ }
58
+ /** ツールの応答から差分用の識別子を取り出す。上限を超えたらtruncated */
59
+ function findingsOf(tool, payload) {
60
+ const findings = [];
61
+ const budget = { remaining: MAX_FINDING_VULNERABILITIES };
62
+ const source = tool === "suggest_fix" ? asArray(payload.suggestions) : asArray(payload.packages);
63
+ for (const item of source) {
64
+ const pkg = asRecord(item);
65
+ if (pkg === null)
66
+ continue;
67
+ const name = text(tool === "suggest_fix" ? pkg.package : pkg.name);
68
+ const version = text(tool === "suggest_fix" ? pkg.current_version : pkg.version);
69
+ const ecosystem = text(pkg.ecosystem);
70
+ if (name === null || version === null || ecosystem === null)
71
+ continue;
72
+ if (findings.length >= MAX_FINDING_PACKAGES)
73
+ return { findings, truncated: true };
74
+ const vulnerabilities = vulnerabilitiesOf(tool === "suggest_fix" ? pkg.per_cve_detail : pkg.vulnerabilities, budget);
75
+ if (vulnerabilities === null)
76
+ return { findings, truncated: true };
77
+ findings.push({
78
+ ecosystem, name, version, vulnerabilities,
79
+ ...(tool === "suggest_fix"
80
+ ? { recommended_upgrade: text(pkg.recommended_upgrade), upgrade_tier: text(pkg.upgrade_tier), verification: text(pkg.verification) ?? "unknown" }
81
+ : {}),
82
+ });
83
+ }
84
+ return { findings, truncated: false };
85
+ }
86
+ function manifestsOf(payload) {
87
+ const coverage = asRecord(payload.coverage);
88
+ const raw = coverage?.manifests ?? payload.manifests;
89
+ if (raw === undefined)
90
+ return undefined;
91
+ const out = [];
92
+ for (const item of asArray(raw).slice(0, MAX_MANIFESTS)) {
93
+ const rec = asRecord(item);
94
+ const p = text(rec?.path ?? item);
95
+ if (p !== null)
96
+ out.push({ path: p, ecosystem: text(rec?.ecosystem), format: text(rec?.format) });
97
+ }
98
+ return out;
99
+ }
100
+ /** 深刻度別の件数。キーの数も抑える(1件の大きさの上限を保証するため) */
101
+ function severityOf(severity) {
102
+ if (severity === null)
103
+ return null;
104
+ return Object.fromEntries(Object.entries(severity).filter(([, v]) => count(v) !== null).slice(0, MAX_SEVERITY_KEYS).map(([k, v]) => [k.slice(0, 32), count(v)]));
105
+ }
106
+ const GAP_FIELDS = ["lockfile_missing", "unpinned_requirements", "unscannable_requirements", "unscannable_crates", "skipped_files", "unidentified_jars", "unidentified_packages"];
107
+ function gapsOf(payload) {
108
+ const coverage = asRecord(payload.coverage);
109
+ const gaps = {};
110
+ for (const field of GAP_FIELDS) {
111
+ const value = coverage?.[field];
112
+ if (Array.isArray(value))
113
+ gaps[field] = value.length;
114
+ }
115
+ const skipped = payload.skipped_manifests; // scan_java_project
116
+ if (Array.isArray(skipped))
117
+ gaps.skipped_manifests = skipped.length;
118
+ return Object.keys(gaps).length > 0 ? gaps : undefined;
119
+ }
120
+ /** ツールの応答(JSONテキスト)から1件を作る。応答を解釈できない場合はエラーの記録にする */
121
+ export function buildHistoryRecord(context, responseText, isError) {
122
+ let payload = null;
123
+ try {
124
+ payload = asRecord(JSON.parse(responseText));
125
+ }
126
+ catch {
127
+ payload = null;
128
+ }
129
+ const input = pathText(context.inputPath) ?? { path: "", truncated: false };
130
+ const resolved = pathText(payload?.project_dir) ?? pathText(context.resolvedInput) ?? input;
131
+ const base = {
132
+ schema: 1,
133
+ id: context.id,
134
+ started_at: context.startedAt.toISOString(),
135
+ duration_ms: Math.max(0, Math.round(context.durationMs)),
136
+ tool: context.tool,
137
+ kind: scanKind(context.tool),
138
+ server_version: context.serverVersion,
139
+ osv_scanner: { pinned_version: context.pinnedScannerVersion, source: context.scannerSource },
140
+ target: { input: input.path, resolved: resolved.path },
141
+ ...(input.truncated || resolved.truncated ? { target_truncated: true } : {}),
142
+ allowed_root_set: context.allowedRootSet,
143
+ };
144
+ const errorKind = text(asRecord(payload?.error)?.kind);
145
+ if (isError || payload === null || errorKind !== null) {
146
+ return { ...base, status: "error", error_kind: errorKind ?? "unknown", coverage_complete: null };
147
+ }
148
+ const coverage = asRecord(payload.coverage);
149
+ const complete = typeof coverage?.complete === "boolean" ? coverage.complete : null;
150
+ const severity = asRecord(payload.severity_breakdown);
151
+ const { findings, truncated } = findingsOf(context.tool, payload);
152
+ const record = {
153
+ ...base,
154
+ status: "ok",
155
+ coverage_complete: complete,
156
+ ...(manifestsOf(payload) !== undefined ? { manifests: manifestsOf(payload) } : {}),
157
+ ...(gapsOf(payload) !== undefined ? { coverage_gaps: gapsOf(payload) } : {}),
158
+ counts: {
159
+ vulnerable_package_count: count(payload.vulnerable_package_count ?? payload.identified_vulnerable_package_count),
160
+ vulnerability_count: count(payload.vulnerability_count ?? payload.identified_vulnerability_count),
161
+ informational_count: count(payload.informational_count) ?? 0,
162
+ severity_breakdown: severityOf(severity),
163
+ },
164
+ findings,
165
+ ...(truncated ? { findings_truncated: true } : {}),
166
+ };
167
+ if (context.tool === "suggest_fix") {
168
+ // suggest_fixの応答には脆弱性の総数が無いため、提案の脆弱性の数を数える
169
+ record.counts.vulnerability_count = findings.reduce((n, f) => n + f.vulnerabilities.filter((v) => v.informational === undefined).length, 0);
170
+ }
171
+ return record;
172
+ }
173
+ function fitting(value) {
174
+ const text = JSON.stringify(value);
175
+ return Buffer.byteLength(text, "utf8") <= MAX_RECORD_BYTES ? text : null;
176
+ }
177
+ /**
178
+ * 保存する文字列。1件の上限(読み出しの上限と同じ)に必ず収める:
179
+ * findingsを省き、それでも超えればマニフェストの一覧を半分ずつ減らし、最後は一覧を省く
180
+ * (残りの項目は文字列の長さ・件数に上限があり、上限を十分に下回る。対象のパスも含む)
181
+ */
182
+ export function serializeHistoryRecord(record) {
183
+ const full = fitting(record);
184
+ if (full !== null)
185
+ return full;
186
+ const { findings: _findings, findings_truncated: _truncated, manifests, ...rest } = record;
187
+ const summary = { ...rest, findings_omitted: true };
188
+ if (manifests === undefined)
189
+ return JSON.stringify(summary);
190
+ const withAll = fitting({ ...summary, manifests });
191
+ if (withAll !== null)
192
+ return withAll;
193
+ for (let n = Math.floor(manifests.length / 2); n > 0; n = Math.floor(n / 2)) {
194
+ const partial = fitting({ ...summary, manifests: manifests.slice(0, n), manifests_truncated: true });
195
+ if (partial !== null)
196
+ return partial;
197
+ }
198
+ const last = fitting({ ...summary, manifests_truncated: true });
199
+ // 残りの項目はどれも大きさに上限があるため通常は届かない。読み戻せないファイルは書かない(記録側が警告する)
200
+ if (last === null)
201
+ throw new Error("The history record exceeds the size limit even without findings and manifests");
202
+ return last;
203
+ }
204
+ /** 読み出した記録の形式を確かめる(履歴のファイルは信頼しない)。解釈できなければnull */
205
+ export function parseHistoryRecord(id, raw) {
206
+ const rec = asRecord(raw);
207
+ if (rec === null || rec.schema !== 1 || rec.id !== id)
208
+ return null;
209
+ const tool = asString(rec.tool);
210
+ if (tool === null || !HISTORY_TOOLS.includes(tool))
211
+ return null;
212
+ const target = asRecord(rec.target);
213
+ const input = pathText(target?.input)?.path ?? null;
214
+ const resolved = pathText(target?.resolved)?.path ?? null;
215
+ const startedAt = text(rec.started_at);
216
+ const status = rec.status === "ok" || rec.status === "error" ? rec.status : null;
217
+ if (input === null || resolved === null || startedAt === null || status === null)
218
+ return null;
219
+ const osv = asRecord(rec.osv_scanner);
220
+ const source = asString(osv?.source);
221
+ const findings = [];
222
+ for (const item of asArray(rec.findings).slice(0, MAX_FINDING_PACKAGES)) {
223
+ const f = asRecord(item);
224
+ const ecosystem = text(f?.ecosystem);
225
+ const name = text(f?.name);
226
+ const version = text(f?.version);
227
+ if (ecosystem === null || name === null || version === null)
228
+ continue;
229
+ const vulnerabilities = (vulnerabilitiesOf(f.vulnerabilities, { remaining: MAX_FINDING_VULNERABILITIES }) ?? []);
230
+ findings.push({
231
+ ecosystem, name, version, vulnerabilities,
232
+ ...("recommended_upgrade" in f ? { recommended_upgrade: text(f.recommended_upgrade) } : {}),
233
+ ...("upgrade_tier" in f ? { upgrade_tier: text(f.upgrade_tier) } : {}),
234
+ ...(text(f.verification) !== null ? { verification: text(f.verification) } : {}),
235
+ });
236
+ }
237
+ const counts = asRecord(rec.counts);
238
+ const severity = asRecord(counts?.severity_breakdown);
239
+ return {
240
+ schema: 1,
241
+ id,
242
+ started_at: startedAt,
243
+ duration_ms: count(rec.duration_ms) ?? 0,
244
+ tool: tool,
245
+ kind: scanKind(tool),
246
+ server_version: text(rec.server_version) ?? "unknown",
247
+ osv_scanner: {
248
+ pinned_version: text(osv?.pinned_version) ?? "unknown",
249
+ source: source === "OSV_SCANNER_PATH" || source === "pinned_download" ? source : "PATH_or_pinned_download",
250
+ },
251
+ target: { input, resolved },
252
+ ...(rec.target_truncated === true ? { target_truncated: true } : {}),
253
+ allowed_root_set: rec.allowed_root_set === true,
254
+ status,
255
+ ...(status === "error" ? { error_kind: text(rec.error_kind) ?? "unknown" } : {}),
256
+ coverage_complete: typeof rec.coverage_complete === "boolean" ? rec.coverage_complete : null,
257
+ ...(Array.isArray(rec.manifests) ? { manifests: manifestsOf({ manifests: rec.manifests }) ?? [] } : {}),
258
+ ...(asRecord(rec.coverage_gaps) !== null
259
+ ? { coverage_gaps: Object.fromEntries(Object.entries(asRecord(rec.coverage_gaps)).filter(([, v]) => count(v) !== null).map(([k, v]) => [k.slice(0, 64), count(v)])) }
260
+ : {}),
261
+ ...(counts !== null
262
+ ? {
263
+ counts: {
264
+ vulnerable_package_count: count(counts.vulnerable_package_count),
265
+ vulnerability_count: count(counts.vulnerability_count),
266
+ informational_count: count(counts.informational_count) ?? 0,
267
+ severity_breakdown: severityOf(severity),
268
+ },
269
+ }
270
+ : {}),
271
+ ...(Array.isArray(rec.findings) ? { findings } : {}),
272
+ ...(rec.findings_truncated === true ? { findings_truncated: true } : {}),
273
+ ...(rec.findings_omitted === true ? { findings_omitted: true } : {}),
274
+ ...(rec.manifests_truncated === true ? { manifests_truncated: true } : {}),
275
+ };
276
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * スキャンのツール呼び出しを実行履歴に記録する。記録の失敗はスキャンを失敗させない(stderrに警告するだけ)。
3
+ */
4
+ import { realpath } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { PINNED_OSV_SCANNER_VERSION } from "../osv/binaryDownloader.js";
7
+ import { buildHistoryRecord, serializeHistoryRecord } from "./historyRecord.js";
8
+ import { newRecordId, writeHistoryRecord } from "./historyStore.js";
9
+ function scannerSource(env) {
10
+ if (env.OSV_SCANNER_PATH?.trim())
11
+ return "OSV_SCANNER_PATH";
12
+ const prefer = env.OSV_MCP_PREFER_DOWNLOAD?.trim().toLowerCase();
13
+ if (prefer === "1" || prefer === "true" || prefer === "yes")
14
+ return "pinned_download";
15
+ return "PATH_or_pinned_download";
16
+ }
17
+ async function resolveInput(input) {
18
+ const absolute = path.resolve(input);
19
+ try {
20
+ return await realpath(absolute);
21
+ }
22
+ catch {
23
+ return absolute;
24
+ }
25
+ }
26
+ /** ツールを実行し、履歴が有効なら結果の要約を記録してから、結果をそのまま返す */
27
+ export async function withHistory(tool, inputPath, run, options) {
28
+ if (!options.config.enabled)
29
+ return run();
30
+ const startedAt = new Date();
31
+ const started = performance.now();
32
+ const result = await run();
33
+ const durationMs = performance.now() - started;
34
+ try {
35
+ const record = buildHistoryRecord({
36
+ id: newRecordId(startedAt),
37
+ startedAt,
38
+ durationMs,
39
+ tool,
40
+ serverVersion: options.serverVersion,
41
+ pinnedScannerVersion: PINNED_OSV_SCANNER_VERSION,
42
+ scannerSource: scannerSource(options.env ?? process.env),
43
+ inputPath: typeof inputPath === "string" ? inputPath : String(inputPath),
44
+ resolvedInput: await resolveInput(typeof inputPath === "string" ? inputPath : String(inputPath)),
45
+ allowedRootSet: options.allowedRootSet,
46
+ }, result.content[0]?.text ?? "", result.isError === true);
47
+ await writeHistoryRecord(options.config, record.id, serializeHistoryRecord(record));
48
+ }
49
+ catch (error) {
50
+ (options.warn ?? ((m) => console.error(m)))(`osv-scanner-mcp: [warning] The scan history could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
51
+ }
52
+ return result;
53
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * スキャンの実行履歴の保存(docs/DESIGN_TODO.md「スキャンの実行履歴」)。
3
+ *
4
+ * - 既定は無効。OSV_MCP_HISTORY=1の場合だけ記録する(社内のパッケージ名・パスをディスクに残すため)
5
+ * - 保存先: OSV_MCP_HISTORY_DIR(絶対パス) → $XDG_STATE_HOME/osv-scanner-mcp/history → ~/.local/state/osv-scanner-mcp/history
6
+ * - 1回=1ファイル(`<UTC時刻>-<乱数>.json`)。一時ファイルに書いてからrenameする(複数のプロセスが同時に書いても混ざらない)
7
+ * - ディレクトリは0700・ファイルは0600。シンボリックリンク・他人の所有するディレクトリには書かない
8
+ * - 履歴のファイルは本サーバー以外も書き換えられる前提で、読むときはサイズの上限付きで安全に読み、形式を確かめる
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+ import { chmod, lstat, mkdir, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { readRegularFile } from "../utils/safeRead.js";
15
+ export const HISTORY_ENV = "OSV_MCP_HISTORY";
16
+ export const HISTORY_DIR_ENV = "OSV_MCP_HISTORY_DIR";
17
+ export const HISTORY_MAX_ENTRIES_ENV = "OSV_MCP_HISTORY_MAX_ENTRIES";
18
+ const DEFAULT_MAX_ENTRIES = 500;
19
+ const MAX_MAX_ENTRIES = 5_000;
20
+ /** 履歴全体の合計サイズの上限 */
21
+ const DEFAULT_MAX_TOTAL_BYTES = 100 * 1024 * 1024;
22
+ /** 1件の上限。超える場合は差分用の識別子を省いて要約だけ記録する */
23
+ export const MAX_RECORD_BYTES = 1024 * 1024;
24
+ /** scan_historyで1回に読む量の合計の上限 */
25
+ const MAX_READ_TOTAL_BYTES = 64 * 1024 * 1024;
26
+ /** 書き込みに失敗して残った一時ファイルを消すまでの時間 */
27
+ const STALE_TMP_MS = 60 * 60 * 1000;
28
+ const RECORD_NAME = /^\d{8}T\d{9}Z-[0-9a-f]{12}\.json$/;
29
+ const TMP_NAME = /^\.\d{8}T\d{9}Z-[0-9a-f]{12}\.json\.tmp$/;
30
+ function truthy(value) {
31
+ const v = value?.trim().toLowerCase();
32
+ return v === "1" || v === "true" || v === "yes";
33
+ }
34
+ /** 既定の保存先(XDG Base Directoryの状態の置き場所) */
35
+ export function defaultHistoryDir(env = process.env) {
36
+ const state = env.XDG_STATE_HOME?.trim();
37
+ const base = state !== undefined && state !== "" && path.isAbsolute(state) ? state : path.join(os.homedir(), ".local", "state");
38
+ return path.join(base, "osv-scanner-mcp", "history");
39
+ }
40
+ export function historyConfigFromEnv(env = process.env) {
41
+ const explicit = env[HISTORY_DIR_ENV]?.trim();
42
+ const dir = explicit !== undefined && explicit !== "" && path.isAbsolute(explicit) ? path.resolve(explicit) : defaultHistoryDir(env);
43
+ const requested = Number.parseInt(env[HISTORY_MAX_ENTRIES_ENV]?.trim() ?? "", 10);
44
+ const maxEntries = Number.isInteger(requested) && requested > 0 ? Math.min(requested, MAX_MAX_ENTRIES) : DEFAULT_MAX_ENTRIES;
45
+ return { enabled: truthy(env[HISTORY_ENV]), dir, maxEntries, maxTotalBytes: DEFAULT_MAX_TOTAL_BYTES };
46
+ }
47
+ /** 設定が無効なまま読み書きしようとした場合や、保存先が安全でない場合の理由 */
48
+ export class HistoryUnavailableError extends Error {
49
+ }
50
+ /**
51
+ * 保存先を作成し、安全か確かめる: 実体のディレクトリ(シンボリックリンクでない)で、所有者が自分。
52
+ * 権限が広ければ0700に絞る。読み書きには解決後の実パスを使う(読み込みの境界の確認が実パスで行われるため)
53
+ */
54
+ export async function ensureHistoryDir(dir) {
55
+ await mkdir(dir, { recursive: true, mode: 0o700 });
56
+ const info = await lstat(dir);
57
+ if (info.isSymbolicLink() || !info.isDirectory()) {
58
+ throw new HistoryUnavailableError(`The history directory is not a regular directory: ${dir}`);
59
+ }
60
+ const uid = process.getuid?.();
61
+ if (uid !== undefined && info.uid !== uid) {
62
+ throw new HistoryUnavailableError(`The history directory is not owned by the current user: ${dir}`);
63
+ }
64
+ if ((info.mode & 0o077) !== 0)
65
+ await chmod(dir, 0o700);
66
+ return realpath(dir);
67
+ }
68
+ /** ファイル名に使うUTC時刻(並べ替えで時刻順になる) */
69
+ function stamp(date) {
70
+ return date.toISOString().replace(/[-:]/g, "").replace(".", "");
71
+ }
72
+ export function newRecordId(date = new Date()) {
73
+ return `${stamp(date)}-${randomBytes(6).toString("hex")}`;
74
+ }
75
+ /** 1件を書く。一時ファイルに書いてからrenameし、古いものを削除する */
76
+ export async function writeHistoryRecord(config, id, content) {
77
+ const dir = await ensureHistoryDir(config.dir);
78
+ const name = `${id}.json`;
79
+ if (!RECORD_NAME.test(name))
80
+ throw new HistoryUnavailableError(`Invalid history record id: ${id}`);
81
+ const tmp = path.join(dir, `.${name}.tmp`);
82
+ await writeFile(tmp, content, { mode: 0o600, flag: "wx" });
83
+ try {
84
+ await rename(tmp, path.join(dir, name));
85
+ }
86
+ catch (error) {
87
+ await unlink(tmp).catch(() => undefined);
88
+ throw error;
89
+ }
90
+ await pruneHistory({ ...config, dir });
91
+ }
92
+ /** 件数・合計サイズの上限を超えた古い記録と、残った一時ファイルを削除する(他のプロセスとの競合は無視する) */
93
+ export async function pruneHistory(config, now = Date.now()) {
94
+ const names = await readdir(config.dir);
95
+ for (const name of names.filter((n) => TMP_NAME.test(n))) {
96
+ const file = path.join(config.dir, name);
97
+ const info = await lstat(file).catch(() => null);
98
+ if (info !== null && info.isFile() && now - info.mtimeMs > STALE_TMP_MS)
99
+ await unlink(file).catch(() => undefined);
100
+ }
101
+ const records = names.filter((n) => RECORD_NAME.test(n)).sort(); // 古い順
102
+ const remove = new Set(records.slice(0, Math.max(0, records.length - config.maxEntries)));
103
+ let total = 0;
104
+ // 新しい順に合計し、上限を超えた分より古いものを削除する
105
+ for (const name of [...records].reverse()) {
106
+ if (remove.has(name))
107
+ continue;
108
+ const info = await stat(path.join(config.dir, name)).catch(() => null);
109
+ if (info === null)
110
+ continue;
111
+ total += info.size;
112
+ if (total > config.maxTotalBytes)
113
+ remove.add(name);
114
+ }
115
+ for (const name of remove)
116
+ await unlink(path.join(config.dir, name)).catch(() => undefined);
117
+ }
118
+ /**
119
+ * 記録を新しい順に読む。読めない・大きすぎる・JSONでないファイル、読み込みの合計の上限を超えた分はskippedに数える
120
+ */
121
+ export async function readHistoryRecords(config) {
122
+ const dir = await ensureHistoryDir(config.dir);
123
+ const names = (await readdir(dir)).filter((n) => RECORD_NAME.test(n)).sort().reverse().slice(0, config.maxEntries);
124
+ const records = [];
125
+ let skipped = 0;
126
+ let remaining = MAX_READ_TOTAL_BYTES;
127
+ for (const name of names) {
128
+ if (remaining <= 0) {
129
+ skipped++;
130
+ continue;
131
+ }
132
+ const read = await readRegularFile(path.join(dir, name), { maxBytes: Math.min(MAX_RECORD_BYTES, remaining), boundary: dir });
133
+ if (!read.ok) {
134
+ if (read.failure !== "not_found")
135
+ skipped++; // 読んでいる間に削除されたものは数えない
136
+ continue;
137
+ }
138
+ remaining -= read.bytes.length;
139
+ try {
140
+ records.push({ id: name.slice(0, -".json".length), data: JSON.parse(read.bytes.toString("utf8")) });
141
+ }
142
+ catch {
143
+ skipped++;
144
+ }
145
+ }
146
+ return { records, skipped };
147
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,9 @@
11
11
  * - OSV_MCP_PREFER_DOWNLOAD: 1/true指定時、PATH上のバイナリを使わず検証済み自動ダウンロードを優先
12
12
  * - OSV_MCP_NO_REMOTE_RESOLUTION: 1/true指定時、pom.xmlの推移的依存をdeps.devで解決しない
13
13
  * - OSV_MCP_NO_CANDIDATE_CHECK: 1/true指定時、suggest_fixの推奨先のOSV照会を行わない
14
+ * - OSV_MCP_HISTORY: 1/true指定時、スキャンの実行履歴(要約と差分用の識別子)をローカルに記録する(既定は無効)
15
+ * - OSV_MCP_HISTORY_DIR: 実行履歴の保存先(絶対パス。既定は$XDG_STATE_HOME/osv-scanner-mcp/history)
16
+ * - OSV_MCP_HISTORY_MAX_ENTRIES: 保持する実行履歴の件数(既定500)
14
17
  */
15
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
19
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -21,6 +24,9 @@ import { handleScanProject } from "./tools/scanProject.js";
21
24
  import { handleScanJavaArtifact } from "./tools/scanJavaArtifact.js";
22
25
  import { handleScanSbom } from "./tools/scanSbom.js";
23
26
  import { handleSuggestFix } from "./tools/suggestFix.js";
27
+ import { handleScanHistory } from "./tools/scanHistory.js";
28
+ import { withHistory } from "./history/historyRecorder.js";
29
+ import { historyConfigFromEnv } from "./history/historyStore.js";
24
30
  import { realpathSync } from "node:fs";
25
31
  import os from "node:os";
26
32
  import { defaultCacheDir } from "./osv/binaryDownloader.js";
@@ -54,15 +60,20 @@ if (startupWarning !== null) {
54
60
  tmpDirReal,
55
61
  cacheDir: scannerPath ? undefined : defaultCacheDir(),
56
62
  scannerPath: scannerPath || undefined,
63
+ historyDir: historyConfigFromEnv().enabled ? historyConfigFromEnv().dir : undefined,
57
64
  })) {
58
65
  console.error(`osv-scanner-mcp: [warning] ${warning}`);
59
66
  }
60
67
  }
61
68
  // NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
69
+ const SERVER_VERSION = "0.15.0";
62
70
  const server = new McpServer({
63
71
  name: "osv-scanner-mcp",
64
- version: "0.14.1",
72
+ version: SERVER_VERSION,
65
73
  });
74
+ const historyConfig = historyConfigFromEnv();
75
+ /** スキャンのツールを実行履歴に記録する(OSV_MCP_HISTORYが無効なら何もしない) */
76
+ const recorded = (tool, input, run) => withHistory(tool, input, run, { config: historyConfig, serverVersion: SERVER_VERSION, allowedRootSet: allowedRootFromEnv() !== undefined });
66
77
  server.registerTool("scan_project", {
67
78
  title: "Scan project dependencies for vulnerabilities (Java / JavaScript / Python / Go / Rust)",
68
79
  description: "Detects the lockfiles and manifests in a project and scans the dependencies for known vulnerabilities (CVE/GHSA). " +
@@ -81,7 +92,7 @@ server.registerTool("scan_project", {
81
92
  .min(1)
82
93
  .describe("Absolute path to the project directory, or to a supported lockfile or manifest"),
83
94
  },
84
- }, async ({ project_path }) => handleScanProject({ project_path }, { allowedRoot: allowedRootFromEnv() }));
95
+ }, async ({ project_path }) => recorded("scan_project", project_path, () => handleScanProject({ project_path }, { allowedRoot: allowedRootFromEnv() })));
85
96
  server.registerTool("scan_java_project", {
86
97
  title: "Scan a Java project for vulnerabilities",
87
98
  description: "Scans a Java (Maven) project with Google OSV-Scanner and returns the known vulnerabilities (CVE/GHSA) in its dependencies as a JSON report sorted by severity. " +
@@ -94,7 +105,7 @@ server.registerTool("scan_java_project", {
94
105
  .min(1)
95
106
  .describe("Absolute path to the project directory, or to a pom.xml / gradle.lockfile"),
96
107
  },
97
- }, async ({ project_path }) => handleScanJavaProject({ project_path }, { allowedRoot: allowedRootFromEnv() }));
108
+ }, async ({ project_path }) => recorded("scan_java_project", project_path, () => handleScanJavaProject({ project_path }, { allowedRoot: allowedRootFromEnv() })));
98
109
  server.registerTool("suggest_fix", {
99
110
  title: "Recommend upgrades that fix vulnerabilities",
100
111
  description: "Scans the project with the same detection as scan_project and recommends an upgrade version for each vulnerable package. " +
@@ -118,7 +129,7 @@ server.registerTool("suggest_fix", {
118
129
  .min(1)
119
130
  .describe("Absolute path to the project directory, or to a supported lockfile or manifest"),
120
131
  },
121
- }, async ({ project_path }) => handleSuggestFix({ project_path }, { allowedRoot: allowedRootFromEnv() }));
132
+ }, async ({ project_path }) => recorded("suggest_fix", project_path, () => handleSuggestFix({ project_path }, { allowedRoot: allowedRootFromEnv() })));
122
133
  server.registerTool("explain_vulnerability", {
123
134
  title: "Explain a vulnerability",
124
135
  description: "Fetches the details of a vulnerability by GHSA or CVE ID from the OSV database (api.osv.dev). " +
@@ -141,7 +152,7 @@ server.registerTool("scan_java_artifact", {
141
152
  inputSchema: {
142
153
  artifact_path: z.string().min(1).describe("Absolute path to a JAR/WAR file, or to a directory to search"),
143
154
  },
144
- }, async ({ artifact_path }) => handleScanJavaArtifact({ artifact_path }, { allowedRoot: allowedRootFromEnv() }));
155
+ }, async ({ artifact_path }) => recorded("scan_java_artifact", artifact_path, () => handleScanJavaArtifact({ artifact_path }, { allowedRoot: allowedRootFromEnv() })));
145
156
  server.registerTool("scan_sbom", {
146
157
  title: "Scan an SBOM for vulnerabilities",
147
158
  description: "Scans the dependencies that can be identified in a CycloneDX 1.4/1.5/1.6 or SPDX 2.2/2.3 JSON SBOM. " +
@@ -150,7 +161,22 @@ server.registerTool("scan_sbom", {
150
161
  inputSchema: {
151
162
  sbom_path: z.string().min(1).describe("Absolute path to a CycloneDX/SPDX JSON SBOM file"),
152
163
  },
153
- }, async ({ sbom_path }) => handleScanSbom({ sbom_path }, { allowedRoot: allowedRootFromEnv() }));
164
+ }, async ({ sbom_path }) => recorded("scan_sbom", sbom_path, () => handleScanSbom({ sbom_path }, { allowedRoot: allowedRootFromEnv() })));
165
+ server.registerTool("scan_history", {
166
+ title: "Show the scan history and what changed",
167
+ description: "Lists recorded runs of the scan tools (scan_project, suggest_fix, scan_java_project, scan_java_artifact, scan_sbom), newest first, " +
168
+ "with their time, target, coverage, and vulnerability counts. With project_path, also compares the last two successful runs of each kind for that target: " +
169
+ "new_vulnerabilities, resolved_vulnerabilities (reason: version_changed, package_not_reported, or same_version_not_reported), version_changes, and coverage_changes. " +
170
+ "With run_id, returns the details of one run, including the recorded packages and vulnerability IDs. " +
171
+ "Recording is off by default and must be enabled with OSV_MCP_HISTORY=1 (otherwise the error kind is history_disabled). " +
172
+ "Changes reflect the OSV data at the time of each scan, so they can also appear when only the vulnerability database changed. " +
173
+ "Only summaries and identifiers are kept, not full results.",
174
+ inputSchema: {
175
+ project_path: z.string().min(1).optional().describe("Absolute path of a scanned target (project directory, lockfile, JAR/WAR, or SBOM) to filter by and compare"),
176
+ limit: z.number().int().min(1).max(50).optional().describe("Number of runs to list (default 10, maximum 50)"),
177
+ run_id: z.string().min(1).max(64).optional().describe("ID of a run from the runs list, to show its details"),
178
+ },
179
+ }, async ({ project_path, limit, run_id }) => handleScanHistory({ project_path, limit, run_id }, { config: historyConfig, allowedRoot: allowedRootFromEnv() }));
154
180
  // シグナル・stdinの終了時に一時ディレクトリと実行中のosv-scannerを片付ける(接続前に登録する)
155
181
  installShutdownHandlers();
156
182
  // 前回の異常終了で残った一時ディレクトリの掃除。起動を遅らせないよう待たない
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `scan_history`ツールのハンドラ: スキャンの実行履歴の一覧・1回の詳細・直近2回の比較を返す
3
+ * (docs/DESIGN_TODO.md「スキャンの実行履歴」)。
4
+ *
5
+ * - 履歴が無効ならhistory_disabled(空の結果は「履歴がない」と誤読されるため)
6
+ * - OSV_MCP_ALLOWED_ROOT設定時は、対象が許可ルートの内側の回だけを返す
7
+ * - 履歴のファイルは信頼しない入力として形式を確かめ、文字列は返す前にも無害化する
8
+ */
9
+ import { realpath } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { ScanToolError } from "../errors.js";
12
+ import { compareHistoryRecords } from "../history/historyDiff.js";
13
+ import { parseHistoryRecord } from "../history/historyRecord.js";
14
+ import { HISTORY_ENV, HistoryUnavailableError, readHistoryRecords } from "../history/historyStore.js";
15
+ import { sanitizeExternalText } from "../utils/externalText.js";
16
+ import { assertInsideAllowedRoot, isInsideDir } from "../utils/projectWalk.js";
17
+ import { errorResult, jsonResult } from "./toolResult.js";
18
+ const DEFAULT_LIMIT = 10;
19
+ const MAX_LIMIT = 50;
20
+ const MAX_DETAIL_PACKAGES = 500;
21
+ const RUN_ID = /^\d{8}T\d{9}Z-[0-9a-f]{12}$/;
22
+ const NOTE = "Changes are computed from the vulnerability data at the time of each scan: they can also appear when the OSV database changes " +
23
+ "without any change to the project. Scan results list only vulnerable packages, so a package that disappears from the results " +
24
+ "may have been upgraded to a version without known vulnerabilities, removed, or no longer scanned (reason: package_not_reported)";
25
+ /** 応答に含める文字列をすべて無害化する(履歴のファイルは書き換えられうる) */
26
+ function sanitizeDeep(value) {
27
+ if (typeof value === "string")
28
+ return sanitizeExternalText(value);
29
+ if (Array.isArray(value))
30
+ return value.map(sanitizeDeep);
31
+ if (typeof value === "object" && value !== null) {
32
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [sanitizeExternalText(k), sanitizeDeep(v)]));
33
+ }
34
+ return value;
35
+ }
36
+ async function resolveLoosely(input) {
37
+ const absolute = path.resolve(input);
38
+ try {
39
+ return await realpath(absolute);
40
+ }
41
+ catch {
42
+ return absolute; // 削除済みのプロジェクトの履歴も引けるようにする
43
+ }
44
+ }
45
+ function summary(record) {
46
+ return {
47
+ id: record.id,
48
+ started_at: record.started_at,
49
+ tool: record.tool,
50
+ target: record.target.input,
51
+ project: record.target.resolved,
52
+ status: record.status,
53
+ ...(record.error_kind !== undefined ? { error_kind: record.error_kind } : {}),
54
+ coverage_complete: record.coverage_complete,
55
+ ...(record.counts !== undefined
56
+ ? {
57
+ vulnerable_package_count: record.counts.vulnerable_package_count,
58
+ vulnerability_count: record.counts.vulnerability_count,
59
+ ...(record.counts.informational_count > 0 ? { informational_count: record.counts.informational_count } : {}),
60
+ }
61
+ : {}),
62
+ duration_ms: record.duration_ms,
63
+ };
64
+ }
65
+ function detail(record) {
66
+ const findings = record.findings ?? [];
67
+ return {
68
+ ...summary(record),
69
+ server_version: record.server_version,
70
+ osv_scanner: record.osv_scanner,
71
+ allowed_root_set: record.allowed_root_set,
72
+ ...(record.target_truncated ? { target_truncated: true } : {}),
73
+ ...(record.manifests !== undefined ? { manifests: record.manifests } : {}),
74
+ ...(record.manifests_truncated ? { manifests_truncated: true } : {}),
75
+ ...(record.coverage_gaps !== undefined ? { coverage_gaps: record.coverage_gaps } : {}),
76
+ ...(record.counts?.severity_breakdown ? { severity_breakdown: record.counts.severity_breakdown } : {}),
77
+ ...(record.findings !== undefined ? { findings: findings.slice(0, MAX_DETAIL_PACKAGES) } : {}),
78
+ ...(findings.length > MAX_DETAIL_PACKAGES ? { findings_omitted_from_response: findings.length - MAX_DETAIL_PACKAGES } : {}),
79
+ ...(record.findings_truncated ? { findings_truncated: true } : {}),
80
+ ...(record.findings_omitted ? { findings_omitted: true } : {}),
81
+ };
82
+ }
83
+ function matchesProject(record, resolvedQuery, absoluteQuery) {
84
+ return record.target.resolved === resolvedQuery || path.resolve(record.target.input) === absoluteQuery;
85
+ }
86
+ /** 種類ごとに、直近の成功した2回を比べる */
87
+ function changesByKind(records) {
88
+ const kinds = [...new Set(records.map((r) => r.kind))];
89
+ return kinds.map((kind) => {
90
+ const ok = records.filter((r) => r.kind === kind && r.status === "ok"); // 新しい順
91
+ if (ok.length < 2)
92
+ return { kind, note: "Fewer than two successful runs of this kind are recorded, so there is nothing to compare yet" };
93
+ return { kind, ...compareHistoryRecords(ok[1], ok[0]) };
94
+ });
95
+ }
96
+ export async function handleScanHistory(args, options) {
97
+ try {
98
+ if (!options.config.enabled) {
99
+ throw new ScanToolError("history_disabled", `The scan history is disabled. Set ${HISTORY_ENV}=1 in the MCP server environment to record scans from then on ` +
100
+ "(records are kept locally; see the README for the location and limits)");
101
+ }
102
+ if (args.run_id !== undefined && !RUN_ID.test(args.run_id)) {
103
+ throw new ScanToolError("history_run_not_found", "run_id is not a valid history record ID (use an id from the runs list)");
104
+ }
105
+ const limit = Math.min(Math.max(1, Math.floor(args.limit ?? DEFAULT_LIMIT)), MAX_LIMIT);
106
+ const rootReal = options.allowedRoot !== undefined ? await resolveLoosely(options.allowedRoot) : undefined;
107
+ let resolvedQuery;
108
+ let absoluteQuery;
109
+ if (args.project_path !== undefined) {
110
+ if (typeof args.project_path !== "string" || args.project_path.trim() === "") {
111
+ throw new ScanToolError("project_not_found", "project_path is empty");
112
+ }
113
+ absoluteQuery = path.resolve(args.project_path);
114
+ resolvedQuery = await resolveLoosely(args.project_path);
115
+ if (rootReal !== undefined)
116
+ assertInsideAllowedRoot(resolvedQuery, rootReal);
117
+ }
118
+ let stored;
119
+ try {
120
+ stored = await readHistoryRecords(options.config);
121
+ }
122
+ catch (error) {
123
+ if (error instanceof HistoryUnavailableError)
124
+ throw new ScanToolError("history_unavailable", error.message);
125
+ throw error;
126
+ }
127
+ let skipped = stored.skipped;
128
+ const records = [];
129
+ for (const { id, data } of stored.records) {
130
+ const record = parseHistoryRecord(id, data);
131
+ if (record === null) {
132
+ skipped++;
133
+ continue;
134
+ }
135
+ // 許可ルートを狭めて起動し直した場合に、範囲外の過去の情報を返さない
136
+ if (rootReal !== undefined && !isInsideDir(rootReal, record.target.resolved))
137
+ continue;
138
+ records.push(record);
139
+ }
140
+ if (args.run_id !== undefined) {
141
+ const record = records.find((r) => r.id === args.run_id);
142
+ if (record === undefined)
143
+ throw new ScanToolError("history_run_not_found", `No recorded run with id ${args.run_id} is available`);
144
+ return jsonResult(sanitizeDeep({ run: detail(record), note: NOTE }));
145
+ }
146
+ const selected = resolvedQuery !== undefined ? records.filter((r) => matchesProject(r, resolvedQuery, absoluteQuery)) : records;
147
+ return jsonResult(sanitizeDeep({
148
+ history: {
149
+ directory: options.config.dir,
150
+ max_entries: options.config.maxEntries,
151
+ recorded_runs: selected.length,
152
+ ...(skipped > 0 ? { skipped_records: skipped } : {}),
153
+ },
154
+ runs: selected.slice(0, limit).map(summary),
155
+ ...(resolvedQuery !== undefined ? { changes: changesByKind(selected) } : {}),
156
+ note: NOTE,
157
+ }));
158
+ }
159
+ catch (error) {
160
+ return errorResult(error);
161
+ }
162
+ }
@@ -34,5 +34,9 @@ export function permissionModelWarnings(input, permission = process.permission)
34
34
  warnings.push(`Writing to the osv-scanner cache is not allowed: ${input.cacheDir} ` +
35
35
  "(to use the automatic download, add --allow-fs-write and --allow-fs-read; otherwise set OSV_SCANNER_PATH)");
36
36
  }
37
+ if (input.historyDir !== undefined && (!permission.has("fs.write", inside(input.historyDir)) || !permission.has("fs.read", inside(input.historyDir)))) {
38
+ warnings.push(`Reading and writing the scan history directory is not allowed: ${input.historyDir} ` +
39
+ "(add --allow-fs-read and --allow-fs-write for it, or unset OSV_MCP_HISTORY; scans still run but are not recorded)");
40
+ }
37
41
  return warnings;
38
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "MCP server that wraps Google's OSV-Scanner to scan Java projects for known vulnerabilities",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",