osv-scanner-mcp 0.3.4 → 0.4.1

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.
@@ -11,22 +11,22 @@
11
11
  * `project_path`はLLM・ユーザー由来の信頼できない入力として扱う:
12
12
  * - `realpath`で正規化し、シンボリックリンクを解決した実体パスで判定する
13
13
  * - `allowedRoot`指定時は、解決後のパスがその配下にあることを検証する(パストラバーサル対策)
14
- * - マニフェスト探索はエントリ数・件数・深さに上限を設け、シンボリックリンクのディレクトリは辿らない
14
+ * - 走査は上限付きで、シンボリックリンクのディレクトリは辿らない(projectWalk.ts)
15
15
  *
16
16
  * 検出したマニフェストが、そのままOSV-Scannerのスキャン範囲になる(ディレクトリは渡さない)。
17
- * そのため探索を深さで打ち切らず(Javaのソースツリーは深い)、上限に達した場合は
18
- * 結果を黙って欠落させずにエラーにする。
19
17
  */
20
- import { readdir, realpath, stat } from "node:fs/promises";
18
+ import { stat } from "node:fs/promises";
21
19
  import path from "node:path";
22
20
  import { ScanToolError } from "../errors.js";
23
- const DEFAULT_MAX_DEPTH = 64;
24
- const DEFAULT_MAX_ENTRIES = 200_000;
25
- const DEFAULT_MAX_MANIFESTS = 1_000;
21
+ import { assertInsideAllowedRoot, resolveExistingPath, walkProjectFiles, } from "./projectWalk.js";
26
22
  /** ビルド成果物・VCS等、マニフェスト探索でスキップするディレクトリ */
27
23
  const SKIPPED_DIRS = new Set([".git", "node_modules", "target", "build", ".idea", ".vscode"]);
28
- /** OSV-Scannerがスキャンできるマニフェスト(実機確認済み) */
29
- const MANIFEST_FILENAMES = new Set(["pom.xml", "gradle.lockfile", "buildscript-gradle.lockfile"]);
24
+ /** OSV-Scannerがスキャンできるマニフェスト(実機確認済み)。ファイル名がそのまま解析形式になる */
25
+ const MANIFEST_FILENAMES = new Set([
26
+ "pom.xml",
27
+ "gradle.lockfile",
28
+ "buildscript-gradle.lockfile",
29
+ ]);
30
30
  /** Gradleプロジェクトの存在を示すが、それ自体はスキャンできないビルドファイル */
