SPB Git

Repository Public MIT

validate.ts 13.9 KB 239 lines typescript

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
Files main
Menu

Theme