Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fd2572d
auto-claude: subtask-1-1 - Define security report types in shared pac…
siracusa5 Apr 6, 2026
6c08c66
auto-claude: subtask-1-2 - Create security scanner rule engine
siracusa5 Apr 6, 2026
53c7c19
auto-claude: subtask-1-3 - Create security scanner main logic
siracusa5 Apr 6, 2026
88ff498
auto-claude: subtask-1-4 - Create security report formatter
siracusa5 Apr 6, 2026
d72d221
auto-claude: subtask-1-5 - Export security scanner from core package
siracusa5 Apr 6, 2026
9f0abc0
auto-claude: subtask-1-6 - Add unit tests for security scanner
siracusa5 Apr 6, 2026
c30592b
auto-claude: subtask-2-1 - Create scan command implementation
siracusa5 Apr 6, 2026
3084a07
auto-claude: subtask-2-2 - Register scan command in CLI
siracusa5 Apr 6, 2026
f2b58e2
auto-claude: subtask-2-3 - Create scan command formatter
siracusa5 Apr 6, 2026
7cfec4a
auto-claude: subtask-2-4 - Add CLI integration tests for scan command
siracusa5 Apr 6, 2026
ebe99e1
auto-claude: subtask-3-1 - Create SecurityBadge component
siracusa5 Apr 6, 2026
57a982e
auto-claude: subtask-3-2 - Create PermissionsSummary component
siracusa5 Apr 6, 2026
27fb50f
auto-claude: subtask-3-3 - Update plugin detail page with security info
siracusa5 Apr 6, 2026
dc131e2
auto-claude: subtask-3-4 - Update TrustBadge to include security-scan…
siracusa5 Apr 6, 2026
968253e
feat(security): add database migration for security metadata columns
siracusa5 Apr 6, 2026
15997ea
feat(security): wire security scan fields into Component type and UI
siracusa5 Apr 6, 2026
ef9d5cc
feat(ci): add security-scan job to validate workflow
siracusa5 Apr 6, 2026
3004205
fix(security): remove dead formatter and fix CI count regex patterns
siracusa5 Apr 6, 2026
9215d38
fix(security): address critical and high security findings from review
siracusa5 Apr 6, 2026
40842d7
chore: remove accidentally committed node_modules symlinks and .claude
siracusa5 Apr 6, 2026
d2f06a9
fix(ci): fix crypto import and add shared build step before core
siracusa5 Apr 6, 2026
d490136
fix(tests): replace message.includes(url) with startsWith to clear Co…
siracusa5 Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
238 changes: 238 additions & 0 deletions apps/cli/__tests__/scan.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading
Loading