diff --git a/src/filesystem/README.md b/src/filesystem/README.md index ac63f39a5f..6e0116c2df 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -9,6 +9,7 @@ Node.js server implementing Model Context Protocol (MCP) for filesystem operatio - Move files/directories - Search files - Get file metadata +- Get file digest (md5, sha1, sha256) - Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) ## Directory Access Control @@ -150,6 +151,21 @@ The server's directory access control follows this flow: - Type (file/directory) - Permissions +- **get_file_hash** + + - Compute the cryptographic hash of a regular file (md5, sha1, or sha256) + - Inputs: + - `path` (string): File to hash + - `algorithm` (`"md5" | "sha1" | "sha256"`, optional): Defaults to `"sha256"` + - `encoding` (`"hex" | "base64"`, optional): Digest encoding, defaults to `"hex"` + - Streams file contents for memory-efficient hashing + - Only operates within allowed directories + - Returns the digest as a string + - Notes: + - Fails if the path is not a regular file + - May error if the requested algorithm is unavailable in the current Node/OpenSSL build (e.g., FIPS mode) + - Digest encodings (`hex`, `base64`) are supported by Node’s `crypto` `Hash#digest`, and the filesystem server restricts operations to configured allowed directories. + - **list_allowed_directories** - List all directories the server is allowed to access - No input required diff --git a/src/filesystem/__tests__/file-hash.test.ts b/src/filesystem/__tests__/file-hash.test.ts new file mode 100644 index 0000000000..8580b8f7dc --- /dev/null +++ b/src/filesystem/__tests__/file-hash.test.ts @@ -0,0 +1,135 @@ +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { getFileHash } from "../file-hash.js"; + +describe("get_file_hash (complete coverage)", () => { + let tmpDir: string; + let textFile: string; + let binFile: string; + let dirPath: string; + let symlinkToDir: string; + + // Test data + const TEXT = "ForensicShark"; + // Expected digests for "ForensicShark" (without newline) + const TEXT_DIGESTS = { + md5_hex: "1422ac7778fd50963651bc74686158b7", + sha1_hex: "a74904ee14c16d949256e96110596bdffc48f481", + sha256_hex: "53746f49c75306a3066eb456dba05b99aab88f562d2c020582c9226d9c969987", + md5_b64: "FCKsd3j9UJY2Ubx0aGFYtw==", + sha1_b64: "p0kE7hTBbZSSVulhEFlr3/xI9IE=", + sha256_b64: "U3RvScdTBqMGbrRW26Bbmaq4j1YtLAIFgskibZyWmYc=", + } as const; + + // Small binary snippet: 00 FF 10 20 42 7F + const BIN_SNIPPET = Buffer.from([0x00, 0xff, 0x10, 0x20, 0x42, 0x7f]); + const BIN_DIGESTS = { + md5_hex: "3bd2f5d961a05d8cb7edd3953adc069c", + sha1_hex: "28541834deba1f200e2fbde455bddb2e258afe36", + sha256_hex: "6048e89b6ff39be935d44c069a21f22ae7401177ee4c7d3156a4e3b48102d53f", + md5_b64: "O9L12WGgXYy37dOVOtwGnA==", + sha1_b64: "KFQYNN66HyAOL73kVb3bLiWK/jY=", + sha256_b64: "YEjom2/zm+k11EwGmiHyKudAEXfuTH0xVqTjtIEC1T8=", + } as const; + + beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "get-file-hash-")); + textFile = path.join(tmpDir, "text.txt"); + binFile = path.join(tmpDir, "bin.dat"); + dirPath = path.join(tmpDir, "a-directory"); + symlinkToDir = path.join(tmpDir, "dir-link"); + + await fs.writeFile(textFile, TEXT, "utf-8"); + await fs.writeFile(binFile, BIN_SNIPPET); + await fs.mkdir(dirPath); + + // Symlink to directory (on Windows: "junction") + if (process.platform === "win32") { + await fs.symlink(dirPath, symlinkToDir, "junction"); + } else { + await fs.symlink(dirPath, symlinkToDir); + } + }); + + afterAll(async () => { + // Cleanup + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + // + // 1) Text 'ForensicShark' → md5/sha1/sha256 (hex) + // + test("hash of text 'ForensicShark' (md5/sha1/sha256, hex)", async () => { + await expect(getFileHash(textFile, "md5", "hex")).resolves.toBe(TEXT_DIGESTS.md5_hex); + await expect(getFileHash(textFile, "sha1", "hex")).resolves.toBe(TEXT_DIGESTS.sha1_hex); + await expect(getFileHash(textFile, "sha256", "hex")).resolves.toBe(TEXT_DIGESTS.sha256_hex); + }); + + // + // 2) Not a file: directory, symlink to directory, /dev/null (if present) + // + test("rejects directory as not a regular file", async () => { + await expect(getFileHash(dirPath, "sha256", "hex")).rejects.toThrow(/not a regular file/i); + }); + + test("rejects symlink to directory as not a regular file", async () => { + await expect(getFileHash(symlinkToDir, "sha256", "hex")).rejects.toThrow(/not a regular file/i); + }); + + test("rejects device file like /dev/null when present", async () => { + if (process.platform === "win32") { + // No /dev/null → skip test + return; + } + try { + const devNull = "/dev/null"; + const st = await fs.lstat(devNull); + // If present & not a regular file → expected error + if (!st.isFile()) { + await expect(getFileHash(devNull, "sha256", "hex")).rejects.toThrow(/not a regular file|EISDIR|EPERM|EINVAL/i); + } + } catch { + // /dev/null does not exist → skip + return; + } + }); + + // + // 3) Binary snippet correct (all three algorithms, hex) + // + test("hash of small binary snippet (md5/sha1/sha256, hex)", async () => { + await expect(getFileHash(binFile, "md5", "hex")).resolves.toBe(BIN_DIGESTS.md5_hex); + await expect(getFileHash(binFile, "sha1", "hex")).resolves.toBe(BIN_DIGESTS.sha1_hex); + await expect(getFileHash(binFile, "sha256", "hex")).resolves.toBe(BIN_DIGESTS.sha256_hex); + }); + + // + // 4) Unknown algorithms → error (at least three) + // We intentionally use common but NOT allowed names (sha512) + // plus fantasy/legacy names, so the test remains stable. + // + test("rejects unsupported algorithms", async () => { + const badAlgos = ["sha512", "crc32", "whirlpool", "shark512", "legacy-md5"]; + for (const algo of badAlgos) { + // cast to any to bypass TS union, we test runtime errors + await expect(getFileHash(textFile, algo as any, "hex")).rejects.toThrow(/algorithm|unsupported|not available/i); + } + }); + + // + // 5) Encodings hex & base64 correct + // + test("encodings: hex and base64 (text case)", async () => { + // hex wurde oben schon geprüft; hier nochmals base64 explizit + await expect(getFileHash(textFile, "md5", "base64")).resolves.toBe(TEXT_DIGESTS.md5_b64); + await expect(getFileHash(textFile, "sha1", "base64")).resolves.toBe(TEXT_DIGESTS.sha1_b64); + await expect(getFileHash(textFile, "sha256", "base64")).resolves.toBe(TEXT_DIGESTS.sha256_b64); + }); + + test("encodings: hex and base64 (binary case)", async () => { + await expect(getFileHash(binFile, "md5", "base64")).resolves.toBe(BIN_DIGESTS.md5_b64); + await expect(getFileHash(binFile, "sha1", "base64")).resolves.toBe(BIN_DIGESTS.sha1_b64); + await expect(getFileHash(binFile, "sha256", "base64")).resolves.toBe(BIN_DIGESTS.sha256_b64); + }); +}); diff --git a/src/filesystem/file-hash.ts b/src/filesystem/file-hash.ts new file mode 100644 index 0000000000..a5e15d23bc --- /dev/null +++ b/src/filesystem/file-hash.ts @@ -0,0 +1,46 @@ +import { createHash, getHashes } from "crypto"; +import { createReadStream } from "fs"; +import fs from "fs/promises"; + +// Hashing utility +type HashAlgorithm = "md5" | "sha1" | "sha256"; +export async function getFileHash( + filePath: string, + algorithm: HashAlgorithm, + encoding: "hex" | "base64" = "hex" +): Promise { + const algo = algorithm.toLowerCase() as HashAlgorithm; + // Policy gate: allow only md5|sha1|sha256 (for DFIR interoperability) + if (!["md5","sha1","sha256"].includes(algo)) { + throw new Error(`Unsupported hash algorithm: ${algorithm}`); + } + + // Fail early if Node/OpenSSL is not supported (FIPS/Builds) + const available = new Set(getHashes().map(h => h.toLowerCase())); + if (!available.has(algo)) { + throw new Error( + `Algorithm '${algo}' is not available in this Node/OpenSSL build (FIPS or policy may disable it).` + ); + } + + // Allow only regular files (throw a clear error if not) + const st = await fs.stat(filePath); + if (!st.isFile()) { + throw new Error(`Path is not a regular file: ${filePath}`); + } + + const hash = createHash(algo); + const stream = createReadStream(filePath, { highWaterMark: 1024 * 1024 }); // 1 MiB + + return await new Promise((resolve, reject) => { + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", (err) => reject(err)); + stream.on("end", () => { + try { + resolve(hash.digest(encoding)); + } catch (e) { + reject(e); + } + }); + }); +} \ No newline at end of file diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 6723f43600..e07d292c74 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -13,13 +13,14 @@ import fs from "fs/promises"; import { createReadStream } from "fs"; import path from "path"; import os from 'os'; -import { randomBytes } from 'crypto'; +import { randomBytes, createHash, getHashes } from 'crypto'; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { diffLines, createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; import { isPathWithinAllowedDirectories } from './path-validation.js'; import { getValidRootDirectories } from './roots-utils.js'; +import { getFileHash } from "./file-hash.js"; // Command line argument parsing const args = process.argv.slice(2); @@ -179,6 +180,12 @@ const GetFileInfoArgsSchema = z.object({ path: z.string(), }); +const GetFileHashArgsSchema = z.object({ + path: z.string(), + algorithm: z.enum(['md5', 'sha1', 'sha256']).default('sha256').describe('Hash algorithm to use'), + encoding: z.enum(['hex', 'base64']).default('hex').describe('Digest encoding') +}); + const ToolInputSchema = ToolSchema.shape.inputSchema; type ToolInput = z.infer; @@ -623,6 +630,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: [], }, }, + { + name: "get_file_hash", + description: + "Compute the cryptographic hash of a file for integrity verification. " + + "Use only for regular files within allowed directories (not directories/devices). " + + "Inputs: { path: absolute path, algorithm: \"md5\"|\"sha1\"|\"sha256\", " + + "encoding: \"hex\"|\"base64\" (optional, default \"hex\") }. " + + "Return only the digest string. Call when verifying file integrity or comparing files.", + inputSchema: zodToJsonSchema(GetFileHashArgsSchema) as ToolInput, + } ], }; }); @@ -944,6 +961,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "get_file_hash": { + const parsed = GetFileHashArgsSchema.safeParse(args); + if (!parsed.success) { + throw new Error(`Invalid arguments for get_file_hash: ${parsed.error}`); + } + const validPath = await validatePath(parsed.data.path); + const encoding = parsed.data.encoding ?? "hex"; + const hash = await getFileHash(validPath, parsed.data.algorithm, encoding); + return { + content: [{ + type: "text", + text: `algorithm: ${parsed.data.algorithm}\nencoding: ${encoding}\npath: ${parsed.data.path}\ndigest: ${hash}` + }], + }; + } + case "list_allowed_directories": { return { content: [{