Repository Public MIT
1import { createHash } from "node:crypto";2import { EXAMPLE_LIMITS, LIMITS, MANIFEST_FILE, SEVERITIES, type Capability, type Finding, type Severity } from "./constants";3import { deriveCapabilities, isKnownLicense, manifestSchema, parseName, type Manifest } from "./manifest";4import { imageKindForPath, sniffImage } from "./media";5import { isProbablyText, scanFile } from "./scan";6import { parseSkillMd, type SkillFrontmatter } from "./skillmd";7import type { TarFile } from "./tar";89export interface FileSummary {10 path: string;11 size: number;12 sha256: string;13 mode: number;14 mime: string;15 isText: boolean;16}1718export interface ValidationResult {19 ok: boolean;20 manifest: Manifest | null;21 findings: Finding[];22 files: FileSummary[];23 capabilities: Capability[];24 readme: string | null;25 skill: SkillFrontmatter | null;26 maxSeverity: Severity | null;27 totalBytes: number;28}2930const BLOCKED_NAMES = [/^\.git(?:\/|$)/, /(?:^|\/)\.env(?:\.(?!example$|sample$|template$)[^/]*)?$/, /(?:^|\/)\.npmrc$/, /(?:^|\/)\.netrc$/, /(?:^|\/)\.ssh(?:\/|$)/, /(?:^|\/)\.aws(?:\/|$)/, /(?:^|\/)id_(?:rsa|dsa|ecdsa|ed25519)$/, /(?:^|\/)node_modules(?:\/|$)/, /\.(?:pem|p12|pfx|keychain|keychain-db)$/i];31const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;3233const MIME: Record<string, string> = {34 md: "text/markdown", mdx: "text/markdown", txt: "text/plain", json: "application/json", yaml: "application/yaml", yml: "application/yaml",35 toml: "application/toml", js: "text/javascript", mjs: "text/javascript", cjs: "text/javascript", ts: "text/typescript", tsx: "text/typescript",36 py: "text/x-python", sh: "text/x-shellscript", bash: "text/x-shellscript", zsh: "text/x-shellscript", html: "text/html", css: "text/css",37 csv: "text/csv", xml: "application/xml", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",38 webp: "image/webp", pdf: "application/pdf", rb: "text/x-ruby", go: "text/x-go", rs: "text/x-rust", sql: "application/sql",39};40export function mimeFor(path: string, isText: boolean): string {41 const ext = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1).toLowerCase() : "";42 return MIME[ext] ?? (isText ? "text/plain" : "application/octet-stream");43}4445/** Returns a reason when a relative path is unsafe, otherwise null. */46export function unsafePathReason(path: string): string | null {47 if (path.length === 0) return "empty path";48 if (path.length > LIMITS.maxPathLength) return `path longer than ${LIMITS.maxPathLength} characters`;49 // eslint-disable-next-line no-control-regex50 if (/[\u0000-\u001f\u007f]/.test(path)) return "control character in path";51 if (path.includes("\\")) return "backslash in path";52 if (path.startsWith("/")) return "absolute path";53 if (/^[A-Za-z]:/.test(path)) return "drive-letter path";54 if (path !== path.normalize("NFC")) return "path is not NFC-normalised";55 const segs = path.split("/");56 if (segs.length > LIMITS.maxPathDepth) return `path deeper than ${LIMITS.maxPathDepth} levels`;57 for (const s of segs) {58 if (s === "" ) return "empty path segment";59 if (s === "." || s === "..") return "path traversal segment";60 if (s.endsWith(" ") || s.endsWith(".")) return "segment ends with a space or dot";61 if (WINDOWS_RESERVED.test(s)) return "reserved device name";62 }63 return null;64}6566export function sha256Hex(data: Buffer | string): string {67 return createHash("sha256").update(data).digest("hex");68}6970export interface ValidateOptions {71 /** When set, the manifest name must equal this (server: the artifact being published to). */72 expectedName?: string;73 expectedVersion?: string;74}7576/** Validate an unpacked package given as an in-memory file list. Pure: no filesystem, no execution. */77export function validatePackage(input: TarFile[], opts: ValidateOptions = {}): ValidationResult {78 const findings: Finding[] = [];79 const block = (code: string, message: string, path?: string, severity: Severity = "high") => findings.push({ code, severity, message, path, blocking: true });80 const note = (code: string, message: string, path?: string, severity: Severity = "low") => findings.push({ code, severity, message, path, blocking: false });8182 const files: FileSummary[] = [];83 const seenLower = new Map<string, string>();84 const safe: TarFile[] = [];85 let totalBytes = 0;8687 if (input.length === 0) block("package-empty", "package contains no files");88 if (input.length > LIMITS.maxFiles) block("package-too-many-files", `package has more than ${LIMITS.maxFiles} files`);8990 for (const f of input) {91 const reason = unsafePathReason(f.path);92 if (reason) {93 block("path-unsafe", `unsafe path (${reason}): ${JSON.stringify(f.path).slice(0, 200)}`, undefined, "critical");94 continue;95 }96 const lower = f.path.toLowerCase();97 if (seenLower.has(lower)) {98 block("path-collision", `path collides with ${seenLower.get(lower)} (duplicate or case-insensitive clash)`, f.path);99 continue;100 }101 seenLower.set(lower, f.path);102 if (BLOCKED_NAMES.some((re) => re.test(f.path))) {103 block("path-forbidden", "file must not be published (VCS data, dependency tree, environment or key file)", f.path);104 continue;105 }106 if (f.path.split("/").some((s) => s.startsWith("."))) note("path-hidden", "hidden file included in the release", f.path);107 if (f.data.length > LIMITS.maxFileBytes) {108 block("file-too-large", `file exceeds ${LIMITS.maxFileBytes} bytes`, f.path);109 continue;110 }111 totalBytes += f.data.length;112 const isText = isProbablyText(f.data);113 files.push({ path: f.path, size: f.data.length, sha256: sha256Hex(f.data), mode: f.mode & 0o777, mime: mimeFor(f.path, isText), isText });114 safe.push(f);115 findings.push(...scanFile(f));116 }117 if (totalBytes > LIMITS.maxUnpackedBytes) block("package-too-large", `package expands beyond ${LIMITS.maxUnpackedBytes} bytes`);118 files.sort((a, b) => (a.path < b.path ? -1 : 1));119120 const byPath = new Map(safe.map((f) => [f.path, f]));121 let manifest: Manifest | null = null;122 let skill: SkillFrontmatter | null = null;123 const mf = byPath.get(MANIFEST_FILE);124 if (!mf) {125 block("manifest-missing", `${MANIFEST_FILE} is required at the package root`);126 } else if (mf.data.length > LIMITS.maxManifestBytes) {127 block("manifest-too-large", `${MANIFEST_FILE} exceeds ${LIMITS.maxManifestBytes} bytes`, MANIFEST_FILE);128 } else {129 let raw: unknown;130 try {131 raw = JSON.parse(mf.data.toString("utf8"));132 } catch {133 block("manifest-invalid-json", `${MANIFEST_FILE} is not valid JSON`, MANIFEST_FILE);134 }135 if (raw !== undefined) {136 const parsed = manifestSchema.safeParse(raw);137 if (!parsed.success) {138 for (const issue of parsed.error.issues.slice(0, 25)) {139 block("manifest-invalid", `${issue.path.join(".") || "(root)"}: ${issue.message}`, MANIFEST_FILE);140 }141 } else manifest = parsed.data;142 }143 }144145 if (manifest) {146 const m = manifest;147 const id = parseName(m.name)!;148 if (opts.expectedName && m.name !== opts.expectedName) block("manifest-identity-mismatch", `manifest name ${m.name} does not match the target artifact ${opts.expectedName}`, MANIFEST_FILE, "critical");149 if (opts.expectedVersion && m.version !== opts.expectedVersion) block("manifest-version-mismatch", `manifest version ${m.version} does not match the release version ${opts.expectedVersion}`, MANIFEST_FILE, "critical");150 if (!isKnownLicense(m.license)) note("license-unrecognised", `license "${m.license}" is not a common SPDX identifier; make sure a licence file is included`, MANIFEST_FILE);151 if (!m.repository) note("source-missing", "no source repository declared; provenance cannot be verified", MANIFEST_FILE, "info");152 if (m.entrypoint) {153 if (unsafePathReason(m.entrypoint)) block("entrypoint-unsafe", "entrypoint is not a safe relative path", MANIFEST_FILE);154 else if (!byPath.has(m.entrypoint)) block("entrypoint-missing", `entrypoint ${m.entrypoint} is not in the package`, MANIFEST_FILE);155 }156 const deps = m.dependencies ?? [];157 const depNames = new Set<string>();158 for (const d of deps) {159 if (d.name === m.name) block("dependency-self", "a package cannot depend on itself", MANIFEST_FILE);160 if (depNames.has(d.name)) block("dependency-duplicate", `dependency ${d.name} is listed twice`, MANIFEST_FILE);161 depNames.add(d.name);162 if (m.type === "bundle" && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(d.version)) {163 block("bundle-unlocked-dependency", `bundles must lock exact versions; ${d.name} uses "${d.version}"`, MANIFEST_FILE);164 }165 }166 if (m.type === "bundle" && deps.length === 0) block("bundle-empty", "a bundle must list at least one dependency", MANIFEST_FILE);167168 if (m.type === "skill") {169 const sk = byPath.get("SKILL.md");170 if (!sk) block("skill-entry-missing", "skills must contain SKILL.md at the package root");171 else {172 const r = parseSkillMd(sk.data.toString("utf8"), id.slug);173 skill = r.frontmatter;174 findings.push(...r.findings);175 }176 if (m.entrypoint && m.entrypoint !== "SKILL.md") block("skill-entrypoint", "the entrypoint of a skill is SKILL.md", MANIFEST_FILE);177 } else if (m.type === "mcp-server") {178 if (!m.mcp) block("mcp-block-missing", "mcp-server packages must describe their server in the `mcp` block (transport, command or url)", MANIFEST_FILE);179 else if (m.mcp.transport === "stdio" && /(?:^|\s)(?:sudo|rm|curl|wget|sh|bash|zsh)(?:\s|$)/.test(m.mcp.command ?? "")) {180 note("mcp-command-shell", "the declared server command is a shell or privileged tool; users will be warned", MANIFEST_FILE, "medium");181 }182 if (!byPath.has("README.md")) block("readme-missing", "mcp-server packages must include README.md with setup and tool documentation");183 } else if (!byPath.has("README.md")) {184 block("readme-missing", "README.md is required so users can inspect the package before using it");185 }186 const seenImages = new Set<string>();187 (m.examples ?? []).forEach((ex, i) => {188 const where = `examples[${i}].image`;189 const reason = unsafePathReason(ex.image);190 if (reason) return void block("example-path-unsafe", `${where} is not a safe relative path (${reason})`, MANIFEST_FILE, "critical");191 if (seenImages.has(ex.image.toLowerCase())) return void block("example-duplicate", `${where}: ${ex.image} is listed more than once`, MANIFEST_FILE);192 seenImages.add(ex.image.toLowerCase());193 const expected = imageKindForPath(ex.image);194 if (!expected) return void block("example-type-unsupported", `${where}: only ${EXAMPLE_LIMITS.extensions.map((e) => `.${e}`).join(", ")} screenshots are accepted (no SVG, GIF or other formats)`, ex.image);195 const file = byPath.get(ex.image);196 if (!file) return void block("example-missing", `${where}: ${ex.image} is not in the package`, MANIFEST_FILE);197 if (file.data.length > EXAMPLE_LIMITS.maxImageBytes) return void block("example-too-large", `${where}: image is ${file.data.length} bytes; the limit is ${EXAMPLE_LIMITS.maxImageBytes} bytes`, ex.image);198 const info = sniffImage(file.data);199 if (!info) return void block("example-not-an-image", `${where}: the file is not a well-formed PNG, JPEG or WebP image`, ex.image);200 if (info.kind !== expected) return void block("example-type-mismatch", `${where}: the file content is ${info.kind.toUpperCase()} but its extension says ${expected.toUpperCase()}`, ex.image);201 if (info.width > EXAMPLE_LIMITS.maxImageDimension || info.height > EXAMPLE_LIMITS.maxImageDimension) block("example-dimensions", `${where}: ${info.width}×${info.height} px exceeds ${EXAMPLE_LIMITS.maxImageDimension} px on a side`, ex.image);202 });203 if (m.mcp && m.type !== "mcp-server" && m.type !== "connector" && m.type !== "bundle") note("mcp-block-unexpected", `the mcp block is ignored for type ${m.type}`, MANIFEST_FILE);204205 const declared = new Set(m.permissions ?? []);206 const has = (code: string) => findings.some((f) => f.code === code);207 if ((has("net-access") || has("exec-pipe-to-shell")) && !declared.has("network")) note("permission-undeclared-network", "scripts appear to use the network but the manifest does not declare the `network` permission", MANIFEST_FILE, "medium");208 if ((has("exec-subprocess") || has("exec-sudo")) && !declared.has("shell")) note("permission-undeclared-shell", "scripts spawn processes but the manifest does not declare the `shell` permission", MANIFEST_FILE, "medium");209 if (!byPath.has("LICENSE") && !byPath.has("LICENSE.md") && !byPath.has("LICENSE.txt")) note("license-file-missing", "no LICENSE file in the package", undefined, "info");210 if (!byPath.has("CHANGELOG.md")) note("changelog-missing", "no CHANGELOG.md in the package", undefined, "info");211 }212213 const readmeFile = byPath.get("README.md") ?? byPath.get("SKILL.md");214 const readme = readmeFile && isProbablyText(readmeFile.data) ? readmeFile.data.toString("utf8").slice(0, LIMITS.maxPreviewBytes) : null;215 const hasExec = files.some((f) => f.mode & 0o111) || findings.some((f) => f.code === "file-script" || f.code === "file-compiled-binary");216 const order = (s: Severity) => SEVERITIES.indexOf(s);217 findings.sort((a, b) => Number(b.blocking) - Number(a.blocking) || order(b.severity) - order(a.severity) || (a.path ?? "").localeCompare(b.path ?? ""));218 const maxSeverity = findings.reduce<Severity | null>((mx, f) => (mx === null || order(f.severity) > order(mx) ? f.severity : mx), null);219220 return {221 ok: !findings.some((f) => f.blocking),222 manifest,223 findings,224 files,225 capabilities: manifest ? deriveCapabilities(manifest, hasExec) : [],226 readme,227 skill,228 maxSeverity,229 totalBytes,230 };231}232233/** Advisory status shown publicly. "passed" means "no advisory findings from static checks", never "safe". */234export function securityStatus(findings: Finding[]): "passed" | "flagged" | "failed" {235 if (findings.some((f) => f.blocking)) return "failed";236 if (findings.some((f) => f.severity === "medium" || f.severity === "high" || f.severity === "critical")) return "flagged";237 return "passed";238}239