osv-scanner-mcp 0.9.0 → 0.10.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 +38 -1
- package/dist/index.js +25 -1
- package/dist/osv/artifactReport.js +46 -6
- package/dist/osv/binaryDownloader.js +1 -1
- package/dist/osv/binaryManager.js +10 -3
- package/dist/osv/scanReport.js +5 -1
- package/dist/tools/toolResult.js +4 -0
- package/dist/utils/artifactDetector.js +5 -1
- package/dist/utils/permissionCheck.js +38 -0
- package/dist/utils/projectWalk.js +21 -3
- package/dist/utils/safeRead.js +6 -5
- package/dist/utils/sbomInput.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,6 +136,41 @@ npm run build
|
|
|
136
136
|
>
|
|
137
137
|
> 依存の情報をどこに送るかは[通信先とプライバシー](#通信先とプライバシー)を確認してください。どの設定でも、脆弱性照会のためパッケージの名前とバージョンは `api.osv.dev` に送られます。
|
|
138
138
|
|
|
139
|
+
### 権限を絞って起動する
|
|
140
|
+
|
|
141
|
+
多層防御として、Nodeの権限モデル(`--permission`)でサーバーが読み書きできる範囲を絞れます(任意。既定の起動方法は変わりません)。`npx` ではNodeのフラグを渡せないため、`node` で直接起動します。
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"mcpServers": {
|
|
146
|
+
"osv-scanner": {
|
|
147
|
+
"command": "node",
|
|
148
|
+
"args": [
|
|
149
|
+
"--permission",
|
|
150
|
+
"--allow-fs-read=/path/to/OSV-Scanner-MCP",
|
|
151
|
+
"--allow-fs-read=/Users/you/projects",
|
|
152
|
+
"--allow-fs-read=/var/folders/xx/yyyy/T",
|
|
153
|
+
"--allow-fs-read=/private/var/folders/xx/yyyy/T",
|
|
154
|
+
"--allow-fs-write=/private/var/folders/xx/yyyy/T",
|
|
155
|
+
"--allow-fs-read=/opt/homebrew/bin/osv-scanner",
|
|
156
|
+
"--allow-child-process",
|
|
157
|
+
"/path/to/OSV-Scanner-MCP/dist/index.js"
|
|
158
|
+
],
|
|
159
|
+
"env": {
|
|
160
|
+
"OSV_MCP_ALLOWED_ROOT": "/Users/you/projects",
|
|
161
|
+
"OSV_SCANNER_PATH": "/opt/homebrew/bin/osv-scanner"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- 読み取り: サーバー本体(`dist` と `node_modules` を含むディレクトリ)、スキャン対象(`OSV_MCP_ALLOWED_ROOT`)、一時ディレクトリ、osv-scannerのバイナリ
|
|
169
|
+
- 一時ディレクトリ(`os.tmpdir()`、macOSでは `$TMPDIR`)は、**シンボリックリンクの解決前と解決後の両方のパス**に読み取りの許可が必要です(macOSの `/var/folders/...` は `/private/var/folders/...` へのリンク)。書き込みは解決後のパスに許可します
|
|
170
|
+
- 自動ダウンロードを使う場合は、キャッシュ(`$XDG_CACHE_HOME/osv-scanner-mcp`、既定は `~/.cache/osv-scanner-mcp`)の読み書きも許可します。使わない場合は `OSV_SCANNER_PATH` を指定します
|
|
171
|
+
- osv-scannerを起動するため `--allow-child-process` が必要です。**子プロセスのosv-scannerは権限モデルの制限を受けません**(Node自身もこのフラグは権限モデルを弱めると警告します)。osv-scannerには検証済みのコピーだけを渡しているため影響は限定的ですが、より強い隔離が必要ならコンテナ等のOSレベルの仕組みを併用してください
|
|
172
|
+
- 必要な許可が欠けている場合は起動時に標準エラー出力へ警告し、スキャン時は `permission_denied`(不足している許可と対象のパス)を返します
|
|
173
|
+
|
|
139
174
|
### 通信先とプライバシー
|
|
140
175
|
|
|
141
176
|
osv-scanner v2.4.0 で接続先を実機確認した結果です(2026-10-07)。
|
|
@@ -310,7 +345,8 @@ OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJAR
|
|
|
310
345
|
**出力の読み方:**
|
|
311
346
|
|
|
312
347
|
- 先頭の `coverage` に `jars_found`、`jars_identified`、`unidentified_jars` を返します。件数はWARも含む、ファイルシステム上で列挙した外側のアーカイブ単位です。ネストJARの総数ではありません。
|
|
313
|
-
- `artifacts[].status` は `identified_with_vulnerabilities` / `identified_without_known_vulnerabilities` / `unidentified` の
|
|
348
|
+
- `artifacts[].status` は `identified_with_vulnerabilities` / `identified_without_known_vulnerabilities` / `inferred_only` / `unidentified` の4値です。「同定済み」は少なくとも1件のMaven座標を取得できた意味であり、全依存の同定ではありません。`inferred_only` は推測した座標(下記)だけで同定したアーカイブで、脆弱性が見つかった場合も含め `jars_identified` に数えず、`unidentified_jars` に理由付きで示します(検出件数は `identified_vulnerability_count` に示します。推測の誤ったgroupIdで他の脆弱性を取りこぼしている可能性があるため)。
|
|
349
|
+
- **推測した座標**: `pom.properties` を含まないJAR(Spring Frameworkの本体JARなど)について、OSV-Scannerはファイル名等からMaven座標を推測し、groupIdを誤ることがあります(例: `spring-beans:spring-beans`。正しくは `org.springframework:spring-beans`)。誤った座標はOSVで照合されず、**既知の脆弱性を取りこぼします**(実例: zipkin-server 2.23.2 のfat JARに含まれる spring-beans 5.3.2 のSpring4Shell(CVE-2022-22965)は検出されません)。groupIdに `.` を含まない座標を推測とみなし、`coverage.inferred_coordinates`(件数・一覧・警告)と、該当パッケージの `coordinates_inferred: true` で示します。`commons-io:commons-io` のような古い形式の正しい座標も含まれます(安全側)。`.` を含む誤った推測(`com.sun.jna:jna` 等)は区別できません。正確な結果には、ビルド元のlockfile・`pom.xml` を `scan_project` でスキャンしてください
|
|
314
350
|
- `coverage.completeness` は常に `incomplete`。`identified_vulnerability_count: 0` は安全性の保証ではありません。
|
|
315
351
|
- `packages` は同定できた脆弱なパッケージの詳細です。複数アーカイブに含まれる同一パッケージ・脆弱性は全体集計では重複排除します。
|
|
316
352
|
- JAR/WARが無い場合は `no_scannable_artifacts`、全件同定不能の場合は警告を含む成功レポートです。
|
|
@@ -434,6 +470,7 @@ npm・Go・PyPIの「同じ系統」は、npmの `^`(キャレット)が互換
|
|
|
434
470
|
|---|---|
|
|
435
471
|
| `binary_not_found` | OSV-Scannerが見つからない(インストール案内をmessageに含む) |
|
|
436
472
|
| `project_not_found` | 指定パスが存在しない・ディレクトリ/pom.xmlでない |
|
|
473
|
+
| `permission_denied` | Nodeの権限モデル(`--permission`)でファイルの読み書きが許可されていない(メッセージに不足している許可と対象のパスを示します。[権限を絞って起動する](#権限を絞って起動する)を参照) |
|
|
437
474
|
| `no_manifest_found` | 対応マニフェスト(pom.xml / gradle.lockfile)が見つからない |
|
|
438
475
|
| `scan_input_too_large` | スキャン対象ファイル(一時ディレクトリへのコピー)の合計サイズが上限(2GiB)を超えた。対象を絞って再実行する |
|
|
439
476
|
| `manifest_search_limit_exceeded` | マニフェスト探索が上限(20万エントリ・1,000マニフェスト)に達した。より狭いディレクトリかマニフェストを直接指定する |
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,10 @@ import { handleScanProject } from "./tools/scanProject.js";
|
|
|
21
21
|
import { handleScanJavaArtifact } from "./tools/scanJavaArtifact.js";
|
|
22
22
|
import { handleScanSbom } from "./tools/scanSbom.js";
|
|
23
23
|
import { handleSuggestFix } from "./tools/suggestFix.js";
|
|
24
|
+
import { realpathSync } from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import { defaultCacheDir } from "./osv/binaryDownloader.js";
|
|
27
|
+
import { permissionModelWarnings } from "./utils/permissionCheck.js";
|
|
24
28
|
import { installShutdownHandlers, removeStaleTempDirs } from "./utils/processCleanup.js";
|
|
25
29
|
import { ALLOWED_ROOT_ENV, allowedRootFromEnv, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
|
|
26
30
|
export { ALLOWED_ROOT_ENV };
|
|
@@ -35,10 +39,29 @@ const startupWarning = allowedRootStartupWarning();
|
|
|
35
39
|
if (startupWarning !== null) {
|
|
36
40
|
console.error(`osv-scanner-mcp: [警告] ${startupWarning}`);
|
|
37
41
|
}
|
|
42
|
+
// Nodeの権限モデル(--permission)で起動された場合、必要な許可が欠けていればstderrで知らせる(起動は続ける)
|
|
43
|
+
{
|
|
44
|
+
const tmpDir = os.tmpdir();
|
|
45
|
+
let tmpDirReal = tmpDir;
|
|
46
|
+
try {
|
|
47
|
+
tmpDirReal = realpathSync(tmpDir);
|
|
48
|
+
}
|
|
49
|
+
catch { /* 解決前のパスの読み取りが拒否されている場合も、警告で示す */ }
|
|
50
|
+
const scannerPath = process.env.OSV_SCANNER_PATH?.trim();
|
|
51
|
+
for (const warning of permissionModelWarnings({
|
|
52
|
+
allowedRoot: allowedRootFromEnv(),
|
|
53
|
+
tmpDir,
|
|
54
|
+
tmpDirReal,
|
|
55
|
+
cacheDir: scannerPath ? undefined : defaultCacheDir(),
|
|
56
|
+
scannerPath: scannerPath || undefined,
|
|
57
|
+
})) {
|
|
58
|
+
console.error(`osv-scanner-mcp: [警告] ${warning}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
38
61
|
// NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
|
|
39
62
|
const server = new McpServer({
|
|
40
63
|
name: "osv-scanner-mcp",
|
|
41
|
-
version: "0.
|
|
64
|
+
version: "0.10.0",
|
|
42
65
|
});
|
|
43
66
|
server.registerTool("scan_project", {
|
|
44
67
|
title: "プロジェクトの依存の脆弱性スキャン(Java / JavaScript / Python / Go)",
|
|
@@ -104,6 +127,7 @@ server.registerTool("scan_java_artifact", {
|
|
|
104
127
|
title: "JAR/WAR実体の脆弱性スキャン",
|
|
105
128
|
description: "JAR/WARファイルまたはディレクトリ内の実体をスキャンする。ビルドやJavaコードの実行は行わない。" +
|
|
106
129
|
"メタデータによるベストエフォート同定のため、coverageと同定不能ファイルを必ず確認すること。" +
|
|
130
|
+
"pom.propertiesのないJARは座標を推測し、groupIdの誤りで既知の脆弱性を取りこぼしうる(coverage.inferred_coordinates、status: inferred_only)。" +
|
|
107
131
|
"completenessは常にincomplete。検出0件でも安全性や全依存の同定を保証しない。",
|
|
108
132
|
inputSchema: {
|
|
109
133
|
artifact_path: z.string().min(1).describe("JAR/WARファイル、または探索するディレクトリの絶対パス"),
|
|
@@ -4,6 +4,23 @@ import { asArray, asRecord } from "../utils/unknownJson.js";
|
|
|
4
4
|
import { parseOsvScanOutput } from "./scanReport.js";
|
|
5
5
|
const WARNING = "Best-effort metadata identification only. Missing or removed metadata can hide dependencies, even in identified archives. Zero identified vulnerabilities does not establish safety.";
|
|
6
6
|
const HINT = "Metadata may be missing (shaded/minimized archive). Scan the build project's lockfile instead.";
|
|
7
|
+
const INFERRED_WARNING = "Some Maven coordinates were inferred from file names because the JAR has no pom.properties (inferred_coordinates). " +
|
|
8
|
+
"Inferred groupIds are often wrong (e.g. spring-beans:spring-beans instead of org.springframework:spring-beans), " +
|
|
9
|
+
"so known vulnerabilities of these packages may be missed. Scan the build project's lockfile or pom.xml (scan_project) for accurate results.";
|
|
10
|
+
const INFERRED_HINT = "Identified only by inferred coordinates (no pom.properties). Known vulnerabilities may be missed.";
|
|
11
|
+
/** coverage.inferred_coordinatesの上限(巨大なWARで応答を膨らませない) */
|
|
12
|
+
const MAX_INFERRED_ITEMS = 200;
|
|
13
|
+
/**
|
|
14
|
+
* osv-scannerがpom.propertiesのないJARについて、ファイル名等から推測した座標か(B3の実物の検証で判明)。
|
|
15
|
+
* 推測の座標はgroupIdを誤りやすく(`spring-beans:spring-beans`、`jar:grpc-netty-shaded`、`all:opentelemetry-api`)、
|
|
16
|
+
* OSVで照会しても0件になり既知の脆弱性を取りこぼす。osv-scannerの出力には座標の出所がないため、
|
|
17
|
+
* groupIdに`.`がないものを推測とみなす。`commons-io:commons-io`のような古い形式の正しい座標も含む(安全側)。
|
|
18
|
+
* `.`を含む誤った推測(`com.sun.jna:jna`)は区別できない
|
|
19
|
+
*/
|
|
20
|
+
export function isInferredCoordinate(name) {
|
|
21
|
+
const groupId = name.split(":")[0] ?? "";
|
|
22
|
+
return !groupId.includes(".");
|
|
23
|
+
}
|
|
7
24
|
function isIdentifiedPackage(raw) {
|
|
8
25
|
const info = asRecord(asRecord(raw)?.package);
|
|
9
26
|
if (info?.ecosystem !== "Maven" || typeof info.name !== "string" || typeof info.version !== "string")
|
|
@@ -31,30 +48,53 @@ export function buildArtifactReport(raw, artifactPaths) {
|
|
|
31
48
|
}
|
|
32
49
|
const normalized = [...byPath].map(([file, packages]) => ({ source: { path: file }, packages }));
|
|
33
50
|
const report = parseOsvScanOutput({ results: normalized });
|
|
51
|
+
const coordinateOf = (raw) => {
|
|
52
|
+
const info = asRecord(asRecord(raw)?.package);
|
|
53
|
+
return { name: String(info.name), version: String(info.version) };
|
|
54
|
+
};
|
|
34
55
|
const artifacts = artifactPaths.map((file) => {
|
|
35
56
|
const packages = byPath.get(file);
|
|
36
|
-
const identified = packages !== undefined;
|
|
37
57
|
const perFile = parseOsvScanOutput({ results: [{ packages: packages ?? [] }] });
|
|
58
|
+
// 推測の座標だけで同定したアーカイブは、脆弱性の有無に関係なく「同定済み」にしない(同定の確度と検出件数を分ける)。
|
|
59
|
+
// 脆弱性が見つかっても、推測の誤ったgroupIdで他の脆弱性を取りこぼしている可能性がある
|
|
60
|
+
const inferredOnly = packages !== undefined && packages.every((raw) => isInferredCoordinate(coordinateOf(raw).name));
|
|
38
61
|
return {
|
|
39
62
|
path: sanitizeExternalText(file),
|
|
40
|
-
status:
|
|
63
|
+
status: packages === undefined ? "unidentified" : inferredOnly ? "inferred_only" : perFile.vulnerability_count > 0
|
|
41
64
|
? "identified_with_vulnerabilities" : "identified_without_known_vulnerabilities",
|
|
42
65
|
identified_vulnerability_count: perFile.vulnerability_count,
|
|
43
66
|
};
|
|
44
67
|
});
|
|
68
|
+
// 推測の座標の一覧(既知の脆弱性のないものも含む。名前と版で重複を除く)
|
|
69
|
+
const inferred = [...new Map([...byPath.values()].flat().map(coordinateOf)
|
|
70
|
+
.filter((c) => isInferredCoordinate(c.name)).map((c) => [`${c.name}@${c.version}`, c])).values()]
|
|
71
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
45
72
|
return {
|
|
46
73
|
coverage: {
|
|
47
74
|
jars_found: artifactPaths.length,
|
|
48
|
-
jars_identified:
|
|
49
|
-
unidentified_jars: artifacts.filter((item) => item.status === "unidentified")
|
|
50
|
-
.map(({ path }) => ({ path, hint: HINT })),
|
|
75
|
+
jars_identified: artifacts.filter((item) => item.status !== "unidentified" && item.status !== "inferred_only").length,
|
|
76
|
+
unidentified_jars: artifacts.filter((item) => item.status === "unidentified" || item.status === "inferred_only")
|
|
77
|
+
.map(({ path, status }) => ({ path, hint: status === "inferred_only" ? INFERRED_HINT : HINT })),
|
|
51
78
|
completeness: "incomplete",
|
|
52
79
|
warning: WARNING,
|
|
80
|
+
...(inferred.length > 0 ? {
|
|
81
|
+
inferred_coordinates: {
|
|
82
|
+
count: inferred.length,
|
|
83
|
+
items: inferred.slice(0, MAX_INFERRED_ITEMS).map((c) => ({ name: sanitizeExternalText(c.name), version: sanitizeExternalText(c.version) })),
|
|
84
|
+
...(inferred.length > MAX_INFERRED_ITEMS ? { omitted: inferred.length - MAX_INFERRED_ITEMS } : {}),
|
|
85
|
+
warning: INFERRED_WARNING,
|
|
86
|
+
},
|
|
87
|
+
} : {}),
|
|
53
88
|
},
|
|
54
89
|
artifacts,
|
|
55
90
|
identified_vulnerability_count: report.vulnerability_count,
|
|
56
91
|
identified_vulnerable_package_count: report.vulnerable_package_count,
|
|
57
92
|
severity_breakdown: report.severity_breakdown,
|
|
58
|
-
packages: report.packages
|
|
93
|
+
packages: report.packages.map((pkg) => {
|
|
94
|
+
if (!isInferredCoordinate(pkg.name))
|
|
95
|
+
return pkg;
|
|
96
|
+
const { vulnerabilities, ...rest } = pkg;
|
|
97
|
+
return { ...rest, coordinates_inferred: true, vulnerabilities };
|
|
98
|
+
}),
|
|
59
99
|
};
|
|
60
100
|
}
|
|
@@ -47,7 +47,7 @@ export function assetNameForPlatform(platform = process.platform, arch = process
|
|
|
47
47
|
return null;
|
|
48
48
|
return `osv-scanner_${osName}_${cpuName}${osName === "windows" ? ".exe" : ""}`;
|
|
49
49
|
}
|
|
50
|
-
function defaultCacheDir() {
|
|
50
|
+
export function defaultCacheDir() {
|
|
51
51
|
const xdg = process.env.XDG_CACHE_HOME;
|
|
52
52
|
const base = xdg !== undefined && xdg.trim() !== "" ? xdg : path.join(os.homedir(), ".cache");
|
|
53
53
|
return path.join(base, "osv-scanner-mcp");
|
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
* 探索順: 環境変数 OSV_SCANNER_PATH → PATH上の osv-scanner
|
|
5
5
|
*/
|
|
6
6
|
import { access, constants, stat } from "node:fs/promises";
|
|
7
|
+
import { isAccessDenied, permissionDeniedError } from "../utils/projectWalk.js";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { ScanToolError } from "../errors.js";
|
|
9
10
|
export const OSV_SCANNER_PATH_ENV = "OSV_SCANNER_PATH";
|
|
10
11
|
const BINARY_NAME = process.platform === "win32" ? "osv-scanner.exe" : "osv-scanner";
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* 実行可能な通常のファイルか。rethrowDeniedのとき、Nodeの権限モデル(--permission)による拒否は
|
|
14
|
+
* 「見つからない」にせず投げる(明示指定のパスで原因を誤らせない。PATHの探索では読めない場所を飛ばす)
|
|
15
|
+
*/
|
|
16
|
+
async function isExecutableFile(filePath, rethrowDenied = false) {
|
|
12
17
|
try {
|
|
13
18
|
const stats = await stat(filePath);
|
|
14
19
|
if (!stats.isFile())
|
|
@@ -16,7 +21,9 @@ async function isExecutableFile(filePath) {
|
|
|
16
21
|
await access(filePath, constants.X_OK);
|
|
17
22
|
return true;
|
|
18
23
|
}
|
|
19
|
-
catch {
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (rethrowDenied && isAccessDenied(error))
|
|
26
|
+
throw permissionDeniedError(error);
|
|
20
27
|
return false;
|
|
21
28
|
}
|
|
22
29
|
}
|
|
@@ -25,7 +32,7 @@ export async function findOsvScannerBinary(env = process.env) {
|
|
|
25
32
|
const explicit = env[OSV_SCANNER_PATH_ENV];
|
|
26
33
|
if (explicit !== undefined && explicit.trim() !== "") {
|
|
27
34
|
// 明示指定が無効な場合はPATHにフォールバックせず失敗させる(意図しないバイナリの実行を防ぐ)
|
|
28
|
-
return (await isExecutableFile(explicit)) ? explicit : null;
|
|
35
|
+
return (await isExecutableFile(explicit, true)) ? explicit : null;
|
|
29
36
|
}
|
|
30
37
|
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
31
38
|
if (dir === "")
|
package/dist/osv/scanReport.js
CHANGED
|
@@ -165,7 +165,11 @@ export function parseOsvScanOutput(raw) {
|
|
|
165
165
|
severity: severityFromScore(score),
|
|
166
166
|
summary: extractSummary(vulnDetails),
|
|
167
167
|
fixed_versions: extractFixedVersions(vulnDetails, name, ecosystem),
|
|
168
|
-
|
|
168
|
+
});
|
|
169
|
+
// 内部でだけ使うため列挙不可にする(JSON.stringifyに含まれない)
|
|
170
|
+
Object.defineProperty(entry.vulns.get(primaryId), "affected_versions", {
|
|
171
|
+
value: extractAffectedVersions(vulnDetails, ids, name, ecosystem),
|
|
172
|
+
enumerable: false,
|
|
169
173
|
});
|
|
170
174
|
}
|
|
171
175
|
}
|
package/dist/tools/toolResult.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { ScanToolError } from "../errors.js";
|
|
10
10
|
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
11
|
+
import { isAccessDenied, permissionDeniedError } from "../utils/projectWalk.js";
|
|
11
12
|
export function jsonResult(payload, isError = false) {
|
|
12
13
|
return {
|
|
13
14
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
@@ -28,6 +29,9 @@ export function errorResult(error) {
|
|
|
28
29
|
},
|
|
29
30
|
}, true);
|
|
30
31
|
}
|
|
32
|
+
// Nodeの権限モデルの拒否は、許可の付け方が分かるエラーにする(内部の例外の詳細は返さない)
|
|
33
|
+
if (isAccessDenied(error))
|
|
34
|
+
return errorResult(permissionDeniedError(error));
|
|
31
35
|
// 想定外の例外はスタックトレース等の内部情報をクライアントへ返さない
|
|
32
36
|
return jsonResult({ error: { kind: "internal_error", message: "予期しないエラーが発生しました" } }, true);
|
|
33
37
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { opendir, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ScanToolError } from "../errors.js";
|
|
4
|
+
import { isAccessDenied, permissionDeniedError } from "./projectWalk.js";
|
|
4
5
|
// Build output directories must be included for artifact scans.
|
|
5
6
|
const SKIPPED_DIRS = new Set([".git", "node_modules", ".idea", ".vscode"]);
|
|
6
7
|
const isArchive = (file) => /\.(jar|war)$/i.test(file);
|
|
@@ -12,7 +13,10 @@ export async function detectJavaArtifacts(inputPath, options = {}) {
|
|
|
12
13
|
try {
|
|
13
14
|
targetPath = await realpath(inputPath);
|
|
14
15
|
}
|
|
15
|
-
catch {
|
|
16
|
+
catch (error) {
|
|
17
|
+
// Nodeの権限モデルの拒否を「存在しない」と誤って伝えない
|
|
18
|
+
if (isAccessDenied(error))
|
|
19
|
+
throw permissionDeniedError(error);
|
|
16
20
|
throw new ScanToolError("project_not_found", `Path does not exist: ${inputPath}`);
|
|
17
21
|
}
|
|
18
22
|
if (options.allowedRoot !== undefined) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nodeの権限モデル(--permission)で起動された場合の、必要な許可の確認(docs/DESIGN_TODO.md「B3」)。
|
|
3
|
+
*
|
|
4
|
+
* 権限モデルは任意の多層防御として使える(既定の起動方法は変えない)。必要な許可が欠けると
|
|
5
|
+
* スキャンの途中でERR_ACCESS_DENIEDになるため、起動時にstderrで知らせる(起動は拒否しない)。
|
|
6
|
+
* 子プロセス(osv-scanner)は権限モデルの制限を受けない点に注意(READMEに明記)。
|
|
7
|
+
*/
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
/** 権限モデルが有効なときに、欠けている許可を警告文の一覧で返す。無効なら空 */
|
|
10
|
+
export function permissionModelWarnings(input, permission = process.permission) {
|
|
11
|
+
if (permission === undefined)
|
|
12
|
+
return [];
|
|
13
|
+
const inside = (dir) => path.join(dir, "x"); // ディレクトリ配下の読み書きの許可を確かめる
|
|
14
|
+
const warnings = [];
|
|
15
|
+
if (!permission.has("child")) {
|
|
16
|
+
warnings.push("子プロセスの起動が許可されていません。osv-scannerを実行できません(--allow-child-process)");
|
|
17
|
+
}
|
|
18
|
+
const tmpDirs = [...new Set([input.tmpDir, input.tmpDirReal])];
|
|
19
|
+
const unreadableTmp = tmpDirs.filter((dir) => !permission.has("fs.read", inside(dir)));
|
|
20
|
+
if (unreadableTmp.length > 0) {
|
|
21
|
+
warnings.push(`一時ディレクトリの読み取りが許可されていません: ${unreadableTmp.join(", ")}` +
|
|
22
|
+
"(シンボリックリンクの解決前と解決後の両方のパスに--allow-fs-readが必要です)");
|
|
23
|
+
}
|
|
24
|
+
if (!permission.has("fs.write", inside(input.tmpDirReal))) {
|
|
25
|
+
warnings.push(`一時ディレクトリへの書き込みが許可されていません: ${input.tmpDirReal}(--allow-fs-write)`);
|
|
26
|
+
}
|
|
27
|
+
if (input.allowedRoot !== undefined && !permission.has("fs.read", inside(input.allowedRoot))) {
|
|
28
|
+
warnings.push(`OSV_MCP_ALLOWED_ROOTの読み取りが許可されていません: ${input.allowedRoot}(--allow-fs-read)`);
|
|
29
|
+
}
|
|
30
|
+
if (input.scannerPath !== undefined && !permission.has("fs.read", input.scannerPath)) {
|
|
31
|
+
warnings.push(`OSV_SCANNER_PATHの読み取りが許可されていません: ${input.scannerPath}(--allow-fs-read)`);
|
|
32
|
+
}
|
|
33
|
+
if (input.cacheDir !== undefined && !permission.has("fs.write", inside(input.cacheDir))) {
|
|
34
|
+
warnings.push(`osv-scannerのキャッシュへの書き込みが許可されていません: ${input.cacheDir}` +
|
|
35
|
+
"(自動ダウンロードを使う場合は--allow-fs-writeと--allow-fs-read、使わない場合はOSV_SCANNER_PATHを指定)");
|
|
36
|
+
}
|
|
37
|
+
return warnings;
|
|
38
|
+
}
|
|
@@ -10,11 +10,26 @@ import { ScanToolError } from "../errors.js";
|
|
|
10
10
|
const DEFAULT_MAX_DEPTH = 64;
|
|
11
11
|
const DEFAULT_MAX_ENTRIES = 200_000;
|
|
12
12
|
const DEFAULT_MAX_MANIFESTS = 1_000;
|
|
13
|
+
/** Nodeの権限モデル(--permission)による拒否か */
|
|
14
|
+
export function isAccessDenied(error) {
|
|
15
|
+
return error instanceof Error && error.code === "ERR_ACCESS_DENIED";
|
|
16
|
+
}
|
|
17
|
+
/** 権限モデルの拒否を、許可の付け方が分かるエラーにする */
|
|
18
|
+
export function permissionDeniedError(error) {
|
|
19
|
+
const resource = typeof error.resource === "string" ? `: ${error.resource}` : "";
|
|
20
|
+
const kind = error.permission === "FileSystemWrite" ? "書き込み(--allow-fs-write)" : error.permission === "ChildProcess"
|
|
21
|
+
? "子プロセスの起動(--allow-child-process)" : "読み取り(--allow-fs-read)";
|
|
22
|
+
return new ScanToolError("permission_denied", `Nodeの権限モデル(--permission)で${kind}が許可されていません${resource}。` +
|
|
23
|
+
"スキャン対象・一時ディレクトリ(シンボリックリンクの解決前と解決後の両方のパス)・osv-scannerのキャッシュへの許可が必要です(READMEの「権限を絞って起動する」を参照)");
|
|
24
|
+
}
|
|
13
25
|
export async function resolveExistingPath(inputPath) {
|
|
14
26
|
try {
|
|
15
27
|
return await realpath(path.resolve(inputPath));
|
|
16
28
|
}
|
|
17
|
-
catch {
|
|
29
|
+
catch (error) {
|
|
30
|
+
// Nodeの権限モデルの拒否を「存在しない」と誤って伝えない
|
|
31
|
+
if (isAccessDenied(error))
|
|
32
|
+
throw permissionDeniedError(error);
|
|
18
33
|
throw new ScanToolError("project_not_found", `指定されたパスが存在しません: ${inputPath}`);
|
|
19
34
|
}
|
|
20
35
|
}
|
|
@@ -54,8 +69,11 @@ export async function walkProjectFiles(rootDir, options, skippedDirs, onFile) {
|
|
|
54
69
|
try {
|
|
55
70
|
entries = await readdir(dir, { withFileTypes: true });
|
|
56
71
|
}
|
|
57
|
-
catch {
|
|
58
|
-
|
|
72
|
+
catch (error) {
|
|
73
|
+
// Nodeの権限モデルの拒否は、スキャン範囲を黙って欠落させずにエラーにする
|
|
74
|
+
if (isAccessDenied(error))
|
|
75
|
+
throw permissionDeniedError(error);
|
|
76
|
+
continue; // 読めないディレクトリはスキップ(OSの権限不足等)
|
|
59
77
|
}
|
|
60
78
|
for (const entry of entries) {
|
|
61
79
|
if (++visited > limits.maxEntries)
|
package/dist/utils/safeRead.js
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { constants } from "node:fs";
|
|
16
16
|
import { open, realpath, rm, stat } from "node:fs/promises";
|
|
17
|
-
import { isInsideDir } from "./projectWalk.js";
|
|
17
|
+
import { isAccessDenied, isInsideDir } from "./projectWalk.js";
|
|
18
18
|
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
|
|
19
19
|
const MESSAGES = {
|
|
20
20
|
not_found: "ファイルを開けません",
|
|
21
|
+
denied: "Nodeの権限モデル(--permission)で読み取りが許可されていません",
|
|
21
22
|
not_regular: "通常のファイルではありません(名前付きパイプ・デバイス等)",
|
|
22
23
|
too_large: "サイズが上限を超えています",
|
|
23
24
|
outside: "スキャン範囲の外のファイルです",
|
|
@@ -56,8 +57,8 @@ export async function copyRegularFile(filePath, dest, options) {
|
|
|
56
57
|
try {
|
|
57
58
|
source = await open(filePath, OPEN_FLAGS);
|
|
58
59
|
}
|
|
59
|
-
catch {
|
|
60
|
-
return failure("not_found");
|
|
60
|
+
catch (error) {
|
|
61
|
+
return failure(isAccessDenied(error) ? "denied" : "not_found");
|
|
61
62
|
}
|
|
62
63
|
let total = 0;
|
|
63
64
|
let result = null;
|
|
@@ -114,8 +115,8 @@ export async function readRegularFile(filePath, options) {
|
|
|
114
115
|
try {
|
|
115
116
|
handle = await open(filePath, OPEN_FLAGS);
|
|
116
117
|
}
|
|
117
|
-
catch {
|
|
118
|
-
return failure("not_found");
|
|
118
|
+
catch (error) {
|
|
119
|
+
return failure(isAccessDenied(error) ? "denied" : "not_found");
|
|
119
120
|
}
|
|
120
121
|
let info;
|
|
121
122
|
const chunks = [];
|
package/dist/utils/sbomInput.js
CHANGED
|
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { open, realpath } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { ScanToolError } from "../errors.js";
|
|
6
|
+
import { isAccessDenied, permissionDeniedError } from "./projectWalk.js";
|
|
6
7
|
import { verifyOpenedFile } from "./safeRead.js";
|
|
7
8
|
import { asRecord } from "./unknownJson.js";
|
|
8
9
|
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024;
|
|
@@ -16,7 +17,10 @@ export async function loadSbom(inputPath, options = {}) {
|
|
|
16
17
|
try {
|
|
17
18
|
sourcePath = await realpath(inputPath);
|
|
18
19
|
}
|
|
19
|
-
catch {
|
|
20
|
+
catch (error) {
|
|
21
|
+
// Nodeの権限モデルの拒否を「存在しない」と誤って伝えない
|
|
22
|
+
if (isAccessDenied(error))
|
|
23
|
+
throw permissionDeniedError(error);
|
|
20
24
|
throw new ScanToolError("sbom_not_found", "The specified SBOM file does not exist");
|
|
21
25
|
}
|
|
22
26
|
if (options.allowedRoot !== undefined) {
|
|
@@ -69,6 +73,8 @@ export async function loadSbom(inputPath, options = {}) {
|
|
|
69
73
|
catch (error) {
|
|
70
74
|
if (error instanceof ScanToolError)
|
|
71
75
|
throw error;
|
|
76
|
+
if (isAccessDenied(error))
|
|
77
|
+
throw permissionDeniedError(error);
|
|
72
78
|
throw new ScanToolError("sbom_not_found", "The specified SBOM file could not be read");
|
|
73
79
|
}
|
|
74
80
|
let raw;
|