31
31
  const GRADLE_BUILD_FILENAMES = new Set([
32
32
  "build.gradle",
@@ -38,62 +38,12 @@ const GRADLE_LOCKFILE_GUIDANCE = "Gradleプロジェクトを検出しました
38
38
  "本ツールはlockfile方式のみ対応しています(ビルド実行方式はbuild.gradleの任意コード実行を伴うため非対応)。" +
39
39
  "`./gradlew dependencies --write-locks` でlockfileを生成してから再実行してください" +
40
40
  "(依存ロックが未設定の場合は build.gradle に dependencyLocking { lockAllConfigurations() } の追加が必要です)";
41
- async function resolveExistingPath(inputPath) {
42
- try {
43
- return await realpath(path.resolve(inputPath));
44
- }
45
- catch {
46
- throw new ScanToolError("project_not_found", `指定されたパスが存在しません: ${inputPath}`);
47
- }
48
- }
49
- function assertInsideAllowedRoot(resolvedDir, allowedRootReal) {
50
- const relative = path.relative(allowedRootReal, resolvedDir);
51
- if (relative.startsWith("..") || path.isAbsolute(relative)) {
52
- throw new ScanToolError("path_outside_allowed_root", `指定されたパスは許可されたディレクトリ(${allowedRootReal})の外にあります`);
53
- }
54
- }
55
- function searchLimitError(reason) {
56
- return new ScanToolError("manifest_search_limit_exceeded", `マニフェスト探索が上限(${reason})に達したため、スキャンを中止しました。` +
57
- "結果の欠落を避けるため途中までの結果は返しません。より狭いディレクトリ、またはpom.xml / gradle.lockfileを直接指定してください");
58
- }
59
- /** 上限付きでマニフェストを探索する。シンボリックリンクは辿らない。上限到達はエラー。 */
60
- async function findManifests(rootDir, limits) {
61
- const manifests = [];
62
- let gradleBuildFileFound = false;
63
- let visited = 0;
64
- let currentLevel = [rootDir];
65
- for (let depth = 1; currentLevel.length > 0; depth++) {
66
- if (depth > limits.maxDepth)
67
- throw searchLimitError(`深さ${limits.maxDepth}`);
68
- const nextLevel = [];
69
- for (const dir of currentLevel) {
70
- let entries;
71
- try {
72
- entries = await readdir(dir, { withFileTypes: true });
73
- }
74
- catch {
75
- continue; // 読めないディレクトリはスキップ(権限不足等)
76
- }
77
- for (const entry of entries) {
78
- if (++visited > limits.maxEntries)
79
- throw searchLimitError(`${limits.maxEntries}エントリ`);
80
- if (entry.isFile() && MANIFEST_FILENAMES.has(entry.name)) {
81
- if (manifests.length >= limits.maxManifests)
82
- throw searchLimitError(`マニフェスト${limits.maxManifests}件`);
83
- manifests.push(path.relative(rootDir, path.join(dir, entry.name)));
84
- }
85
- else if (entry.isFile() && GRADLE_BUILD_FILENAMES.has(entry.name)) {
86
- gradleBuildFileFound = true;
87
- }
88
- else if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) {
89
- // isDirectory()はシンボリックリンクに対してfalseを返すため、リンクは自然に除外される
90
- nextLevel.push(path.join(dir, entry.name));
91
- }
92
- }
93
- }
94
- currentLevel = nextLevel;
95
- }
96
- return { manifests, gradleBuildFileFound };
41
+ function buildResult(projectDir, manifests, allowedRootReal) {
42
+ const targets = manifests.map((manifest) => ({
43
+ path: path.join(projectDir, manifest),
44
+ format: path.basename(manifest),
45
+ }));
46
+ return { projectDir, manifests, targets, allowedRootReal };
97
47
  }
98
48
  /**
99
49
  * 入力パスを検証し、スキャン対象のJavaプロジェクトとして解決する。
@@ -110,10 +60,10 @@ export async function detectJavaProject(inputPath, options = {}) {
110
60
  // 親ディレクトリを探索しないため、探索上限エラーの回避手段として使える
111
61
  if (stats.isFile() && MANIFEST_FILENAMES.has(path.basename(resolved))) {
112
62
  const projectDir = path.dirname(resolved);
113
- if (options.allowedRoot !== undefined) {
114
- assertInsideAllowedRoot(projectDir, await resolveExistingPath(options.allowedRoot));
115
- }
116
- return { projectDir, manifests: [path.basename(resolved)], manifestPaths: [resolved] };
63
+ const allowedRootReal = options.allowedRoot !== undefined ? await resolveExistingPath(options.allowedRoot) : undefined;
64
+ if (allowedRootReal !== undefined)
65
+ assertInsideAllowedRoot(projectDir, allowedRootReal);
66
+ return buildResult(projectDir, [path.basename(resolved)], allowedRootReal);
117
67
  }
118
68
  let projectDir;
119
69
  if (stats.isDirectory()) {
@@ -127,13 +77,19 @@ export async function detectJavaProject(inputPath, options = {}) {
127
77
  else {
128
78
  throw new ScanToolError("project_not_found", `指定されたパスはディレクトリでも対応マニフェスト(pom.xml / gradle.lockfile)でもありません: ${inputPath}`);
129
79
  }
130
- if (options.allowedRoot !== undefined) {
131
- assertInsideAllowedRoot(projectDir, await resolveExistingPath(options.allowedRoot));
132
- }
133
- const { manifests, gradleBuildFileFound } = await findManifests(projectDir, {
134
- maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
135
- maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES,
136
- maxManifests: options.maxManifests ?? DEFAULT_MAX_MANIFESTS,
80
+ const allowedRootReal = options.allowedRoot !== undefined ? await resolveExistingPath(options.allowedRoot) : undefined;
81
+ if (allowedRootReal !== undefined)
82
+ assertInsideAllowedRoot(projectDir, allowedRootReal);
83
+ const manifests = [];
84
+ let gradleBuildFileFound = false;
85
+ await walkProjectFiles(projectDir, options, SKIPPED_DIRS, (relativePath, name) => {
86
+ if (MANIFEST_FILENAMES.has(name)) {
87
+ manifests.push(relativePath);
88
+ return true;
89
+ }
90
+ if (GRADLE_BUILD_FILENAMES.has(name))
91
+ gradleBuildFileFound = true;
92
+ return false;
137
93
  });
138
94
  if (manifests.length === 0) {
139
95
  if (gradleBuildFileFound) {
@@ -141,9 +97,5 @@ export async function detectJavaProject(inputPath, options = {}) {
141
97
  }
142
98
  throw new ScanToolError("no_manifest_found", `対応マニフェスト(pom.xml / gradle.lockfile)が見つかりません: ${projectDir}`);
143
99
  }
144
- return {
145
- projectDir,
146
- manifests,
147
- manifestPaths: manifests.map((manifest) => path.join(projectDir, manifest)),
148
- };
100
+ return buildResult(projectDir, manifests, allowedRootReal);
149
101
  }
@@ -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,223 @@
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 { realpath } from "node:fs/promises";
20
+ import path from "node:path";
21
+ import { isInsideDir } from "./projectWalk.js";
22
+ import { readRegularFile } from "./safeRead.js";
23
+ const MAX_FILE_BYTES = 1024 * 1024;
24
+ const MAX_INCLUDE_DEPTH = 5;
25
+ const MAX_FILES = 50;
26
+ /** PEP 503の名前正規化(小文字化し、`-_.`の連続を`-`にする) */
27
+ export function normalizePypiName(name) {
28
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
29
+ }
30
+ // osv-scannerは`-`の後の空白も許して取り込みとみなすため、こちらも空白を許して認識する
31
+ const INCLUDE_REQUIREMENT = /^(?:-\s*r|--\s*requirement)(?:\s*=\s*|\s*)(\S.*)$/;
32
+ const INCLUDE_CONSTRAINT = /^(?:-\s*c|--\s*constraint)(?:\s*=\s*|\s*)(\S.*)$/;
33
+ const EDITABLE = /^(?:-\s*e|--\s*editable)(?:\s|=|$)/;
34
+ /** 依存の解決先や取得方法に関するオプション。スキャン対象には影響しない(deps.devモードでは接続しない) */
35
+ 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|=|$)/;
36
+ const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
37
+ const NAME_AND_SPEC = /^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)\s*(?:\[[^\]]*\])?\s*(.*)$/;
38
+ const SPEC = /^(===|==|~=|!=|>=|<=|>|<)\s*([^\s,]+)$/;
39
+ /** コピーに書く版。想定外の文字を含む版は書かない(解釈のずれを持ち込まない) */
40
+ const SAFE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+!-]*$/;
41
+ /** 行継続を結合し、コメントを除いた論理行を返す(行番号は論理行の先頭) */
42
+ function logicalLines(text) {
43
+ const result = [];
44
+ const physical = text.split(/\r?\n/);
45
+ let buffer = "";
46
+ let start = 0;
47
+ for (let i = 0; i < physical.length; i++) {
48
+ const raw = physical[i];
49
+ if (buffer === "")
50
+ start = i + 1;
51
+ if (raw.endsWith("\\")) {
52
+ buffer += raw.slice(0, -1) + " ";
53
+ continue;
54
+ }
55
+ buffer += raw;
56
+ flush();
57
+ }
58
+ flush(); // 行継続で終わるファイル
59
+ return result;
60
+ function flush() {
61
+ // 行頭または空白の後の#以降はコメント(URL内の#egg=等は残る)
62
+ const content = buffer.replace(/(^|\s)#.*$/, "").trim();
63
+ if (content !== "")
64
+ result.push({ line: start, content });
65
+ buffer = "";
66
+ }
67
+ }
68
+ function classifyRequirement(file, line, content, out) {
69
+ const reference = (reason) => {
70
+ out.references.push({ file, line, text: content, reason });
71
+ };
72
+ // 環境マーカーと行末のオプション(--hash等)を除く
73
+ const withoutMarker = content.split(";")[0];
74
+ const requirement = withoutMarker.replace(/\s--?[A-Za-z].*$/, "").trim();
75
+ if (/\s@\s/.test(requirement) || URL_SCHEME.test(requirement) || /^[./~]/.test(requirement) || requirement.includes("/")) {
76
+ reference("URL・パス指定の依存はスキャンされません");
77
+ return;
78
+ }
79
+ const match = NAME_AND_SPEC.exec(requirement);
80
+ if (!match) {
81
+ reference("解釈できない行です");
82
+ return;
83
+ }
84
+ const name = match[1];
85
+ const specifier = match[2].replace(/[()]/g, "").trim();
86
+ const specs = specifier === "" ? [] : specifier.split(",").map((s) => s.trim()).filter((s) => s !== "");
87
+ if (specs.length === 0) {
88
+ out.issues.push({ file, line, name, specifier, kind: "unpinned" });
89
+ return;
90
+ }
91
+ const parsed = specs.length === 1 ? SPEC.exec(specs[0]) : null;
92
+ if (parsed) {
93
+ const op = parsed[1];
94
+ const version = parsed[2];
95
+ if ((op === "==" || op === "===") && !version.includes("*")) {
96
+ if (!SAFE_VERSION.test(version))
97
+ return reference("解釈できない版の指定です");
98
+ out.entries.push(`${name}==${version}`);
99
+ return;
100
+ }
101
+ if (op === ">=" || op === "~=") {
102
+ if (!SAFE_VERSION.test(version))
103
+ return reference("解釈できない版の指定です");
104
+ // osv-scannerは下限を使用中の版とみなしてスキャンする(実機確認)。その挙動を保ち、印を付けて報告する
105
+ out.entries.push(`${name}${op}${version}`);
106
+ out.issues.push({ file, line, name, specifier, kind: "lower_bound" });
107
+ out.lowerBounds.push({ name: normalizePypiName(name), version });
108
+ return;
109
+ }
110
+ }
111
+ out.issues.push({ file, line, name, specifier, kind: "range" });
112
+ }
113
+ async function include(file, line, content, target, projectDir, depth, out, context) {
114
+ const reference = (reason) => {
115
+ out.references.push({ file, line, text: content, reason });
116
+ };
117
+ const cleaned = target.trim().replace(/^["']|["']$/g, "");
118
+ if (URL_SCHEME.test(cleaned))
119
+ return reference("URLの取り込みは展開しません");
120
+ if (depth >= MAX_INCLUDE_DEPTH)
121
+ return reference(`取り込みの深さが上限(${MAX_INCLUDE_DEPTH})を超えています`);
122
+ let resolved;
123
+ try {
124
+ resolved = await realpath(path.resolve(path.dirname(file), cleaned));
125
+ }
126
+ catch {
127
+ return reference("取り込み先が存在しません");
128
+ }
129
+ if (!isInsideDir(projectDir, resolved))
130
+ return reference("取り込み先がプロジェクトディレクトリの外のため展開しません");
131
+ if (out.visited.has(resolved))
132
+ return; // 循環
133
+ if (out.visited.size >= MAX_FILES)
134
+ return reference(`取り込みファイルが上限(${MAX_FILES}件)を超えています`);
135
+ let failure;
136
+ try {
137
+ failure = await analyze(resolved, projectDir, depth + 1, out, context);
138
+ }
139
+ catch {
140
+ failure = "取り込み先を読み込めません";
141
+ }
142
+ if (failure !== null)
143
+ reference(failure);
144
+ }
145
+ /** 1回のスキャンで読むrequirements.txt(取り込み先を含む)の合計の上限 */
146
+ const DEFAULT_TOTAL_BYTES = 64 * 1024 * 1024;
147
+ export function createRequirementsReadContext(maxTotalBytes = DEFAULT_TOTAL_BYTES) {
148
+ return { remainingBytes: maxTotalBytes, texts: new Map() };
149
+ }
150
+ /** 安全に読む(FIFOで止まらず、差し替え・範囲外を検出する)。同じファイルは1回だけ読む */
151
+ async function readText(file, projectDir, context) {
152
+ const cached = context.texts.get(file);
153
+ if (cached !== undefined)
154
+ return { ok: true, text: cached };
155
+ const budgetLimited = context.remainingBytes < MAX_FILE_BYTES;
156
+ const result = await readRegularFile(file, {
157
+ maxBytes: Math.min(MAX_FILE_BYTES, context.remainingBytes),
158
+ boundary: projectDir,
159
+ });
160
+ if (!result.ok) {
161
+ if (result.failure === "too_large") {
162
+ return {
163
+ ok: false,
164
+ reason: budgetLimited
165
+ ? "requirements.txtの解析量の合計が上限を超えたため読みませんでした"
166
+ : `サイズが上限(${MAX_FILE_BYTES}バイト)を超えています`,
167
+ };
168
+ }
169
+ return { ok: false, reason: result.message };
170
+ }
171
+ context.remainingBytes -= result.bytes.length;
172
+ const text = result.bytes.toString("utf8");
173
+ context.texts.set(file, text);
174
+ return { ok: true, text };
175
+ }
176
+ /** ファイルを解析してoutに追加する。読めない・大きすぎる場合は理由を返す */
177
+ async function analyze(file, projectDir, depth, out, context) {
178
+ out.visited.add(file);
179
+ const read = await readText(file, projectDir, context);
180
+ if (!read.ok)
181
+ return read.reason;
182
+ for (const { line, content } of logicalLines(read.text)) {
183
+ const requirementInclude = INCLUDE_REQUIREMENT.exec(content);
184
+ if (requirementInclude) {
185
+ await include(file, line, content, requirementInclude[1], projectDir, depth, out, context);
186
+ continue;
187
+ }
188
+ if (INCLUDE_CONSTRAINT.test(content)) {
189
+ out.references.push({ file, line, text: content, reason: "制約ファイルは適用していません" });
190
+ continue;
191
+ }
192
+ if (EDITABLE.test(content)) {
193
+ out.references.push({ file, line, text: content, reason: "編集可能インストール(-e)はスキャンされません" });
194
+ continue;
195
+ }
196
+ if (content.startsWith("-")) {
197
+ if (!HARMLESS_OPTION.test(content)) {
198
+ out.references.push({ file, line, text: content, reason: "解釈できないオプションです" });
199
+ }
200
+ continue;
201
+ }
202
+ classifyRequirement(file, line, content, out);
203
+ }
204
+ return null;
205
+ }
206
+ /**
207
+ * requirements.txtを解析する。元ファイル自体が読めない・大きすぎる場合だけ`ok: false`。
208
+ * @param file 検出済みの絶対パス
209
+ * @param projectDir 取り込みを展開する範囲(解決済みの絶対パス)
210
+ */
211
+ export async function analyzeRequirementsFile(file, projectDir, context = createRequirementsReadContext()) {
212
+ const out = { entries: [], issues: [], references: [], lowerBounds: [], visited: new Set() };
213
+ let reason;
214
+ try {
215
+ reason = await analyze(await realpath(file), projectDir, 0, out, context);
216
+ }
217
+ catch {
218
+ reason = "読み込めません";
219
+ }
220
+ if (reason !== null)
221
+ return { ok: false, reason };
222
+ return { ok: true, entries: out.entries, issues: out.issues, references: out.references, lowerBounds: out.lowerBounds };
223
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * スキャン対象ファイルの安全な読み込み(検査と読み込みの不一致を起こさないための共通部品)。
3
+ *
4
+ * - `O_NOFOLLOW`で開き、末尾がシンボリックリンクに差し替えられたファイルを読まない
5
+ * - `O_NONBLOCK`で開き、名前付きパイプ(FIFO)等で処理が止まらないようにする。開いた実体を
6
+ * `fstat`し、通常のファイル以外は読まない
7
+ * - サイズの上限付きで読む
8
+ * - 読み終えた後、パスを解決し直して境界の内側にあることと、開いた実体と同じファイル
9
+ * (デバイス番号・inodeが一致)であることを確認する。途中のディレクトリをシンボリックリンクに
10
+ * 差し替えて範囲外を読ませ、確認前に戻す、といった差し替えを検出する
11
+ *
12
+ * 呼び出し側は読んだ内容(またはそのコピー)だけを使い、元のパスを他のプロセスに渡さない。
13
+ * (ハードリンクはパスとしては境界の内側にあるため区別しない)
14
+ */
15
+ import { constants } from "node:fs";
16
+ import { open, realpath, rm, stat } from "node:fs/promises";
17
+ import { isInsideDir } from "./projectWalk.js";
18
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
19
+ const MESSAGES = {
20
+ not_found: "ファイルを開けません",
21
+ not_regular: "通常のファイルではありません(名前付きパイプ・デバイス等)",
22
+ too_large: "サイズが上限を超えています",
23
+ outside: "スキャン範囲の外のファイルです",
24
+ changed: "検査中にファイルが差し替えられました",
25
+ };
26
+ function failure(kind) {
27
+ return { ok: false, failure: kind, message: MESSAGES[kind] };
28
+ }
29
+ /**
30
+ * 読み終えた後の確認: パスを解決し直し、境界の内側で、開いた実体と同じファイルか。
31
+ * 問題が無ければnull、あれば失敗の種類を返す。
32
+ */
33
+ export async function verifyOpenedFile(filePath, opened, boundary) {
34
+ let resolved;
35
+ let current;
36
+ try {
37
+ resolved = await realpath(filePath);
38
+ current = await stat(resolved);
39
+ }
40
+ catch {
41
+ return "changed";
42
+ }
43
+ if (current.dev !== opened.dev || current.ino !== opened.ino)
44
+ return "changed";
45
+ if (boundary !== undefined && !isInsideDir(boundary, resolved))
46
+ return "outside";
47
+ return null;
48
+ }
49
+ /**
50
+ * 通常のファイルを上限付きで安全にdestへコピーする(大きなJAR/WAR向けに全体をメモリに載せない)。
51
+ * destは新規作成(既存なら失敗)・所有者のみ読み書き可。失敗時はdestを削除する。
52
+ * 成功時はコピーしたバイト数を返す。
53
+ */
54
+ export async function copyRegularFile(filePath, dest, options) {
55
+ let source;
56
+ try {
57
+ source = await open(filePath, OPEN_FLAGS);
58
+ }
59
+ catch {
60
+ return failure("not_found");
61
+ }
62
+ let total = 0;
63
+ let result = null;
64
+ try {
65
+ const info = await source.stat();
66
+ if (!info.isFile())
67
+ result = "not_regular";
68
+ else if (info.size > options.maxBytes)
69
+ result = "too_large";
70
+ else {
71
+ const target = await open(dest, "wx", 0o600);
72
+ try {
73
+ const chunk = Buffer.alloc(1024 * 1024);
74
+ while (true) {
75
+ const { bytesRead } = await source.read(chunk, 0, chunk.length, null);
76
+ if (bytesRead === 0)
77
+ break;
78
+ total += bytesRead;
79
+ if (total > options.maxBytes) {
80
+ result = "too_large";
81
+ break;
82
+ }
83
+ await target.write(chunk, 0, bytesRead);
84
+ }
85
+ }
86
+ finally {
87
+ await target.close();
88
+ }
89
+ if (result === null)
90
+ result = await verifyOpenedFile(filePath, info, options.boundary);
91
+ }
92
+ }
93
+ catch {
94
+ result = "not_found";
95
+ }
96
+ finally {
97
+ await source.close();
98
+ }
99
+ if (result !== null) {
100
+ await rm(dest, { force: true });
101
+ return failure(result);
102
+ }
103
+ return { ok: true, bytes: total };
104
+ }
105
+ /** 通常のファイルを上限付きで安全に読む */
106
+ export async function readRegularFile(filePath, options) {
107
+ let handle;
108
+ try {
109
+ handle = await open(filePath, OPEN_FLAGS);
110
+ }
111
+ catch {
112
+ return failure("not_found");
113
+ }
114
+ let info;
115
+ const chunks = [];
116
+ let total = 0;
117
+ try {
118
+ info = await handle.stat();
119
+ if (!info.isFile())
120
+ return failure("not_regular");
121
+ if (info.size > options.maxBytes)
122
+ return failure("too_large");
123
+ const chunk = Buffer.alloc(Math.min(1024 * 1024, options.maxBytes + 1));
124
+ while (true) {
125
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, options.maxBytes - total + 1), null);
126
+ if (bytesRead === 0)
127
+ break;
128
+ total += bytesRead;
129
+ if (total > options.maxBytes)
130
+ return failure("too_large");
131
+ chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
132
+ }
133
+ }
134
+ catch {
135
+ return failure("not_found");
136
+ }
137
+ finally {
138
+ await handle.close();
139
+ }
140
+ const problem = await verifyOpenedFile(filePath, info, options.boundary);
141
+ if (problem !== null)
142
+ return failure(problem);
143
+ return { ok: true, bytes: Buffer.concat(chunks, total) };
144
+ }
@@ -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 { verifyOpenedFile } from "./safeRead.js";
6
7
  import { asRecord } from "./unknownJson.js";
7
8
  const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024;
8
9
  const CYCLONEDX_VERSIONS = new Set(["1.4", "1.5", "1.6"]);
@@ -33,8 +34,9 @@ export async function loadSbom(inputPath, options = {}) {
33
34
  try {
34
35
  // Nonblocking open avoids hanging on FIFOs. Refuse a leaf replaced by a symlink.
35
36
  const file = await open(sourcePath, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
37
+ let info;
36
38
  try {
37
- const info = await file.stat();
39
+ info = await file.stat();
38
40
  if (!info.isFile())
39
41
  throw new ScanToolError("sbom_not_found", "SBOM input must be a regular file");
40
42
  if (info.size > maxBytes)
@@ -56,6 +58,13 @@ export async function loadSbom(inputPath, options = {}) {
56
58
  finally {
57
59
  await file.close();
58
60
  }
61
+ // 読んだ後に、差し替え(途中のディレクトリのシンボリックリンク化等)と境界を確認する
62
+ const allowedRootReal = options.allowedRoot !== undefined ? await realpath(options.allowedRoot) : undefined;
63
+ const problem = await verifyOpenedFile(sourcePath, info, allowedRootReal);
64
+ if (problem === "outside")
65
+ throw new ScanToolError("path_outside_allowed_root", "SBOM path is outside the allowed root");
66
+ if (problem !== null)
67
+ throw new ScanToolError("sbom_not_found", "The SBOM file was replaced while it was being read");
59
68
  }
60
69
  catch (error) {
61
70
  if (error instanceof ScanToolError)