osv-scanner-mcp 0.3.3 → 0.4.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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * スキャン対象パスの検証と、上限付きのディレクトリ走査(Java用・汎用の検出で共有)。
3
+ *
4
+ * 検出したファイルがそのままOSV-Scannerのスキャン範囲になるため、走査は深さで打ち切らず
5
+ * (Javaのソースツリーは深い)、上限に達した場合は結果を黙って欠落させずにエラーにする。
6
+ */
7
+ import { readdir, realpath } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { ScanToolError } from "../errors.js";
10
+ const DEFAULT_MAX_DEPTH = 64;
11
+ const DEFAULT_MAX_ENTRIES = 200_000;
12
+ const DEFAULT_MAX_MANIFESTS = 1_000;
13
+ export async function resolveExistingPath(inputPath) {
14
+ try {
15
+ return await realpath(path.resolve(inputPath));
16
+ }
17
+ catch {
18
+ throw new ScanToolError("project_not_found", `指定されたパスが存在しません: ${inputPath}`);
19
+ }
20
+ }
21
+ /** targetがbaseDir自身またはその配下か(どちらも解決済みの絶対パス) */
22
+ export function isInsideDir(baseDir, target) {
23
+ const relative = path.relative(baseDir, target);
24
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
25
+ }
26
+ export function assertInsideAllowedRoot(resolvedDir, allowedRootReal) {
27
+ if (!isInsideDir(allowedRootReal, resolvedDir)) {
28
+ throw new ScanToolError("path_outside_allowed_root", `指定されたパスは許可されたディレクトリ(${allowedRootReal})の外にあります`);
29
+ }
30
+ }
31
+ function searchLimitError(reason) {
32
+ return new ScanToolError("manifest_search_limit_exceeded", `マニフェスト探索が上限(${reason})に達したため、スキャンを中止しました。` +
33
+ "結果の欠落を避けるため途中までの結果は返しません。より狭いディレクトリ、またはマニフェストを直接指定してください");
34
+ }
35
+ /**
36
+ * 上限付きで幅優先に走査し、ファイルごとにonFileを呼ぶ。シンボリックリンクは辿らない。
37
+ * onFileがtrueを返したファイルをマニフェストとして数え、上限到達はエラーにする。
38
+ */
39
+ export async function walkProjectFiles(rootDir, options, skippedDirs, onFile) {
40
+ const limits = {
41
+ maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
42
+ maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES,
43
+ maxManifests: options.maxManifests ?? DEFAULT_MAX_MANIFESTS,
44
+ };
45
+ let visited = 0;
46
+ let manifests = 0;
47
+ let currentLevel = [rootDir];
48
+ for (let depth = 1; currentLevel.length > 0; depth++) {
49
+ if (depth > limits.maxDepth)
50
+ throw searchLimitError(`深さ${limits.maxDepth}`);
51
+ const nextLevel = [];
52
+ for (const dir of currentLevel) {
53
+ let entries;
54
+ try {
55
+ entries = await readdir(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ continue; // 読めないディレクトリはスキップ(権限不足等)
59
+ }
60
+ for (const entry of entries) {
61
+ if (++visited > limits.maxEntries)
62
+ throw searchLimitError(`${limits.maxEntries}エントリ`);
63
+ if (entry.isFile()) {
64
+ if (onFile(path.relative(rootDir, path.join(dir, entry.name)), entry.name)) {
65
+ if (++manifests > limits.maxManifests)
66
+ throw searchLimitError(`マニフェスト${limits.maxManifests}件`);
67
+ }
68
+ }
69
+ else if (entry.isDirectory() && !skippedDirs.has(entry.name)) {
70
+ // isDirectory()はシンボリックリンクに対してfalseを返すため、リンクは自然に除外される
71
+ nextLevel.push(path.join(dir, entry.name));
72
+ }
73
+ }
74
+ }
75
+ currentLevel = nextLevel;
76
+ }
77
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * requirements.txtの解析と、osv-scannerに渡す検証済みコピーの内容の生成(pipは使わない)。
3
+ *
4
+ * 元のファイルはosv-scannerに渡さない。osv-scannerは取り込み指定を独自に正規化してたどる
5
+ * (`- r ../x.txt` のような空白入りも取り込みとして扱い、スキャン対象の外のファイルを読む)ため、
6
+ * 事前解析との解釈のずれがそのまま境界の迂回になる。そこで、ここで解釈できた依存の行だけを
7
+ * `名前==版` 等の単純な形に正規化して返し、呼び出し側がそれを専用コピーに書いてスキャンする。
8
+ * コピーには取り込み・オプションを一切含めないため、osv-scannerがたどれる参照は存在しない。
9
+ *
10
+ * osv-scanner v2.4.0の実機確認(2026-10-07):
11
+ * - `==` / `===` はその版をスキャンする。`>=X` / `~=X` は下限Xを使用中の版とみなす
12
+ * - `>` `<` `!=` `==1.*` 範囲の組み合わせ・版なしは空の版になり、結果から消える
13
+ * - `-r` はたどるが、`--requirement` / `-c` はたどらない
14
+ *
15
+ * 取り込み(`-r` / `--requirement`)は、プロジェクトディレクトリ内の取り込み先だけをここで展開する。
16
+ * 外・存在しない・URL・上限超過の取り込み、制約ファイル(`-c`)、解釈できない行は、
17
+ * スキャンされない参照として報告する(黙って無視しない)。
18
+ */
19
+ import { readFile, realpath, stat } from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { isInsideDir } from "./projectWalk.js";
22
+ const MAX_FILE_BYTES = 1024 * 1024;
23
+ const MAX_INCLUDE_DEPTH = 5;
24
+ const MAX_FILES = 50;
25
+ /** PEP 503の名前正規化(小文字化し、`-_.`の連続を`-`にする) */
26
+ export function normalizePypiName(name) {
27
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
28
+ }
29
+ // osv-scannerは`-`の後の空白も許して取り込みとみなすため、こちらも空白を許して認識する
30
+ const INCLUDE_REQUIREMENT = /^(?:-\s*r|--\s*requirement)(?:\s*=\s*|\s*)(\S.*)$/;
31
+ const INCLUDE_CONSTRAINT = /^(?:-\s*c|--\s*constraint)(?:\s*=\s*|\s*)(\S.*)$/;
32
+ const EDITABLE = /^(?:-\s*e|--\s*editable)(?:\s|=|$)/;
33
+ /** 依存の解決先や取得方法に関するオプション。スキャン対象には影響しない(deps.devモードでは接続しない) */
34
+ const HARMLESS_OPTION = /^(?:-i|--index-url|--extra-index-url|-f|--find-links|--trusted-host|--pre|--no-binary|--only-binary|--prefer-binary|--require-hashes|--no-index|--use-feature)(?:\s|=|$)/;
35
+ const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
36
+ const NAME_AND_SPEC = /^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*(?:\[[^\]]*\])?\s*(.*)$/;
37
+ const SPEC = /^(===|==|~=|!=|>=|<=|>|<)\s*([^\s,]+)$/;
38
+ /** コピーに書く版。想定外の文字を含む版は書かない(解釈のずれを持ち込まない) */
39
+ const SAFE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+!-]*$/;
40
+ /** 行継続を結合し、コメントを除いた論理行を返す(行番号は論理行の先頭) */
41
+ function logicalLines(text) {
42
+ const result = [];
43
+ const physical = text.split(/\r?\n/);
44
+ let buffer = "";
45
+ let start = 0;
46
+ for (let i = 0; i < physical.length; i++) {
47
+ const raw = physical[i];
48
+ if (buffer === "")
49
+ start = i + 1;
50
+ if (raw.endsWith("\\")) {
51
+ buffer += raw.slice(0, -1) + " ";
52
+ continue;
53
+ }
54
+ buffer += raw;
55
+ flush();
56
+ }
57
+ flush(); // 行継続で終わるファイル
58
+ return result;
59
+ function flush() {
60
+ // 行頭または空白の後の#以降はコメント(URL内の#egg=等は残る)
61
+ const content = buffer.replace(/(^|\s)#.*$/, "").trim();
62
+ if (content !== "")
63
+ result.push({ line: start, content });
64
+ buffer = "";
65
+ }
66
+ }
67
+ function classifyRequirement(file, line, content, out) {
68
+ const reference = (reason) => {
69
+ out.references.push({ file, line, text: content, reason });
70
+ };
71
+ // 環境マーカーと行末のオプション(--hash等)を除く
72
+ const withoutMarker = content.split(";")[0];
73
+ const requirement = withoutMarker.replace(/\s--?[A-Za-z].*$/, "").trim();
74
+ if (/\s@\s/.test(requirement) || URL_SCHEME.test(requirement) || /^[./~]/.test(requirement) || requirement.includes("/")) {
75
+ reference("URL・パス指定の依存はスキャンされません");
76
+ return;
77
+ }
78
+ const match = NAME_AND_SPEC.exec(requirement);
79
+ if (!match) {
80
+ reference("解釈できない行です");
81
+ return;
82
+ }
83
+ const name = match[1];
84
+ const specifier = match[2].replace(/[()]/g, "").trim();
85
+ const specs = specifier === "" ? [] : specifier.split(",").map((s) => s.trim()).filter((s) => s !== "");
86
+ if (specs.length === 0) {
87
+ out.issues.push({ file, line, name, specifier, kind: "unpinned" });
88
+ return;
89
+ }
90
+ const parsed = specs.length === 1 ? SPEC.exec(specs[0]) : null;
91
+ if (parsed) {
92
+ const op = parsed[1];
93
+ const version = parsed[2];
94
+ if ((op === "==" || op === "===") && !version.includes("*")) {
95
+ if (!SAFE_VERSION.test(version))
96
+ return reference("解釈できない版の指定です");
97
+ out.entries.push(`${name}==${version}`);
98
+ return;
99
+ }
100
+ if (op === ">=" || op === "~=") {
101
+ if (!SAFE_VERSION.test(version))
102
+ return reference("解釈できない版の指定です");
103
+ // osv-scannerは下限を使用中の版とみなしてスキャンする(実機確認)。その挙動を保ち、印を付けて報告する
104
+ out.entries.push(`${name}${op}${version}`);
105
+ out.issues.push({ file, line, name, specifier, kind: "lower_bound" });
106
+ out.lowerBounds.push({ name: normalizePypiName(name), version });
107
+ return;
108
+ }
109
+ }
110
+ out.issues.push({ file, line, name, specifier, kind: "range" });
111
+ }
112
+ async function include(file, line, content, target, projectDir, depth, out) {
113
+ const reference = (reason) => {
114
+ out.references.push({ file, line, text: content, reason });
115
+ };
116
+ const cleaned = target.trim().replace(/^["']|["']$/g, "");
117
+ if (URL_SCHEME.test(cleaned))
118
+ return reference("URLの取り込みは展開しません");
119
+ if (depth >= MAX_INCLUDE_DEPTH)
120
+ return reference(`取り込みの深さが上限(${MAX_INCLUDE_DEPTH})を超えています`);
121
+ let resolved;
122
+ try {
123
+ resolved = await realpath(path.resolve(path.dirname(file), cleaned));
124
+ }
125
+ catch {
126
+ return reference("取り込み先が存在しません");
127
+ }
128
+ if (!isInsideDir(projectDir, resolved))
129
+ return reference("取り込み先がプロジェクトディレクトリの外のため展開しません");
130
+ if (out.visited.has(resolved))
131
+ return; // 循環
132
+ if (out.visited.size >= MAX_FILES)
133
+ return reference(`取り込みファイルが上限(${MAX_FILES}件)を超えています`);
134
+ let failure;
135
+ try {
136
+ failure = await analyze(resolved, projectDir, depth + 1, out);
137
+ }
138
+ catch {
139
+ failure = "取り込み先を読み込めません";
140
+ }
141
+ if (failure !== null)
142
+ reference(failure);
143
+ }
144
+ /** ファイルを解析してoutに追加する。読めない・大きすぎる場合は理由を返す */
145
+ async function analyze(file, projectDir, depth, out) {
146
+ out.visited.add(file);
147
+ const info = await stat(file);
148
+ if (!info.isFile())
149
+ return "通常ファイルではありません";
150
+ if (info.size > MAX_FILE_BYTES)
151
+ return `サイズが上限(${MAX_FILE_BYTES}バイト)を超えています`;
152
+ const text = await readFile(file, "utf8");
153
+ for (const { line, content } of logicalLines(text)) {
154
+ const requirementInclude = INCLUDE_REQUIREMENT.exec(content);
155
+ if (requirementInclude) {
156
+ await include(file, line, content, requirementInclude[1], projectDir, depth, out);
157
+ continue;
158
+ }
159
+ if (INCLUDE_CONSTRAINT.test(content)) {
160
+ out.references.push({ file, line, text: content, reason: "制約ファイルは適用していません" });
161
+ continue;
162
+ }
163
+ if (EDITABLE.test(content)) {
164
+ out.references.push({ file, line, text: content, reason: "編集可能インストール(-e)はスキャンされません" });
165
+ continue;
166
+ }
167
+ if (content.startsWith("-")) {
168
+ if (!HARMLESS_OPTION.test(content)) {
169
+ out.references.push({ file, line, text: content, reason: "解釈できないオプションです" });
170
+ }
171
+ continue;
172
+ }
173
+ classifyRequirement(file, line, content, out);
174
+ }
175
+ return null;
176
+ }
177
+ /**
178
+ * requirements.txtを解析する。元ファイル自体が読めない・大きすぎる場合だけ`ok: false`。
179
+ * @param file 検出済みの絶対パス
180
+ * @param projectDir 取り込みを展開する範囲(解決済みの絶対パス)
181
+ */
182
+ export async function analyzeRequirementsFile(file, projectDir) {
183
+ const out = { entries: [], issues: [], references: [], lowerBounds: [], visited: new Set() };
184
+ let reason;
185
+ try {
186
+ reason = await analyze(await realpath(file), projectDir, 0, out);
187
+ }
188
+ catch {
189
+ reason = "読み込めません";
190
+ }
191
+ if (reason !== null)
192
+ return { ok: false, reason };
193
+ return { ok: true, entries: out.entries, issues: out.issues, references: out.references, lowerBounds: out.lowerBounds };
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osv-scanner-mcp",
3
- "version": "0.3.3",
3
+ "version": "0.4.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",