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
JavaScript
// 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}`);
}
}
}