UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

160 lines (159 loc) • 7.26 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Tar archive extraction and integrity verification utilities. * * These helpers are security-critical: they validate archive entries to * prevent path traversal, run a post-extraction scan for symlink escapes, * and verify integrity hashes (SRI or hex shasum) before extraction. * * Extracted from `registry-install.ts` and shared by all syncable * providers that fetch tarballs (currently `NpmSourceProvider` and the * registry index builder). */ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { isWithin } from "../../core/common.js"; import { warn } from "../../core/warn.js"; import { spawnSync } from "../../runtime.js"; /** * Verify an archive's integrity against a known hash. Throws and removes * the archive when verification fails. * * Supports SRI hashes (sha256-/sha512-) and hex SHA-1 from npm. * Skips verification for git/github sources (revisions are commit SHAs, * not content hashes). */ export function verifyArchiveIntegrity(archivePath, expected, source) { if (!expected) return; // For GitHub and git sources, resolvedRevision is a commit SHA, not a content hash. // Content integrity cannot be verified from a commit hash, so skip verification. if (source === "github" || source === "git") return; const fileBuffer = fs.readFileSync(archivePath); // SRI hash format: sha256-<base64> or sha512-<base64> if (expected.startsWith("sha256-") || expected.startsWith("sha512-")) { const dashIndex = expected.indexOf("-"); const algorithm = expected.slice(0, dashIndex); const expectedBase64 = expected.slice(dashIndex + 1); const actualBase64 = createHash(algorithm).update(fileBuffer).digest("base64"); if (actualBase64 !== expectedBase64) { fs.unlinkSync(archivePath); throw new Error(`Integrity check failed for ${archivePath}: expected ${algorithm} digest ${expectedBase64}, got ${actualBase64}`); } return; } // Hex shasum (SHA-1 from npm) if (/^[0-9a-f]{40}$/i.test(expected)) { const actualHex = createHash("sha1").update(fileBuffer).digest("hex"); if (actualHex.toLowerCase() !== expected.toLowerCase()) { fs.unlinkSync(archivePath); throw new Error(`Integrity check failed for ${archivePath}: expected sha1 ${expected}, got ${actualHex}`); } return; } // Unrecognized format — warn and skip verification warn("Unrecognized integrity format: %s — verification skipped", expected); } /** * Extract a tar.gz archive into `destinationDir`, validating entries first * (no absolute paths, no `..` traversal, no NUL bytes), invoking tar with * `--no-same-owner --strip-components=1`, and finally scanning the extracted * tree for symlinks that would escape the destination. */ export function extractTarGzSecure(archivePath, destinationDir) { const listResult = spawnSync(["tar", "tzf", archivePath]); if (!listResult.success) { const err = listResult.stderr?.toString().trim() || "unknown error"; throw new Error(`Failed to inspect archive ${archivePath}: ${err}`); } validateTarEntries(listResult.stdout.toString()); fs.rmSync(destinationDir, { recursive: true, force: true }); fs.mkdirSync(destinationDir, { recursive: true }); const extractResult = spawnSync([ "tar", "xzf", archivePath, "--no-same-owner", "--strip-components=1", "-C", destinationDir, ]); if (!extractResult.success) { const err = extractResult.stderr?.toString().trim() || "unknown error"; throw new Error(`Failed to extract archive ${archivePath}: ${err}`); } // Post-extraction scan: verify all extracted files are within destinationDir // This mitigates TOCTOU between validateTarEntries (list) and tar extract. scanExtractedFiles(destinationDir, destinationDir); } function scanExtractedFiles(dir, root) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); // Reject only entries whose name is exactly the parent-traversal segment // (or `.`). Substring matches (`foo..bar`, `archive..2024.tar`) are // legitimate filenames that the previous `entry.name.includes("..")` // check rejected as false positives — flagged in PR #168 review. if (entry.name === ".." || entry.name === ".") { throw new Error(`Post-extraction scan: suspicious entry name: ${fullPath}`); } // Symlinks: resolve and confirm the target stays inside the destination. if (entry.isSymbolicLink()) { const target = fs.realpathSync(fullPath); if (!isWithin(target, root)) { throw new Error(`Post-extraction scan: symlink escapes destination directory: ${fullPath} -> ${target}`); } } // Belt-and-suspenders: check that the resolved path of regular entries // stays within the destination root. This catches path traversal attempts // via symlink TOCTOU, directory renames, or any other anomalies. if (!entry.isSymbolicLink()) { const resolved = path.resolve(fullPath); if (!isWithin(resolved, root)) { throw new Error(`Post-extraction scan: entry escapes destination directory: ${fullPath}`); } } if (entry.isDirectory()) { scanExtractedFiles(fullPath, root); } } } /** * Validate the line-oriented `tar tzf` listing for unsafe entries. * * Rejects: * - empty/NUL-containing entries * - absolute paths * - parent traversal (`..` / `../`) * - any entry that would still escape after `--strip-components=1` */ export function validateTarEntries(listOutput) { const lines = listOutput.split(/\r?\n/).filter(Boolean); for (const rawLine of lines) { const entry = rawLine.trim(); if (!entry || entry.includes("\0")) { throw new Error(`Archive contains an invalid entry: ${JSON.stringify(rawLine)}`); } if (entry.startsWith("/")) { throw new Error(`Archive contains an absolute path entry: ${entry}`); } const normalized = path.posix.normalize(entry); if (normalized === ".." || normalized.startsWith("../")) { throw new Error(`Archive contains a path traversal entry: ${entry}`); } const parts = normalized.split("/").filter(Boolean); const stripped = parts.slice(1).join("/"); if (!stripped) continue; const normalizedStripped = path.posix.normalize(stripped); if (normalizedStripped === ".." || normalizedStripped.startsWith("../") || path.posix.isAbsolute(normalizedStripped)) { throw new Error(`Archive contains an unsafe entry after strip-components: ${entry}`); } } }