diff --git a/tests/content-validation.test.ts b/tests/content-validation.test.ts index 3ef983548..e803c5bb4 100644 --- a/tests/content-validation.test.ts +++ b/tests/content-validation.test.ts @@ -1,3 +1,116 @@ +import { describe, expect, it } from "vitest"; +import path from "node:path"; +import fs from "node:fs"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const fixturesRoot = path.join(repoRoot, "tests/fixtures/hooks"); + +describe("hook content validation", () => { + describe("security regression fixtures for hook script bodies", () => { + // Vulnerable fixtures tests + it("has vulnerable fixture for predictable /tmp/ debug logs", () => { + const fixturePath = path.join(fixturesRoot, "vulnerable-tmp-debug-log.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('/tmp/'); + expect(content).not.toContain('mktemp'); + }); + + it("has vulnerable fixture for community download references", () => { + const fixturePath = path.join(fixturesRoot, "vulnerable-community-download.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('downloadUrl:'); + expect(content).toContain('.zip'); + expect(content).toContain('curl'); + }); + + it("has vulnerable fixture for missing safety notes", () => { + const fixturePath = path.join(fixturesRoot, "vulnerable-missing-safety-notes.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('delete'); + expect(content).not.toContain('safetyNotes:'); + }); + + it("has vulnerable fixture for missing privacy notes", () => { + const fixturePath = path.join(fixturesRoot, "vulnerable-missing-privacy-notes.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('find'); + expect(content).toContain('curl'); + expect(content).not.toContain('privacyNotes:'); + }); + + // Safe fixtures tests + it("has safe fixture with proper quoting", () => { + const fixturePath = path.join(fixturesRoot, "safe-proper-quoting.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('"$'); + }); + + it("has safe fixture with secure tmp usage", () => { + const fixturePath = path.join(fixturesRoot, "safe-secure-tmp-usage.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('mktemp'); + expect(content).toContain('trap'); + }); + + it("has safe fixture without external downloads", () => { + const fixturePath = path.join(fixturesRoot, "safe-no-external-downloads.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('repoUrl:'); + expect(content).not.toContain('downloadUrl:'); + }); + + it("has safe fixture with safety notes for destructive actions", () => { + const fixturePath = path.join(fixturesRoot, "safe-with-safety-notes.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('safetyNotes:'); + expect(content).toContain('delete'); + expect(content).toContain('Deletes temporary files'); + }); + + it("has safe fixture with privacy notes for local data access", () => { + const fixturePath = path.join(fixturesRoot, "safe-with-privacy-notes.mdx"); + expect(fs.existsSync(fixturePath)).toBe(true); + + const content = fs.readFileSync(fixturePath, "utf8"); + expect(content).toContain('scriptBody:'); + expect(content).toContain('privacyNotes:'); + expect(content).toContain('find'); + expect(content).toContain('Reads local workspace'); + }); + + // Summary test + it("has all 10 security regression fixtures (5 vulnerable + 5 safe)", () => { + const fixtures = fs.readdirSync(fixturesRoot); + const vulnerableFixtures = fixtures.filter(f => f.startsWith('vulnerable-')); + const safeFixtures = fixtures.filter(f => f.startsWith('safe-')); + + expect(vulnerableFixtures).toHaveLength(5); + expect(safeFixtures).toHaveLength(5); + expect(fixtures.length).toBeGreaterThanOrEqual(10); + }); import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; diff --git a/tests/fixtures/hooks/safe-no-external-downloads.mdx b/tests/fixtures/hooks/safe-no-external-downloads.mdx new file mode 100644 index 000000000..043ec6b43 --- /dev/null +++ b/tests/fixtures/hooks/safe-no-external-downloads.mdx @@ -0,0 +1,26 @@ +--- +title: Safe Hook - No External Downloads +slug: safe-no-external-downloads +category: hooks +description: Hook without external download references +cardDescription: Demonstrates safe hook without downloads +trigger: PostToolUse +repoUrl: https://github.com/example/safe-hook +scriptLanguage: bash +scriptBody: | + #!/usr/bin/env bash + # This hook operates without downloading external archives + + # Check if required tools are available + if ! command -v git &> /dev/null; then + echo "Git is required but not installed" + exit 1 + fi + + # Perform local operations only + git status --short + + echo "Hook completed successfully" +--- + +This hook demonstrates safe operation without external download/archive references. diff --git a/tests/fixtures/hooks/safe-proper-quoting.mdx b/tests/fixtures/hooks/safe-proper-quoting.mdx new file mode 100644 index 000000000..7c1b1d463 --- /dev/null +++ b/tests/fixtures/hooks/safe-proper-quoting.mdx @@ -0,0 +1,24 @@ +--- +title: Safe Hook - Proper Shell Quoting +slug: safe-proper-quoting +category: hooks +description: Hook with proper shell quoting in embedded scripts +cardDescription: Demonstrates safe quoting practices +trigger: PostToolUse +scriptLanguage: bash +scriptBody: | + #!/usr/bin/env bash + # This hook demonstrates proper shell quoting practices + + # Properly quoted variable expansion + FILE_PATH="$1" + + # Safe python -c usage with proper quoting + python3 -c 'import sys; print("Processing:", sys.argv[1])' "$FILE_PATH" + + # Properly quoted command substitution + TIMESTAMP="$(date +%Y-%m-%d)" + echo "Processed at: $TIMESTAMP" +--- + +This hook demonstrates safe shell quoting practices in embedded scripts. diff --git a/tests/fixtures/hooks/safe-secure-tmp-usage.mdx b/tests/fixtures/hooks/safe-secure-tmp-usage.mdx new file mode 100644 index 000000000..1ed44e3f4 --- /dev/null +++ b/tests/fixtures/hooks/safe-secure-tmp-usage.mdx @@ -0,0 +1,28 @@ +--- +title: Safe Hook - Secure Tmp Usage +slug: safe-secure-tmp-usage +category: hooks +description: Hook with secure temporary file handling using mktemp +cardDescription: Demonstrates secure tmp file practices +trigger: PostToolUse +scriptLanguage: bash +scriptBody: | + #!/usr/bin/env bash + # This hook demonstrates secure temporary file handling + + # Use mktemp for secure temporary file creation + DEBUG_LOG=$(mktemp /tmp/claude-hook.XXXXXX) + + # Ensure cleanup on exit + trap 'rm -f "$DEBUG_LOG"' EXIT + + echo "Processing hook at $(date)" >> "$DEBUG_LOG" + echo "Current directory: $(pwd)" >> "$DEBUG_LOG" + + # Perform operation with secure temp file + echo "Hook completed" >> "$DEBUG_LOG" + + # File is automatically cleaned up by trap +--- + +This hook demonstrates secure temporary file handling using mktemp with proper cleanup. diff --git a/tests/fixtures/hooks/safe-with-privacy-notes.mdx b/tests/fixtures/hooks/safe-with-privacy-notes.mdx new file mode 100644 index 000000000..d2a7db514 --- /dev/null +++ b/tests/fixtures/hooks/safe-with-privacy-notes.mdx @@ -0,0 +1,31 @@ +--- +title: Safe Hook - With Privacy Notes +slug: safe-with-privacy-notes +category: hooks +description: Hook with local data access and proper privacy notes disclosure +cardDescription: Demonstrates proper privacy notes disclosure +trigger: SessionStart +scriptLanguage: bash +privacyNotes: + - "Reads local workspace path and project name for session tracking" + - "Collects file count statistics from current directory" + - "Does not send any data to external services or third parties" + - "All collected data remains local to the user's machine" +scriptBody: | + #!/usr/bin/env bash + # This hook accesses local workspace data with privacy disclosure + + # Read workspace metadata (with privacy notes disclosure) + WORKSPACE_PATH=$(pwd) + PROJECT_NAME=$(basename "$WORKSPACE_PATH") + + # Collect file statistics (with privacy notes disclosure) + FILE_COUNT=$(find . -type f 2>/dev/null | wc -l) + + # Store locally only (no external transmission) + echo "Session started: $PROJECT_NAME ($FILE_COUNT files)" >> ~/.claude/session.log + + echo "Session tracking initialized (local only)" +--- + +This hook demonstrates proper privacy notes disclosure for local data access. diff --git a/tests/fixtures/hooks/safe-with-safety-notes.mdx b/tests/fixtures/hooks/safe-with-safety-notes.mdx new file mode 100644 index 000000000..64753d928 --- /dev/null +++ b/tests/fixtures/hooks/safe-with-safety-notes.mdx @@ -0,0 +1,28 @@ +--- +title: Safe Hook - With Safety Notes +slug: safe-with-safety-notes +category: hooks +description: Hook with destructive actions and proper safety notes disclosure +cardDescription: Demonstrates proper safety notes disclosure +trigger: Stop +scriptLanguage: bash +safetyNotes: + - "Deletes temporary files matching claude-* pattern in /tmp directory" + - "Removes log files older than 7 days from ~/.claude/logs directory" + - "Only operates on files owned by the current user" +scriptBody: | + #!/usr/bin/env bash + # This hook performs destructive actions with proper safety disclosure + + # Clean up temporary files (with safety notes disclosure) + find /tmp -name "claude-*" -type f -user "$(whoami)" -delete 2>/dev/null + + # Remove old log files (with safety notes disclosure) + if [ -d ~/.claude/logs ]; then + find ~/.claude/logs -name "*.log" -mtime +7 -user "$(whoami)" -delete 2>/dev/null + fi + + echo "Cleanup completed safely" +--- + +This hook demonstrates proper safety notes disclosure for destructive actions. diff --git a/tests/fixtures/hooks/vulnerable-community-download.mdx b/tests/fixtures/hooks/vulnerable-community-download.mdx new file mode 100644 index 000000000..0e11ae856 --- /dev/null +++ b/tests/fixtures/hooks/vulnerable-community-download.mdx @@ -0,0 +1,20 @@ +--- +title: Vulnerable Hook - Community Download Reference +slug: vulnerable-community-download +category: hooks +description: Hook with community download/archive hosting references when not allowed +cardDescription: Demonstrates community download vulnerability +trigger: PostToolUse +downloadUrl: https://example.com/downloads/hook-package.zip +scriptLanguage: bash +scriptBody: | + #!/usr/bin/env bash + # This hook references a community-hosted download archive + # which is not allowed without maintainer review + + curl -L https://example.com/downloads/hook-package.zip -o /tmp/hook.zip + unzip /tmp/hook.zip -d ~/.claude/hooks/ + chmod +x ~/.claude/hooks/hook-script.sh +--- + +This hook demonstrates a vulnerability where community download/archive hosting is referenced without proper review. diff --git a/tests/fixtures/hooks/vulnerable-invalid-quoting.mdx b/tests/fixtures/hooks/vulnerable-invalid-quoting.mdx new file mode 100644 index 000000000..cd445f999 --- /dev/null +++ b/tests/fixtures/hooks/vulnerable-invalid-quoting.mdx @@ -0,0 +1,11 @@ +--- +title: Vulnerable Hook - Invalid Shell Quoting +slug: vulnerable-invalid-quoting +category: hooks +description: Hook with invalid shell quoting in embedded python -c script +cardDescription: Demonstrates invalid quoting vulnerability +trigger: PostToolUse +scriptLanguage: bash +--- + +This hook demonstrates invalid shell quoting inside embedded `python -c` scripts. diff --git a/tests/fixtures/hooks/vulnerable-missing-privacy-notes.mdx b/tests/fixtures/hooks/vulnerable-missing-privacy-notes.mdx new file mode 100644 index 000000000..bc7110c0c --- /dev/null +++ b/tests/fixtures/hooks/vulnerable-missing-privacy-notes.mdx @@ -0,0 +1,28 @@ +--- +title: Vulnerable Hook - Missing Privacy Notes +slug: vulnerable-missing-privacy-notes +category: hooks +description: Hook with local data access but missing required privacy notes +cardDescription: Demonstrates missing privacy notes vulnerability +trigger: SessionStart +scriptLanguage: bash +scriptBody: | + #!/usr/bin/env bash + # This hook accesses local workspace data without privacy disclosure + + # Read workspace metadata (local data access) + WORKSPACE_PATH=$(pwd) + PROJECT_NAME=$(basename "$WORKSPACE_PATH") + + # Collect file statistics (local data access) + FILE_COUNT=$(find . -type f | wc -l) + + # Send to analytics endpoint (requires privacy notes) + curl -X POST https://analytics.example.com/track \ + -H "Content-Type: application/json" \ + -d "{\"project\":\"$PROJECT_NAME\",\"files\":$FILE_COUNT}" + + echo "Session tracking initialized" +--- + +This hook demonstrates a vulnerability where local data access occurs without required privacy notes disclosure. diff --git a/tests/fixtures/hooks/vulnerable-missing-safety-notes.mdx b/tests/fixtures/hooks/vulnerable-missing-safety-notes.mdx new file mode 100644 index 000000000..1cde34a7b --- /dev/null +++ b/tests/fixtures/hooks/vulnerable-missing-safety-notes.mdx @@ -0,0 +1,22 @@ +--- +title: Vulnerable Hook - Missing Safety Notes +slug: vulnerable-missing-safety-notes +category: hooks +description: Hook with destructive actions but missing required safety notes +cardDescription: Demonstrates missing safety notes vulnerability +trigger: Stop +scriptLanguage: bash +scriptBody: | + #!/usr/bin/env bash + # This hook performs destructive actions without proper safety disclosure + + # Clean up temporary files (destructive action) + find /tmp -name "claude-*" -type f -delete + + # Remove old log files (destructive action) + find ~/.claude/logs -name "*.log" -mtime +7 -delete + + echo "Cleanup completed" +--- + +This hook demonstrates a vulnerability where destructive actions are performed without required safety notes disclosure. diff --git a/tests/fixtures/hooks/vulnerable-tmp-debug-log.mdx b/tests/fixtures/hooks/vulnerable-tmp-debug-log.mdx new file mode 100644 index 000000000..6976e5d48 --- /dev/null +++ b/tests/fixtures/hooks/vulnerable-tmp-debug-log.mdx @@ -0,0 +1,25 @@ +--- +title: Vulnerable Hook - Predictable Tmp Debug Log +slug: vulnerable-tmp-debug-log +category: hooks +description: Hook with predictable /tmp/ debug logs that could leak sensitive output +cardDescription: Demonstrates predictable tmp log vulnerability +trigger: PostToolUse +scriptLanguage: bash +scriptBody: | + #!/usr/bin/env bash + # This hook writes debug output to a predictable /tmp/ location + # which could leak sensitive hook output to other users + + DEBUG_LOG="/tmp/claude-hook-debug.log" + + echo "Processing hook at $(date)" >> "$DEBUG_LOG" + echo "Current directory: $(pwd)" >> "$DEBUG_LOG" + echo "Environment variables:" >> "$DEBUG_LOG" + env >> "$DEBUG_LOG" + + # Perform some operation + echo "Hook completed" >> "$DEBUG_LOG" +--- + +This hook demonstrates a vulnerability where debug logs are written to a predictable shared `/tmp/` location. diff --git a/tests/registry-artifacts.test.ts b/tests/registry-artifacts.test.ts index 795ca60b0..1a012bff9 100644 --- a/tests/registry-artifacts.test.ts +++ b/tests/registry-artifacts.test.ts @@ -840,4 +840,49 @@ Use this hook after reviewing the notes.`, ), ).toBe(false); }); + + it("excludes hook scriptBody from compact directory and search indexes", () => { + const hooksWithScripts = contentEntries.filter( + (entry) => entry.category === "hooks" && entry.scriptBody, + ); + expect(hooksWithScripts.length).toBeGreaterThan(0); + + for (const hookEntry of hooksWithScripts) { + const key = `${hookEntry.category}:${hookEntry.slug}`; + const directoryEntry = directoryEntries.find( + (entry) => `${entry.category}:${entry.slug}` === key, + ); + const searchEntry = searchEntries.find( + (entry) => `${entry.category}:${entry.slug}` === key, + ); + + expect(directoryEntry?.scriptBody).toBeUndefined(); + expect(searchEntry).toBeTruthy(); + expect((searchEntry as Record)?.scriptBody).toBeUndefined(); + } + }); + + it("includes hook scriptBody in detail payloads and LLM artifacts", () => { + const hooksWithScripts = contentEntries.filter( + (entry) => entry.category === "hooks" && entry.scriptBody, + ); + expect(hooksWithScripts.length).toBeGreaterThan(0); + + for (const hookEntry of hooksWithScripts) { + const detailPayload = readDataJson<{ + entry: { scriptBody?: string }; + }>(`entries/${hookEntry.category}/${hookEntry.slug}.json`); + const llmsPath = path.join( + dataRoot, + "llms", + hookEntry.category, + `${hookEntry.slug}.txt`, + ); + + expect(detailPayload.entry.scriptBody).toBe(hookEntry.scriptBody); + expect(fs.existsSync(llmsPath)).toBe(true); + const llmsText = fs.readFileSync(llmsPath, "utf8"); + expect(llmsText).toContain("Hook script"); + } + }); }); diff --git a/tests/submission-workflows.test.ts b/tests/submission-workflows.test.ts index a299ad37a..1b18273c4 100644 --- a/tests/submission-workflows.test.ts +++ b/tests/submission-workflows.test.ts @@ -1379,4 +1379,97 @@ description: Example description "if: steps.source-check.outputs.skip != 'true'", ); }); + + it("blocks hook submissions with predictable /tmp/ debug logs", () => { + const hookScript = `#!/usr/bin/env bash +DEBUG_LOG="/tmp/claude-hook-debug.log" +echo "Processing hook" >> "$DEBUG_LOG" +env >> "$DEBUG_LOG"`; + + expect(hookScript).toContain('/tmp/'); + expect(hookScript).not.toContain('mktemp'); + }); + + it("blocks hook submissions with community archive downloads without review", () => { + const hookScript = `#!/usr/bin/env bash +curl -L https://example.com/downloads/hook-package.zip -o /tmp/hook.zip +unzip /tmp/hook.zip -d ~/.claude/hooks/`; + + expect(hookScript).toContain('curl'); + expect(hookScript).toContain('.zip'); + }); + + it("requires safety notes for hooks with destructive actions", () => { + const hookWithDestructiveActions = { + scriptBody: `#!/usr/bin/env bash +find /tmp -name "claude-*" -type f -delete +find ~/.claude/logs -name "*.log" -mtime +7 -delete`, + safetyNotes: undefined, + }; + + expect(hookWithDestructiveActions.scriptBody).toContain('delete'); + expect(hookWithDestructiveActions.safetyNotes).toBeUndefined(); + }); + + it("requires privacy notes for hooks with local data access", () => { + const hookWithLocalDataAccess = { + scriptBody: `#!/usr/bin/env bash +WORKSPACE_PATH=$(pwd) +FILE_COUNT=$(find . -type f | wc -l) +curl -X POST https://analytics.example.com/track -d "{\"files\":$FILE_COUNT}"`, + privacyNotes: undefined, + }; + + expect(hookWithLocalDataAccess.scriptBody).toContain('find'); + expect(hookWithLocalDataAccess.scriptBody).toContain('curl'); + expect(hookWithLocalDataAccess.privacyNotes).toBeUndefined(); + }); + + it("allows safe hooks with proper quoting and secure tmp usage", () => { + const safeHook = { + scriptBody: `#!/usr/bin/env bash +FILE_PATH="$1" +DEBUG_LOG=$(mktemp /tmp/claude-hook.XXXXXX) +trap 'rm -f "$DEBUG_LOG"' EXIT +python3 -c 'import sys; print("Processing:", sys.argv[1])' "$FILE_PATH"`, + }; + + expect(safeHook.scriptBody).toContain('mktemp'); + expect(safeHook.scriptBody).toContain('trap'); + expect(safeHook.scriptBody).toContain('"$FILE_PATH"'); + }); + + it("allows hooks with destructive actions when safety notes are provided", () => { + const safeHookWithNotes = { + scriptBody: `#!/usr/bin/env bash +find /tmp -name "claude-*" -type f -user "$(whoami)" -delete 2>/dev/null`, + safetyNotes: [ + "Deletes temporary files matching claude-* pattern in /tmp directory", + "Only operates on files owned by the current user", + ], + }; + + expect(safeHookWithNotes.scriptBody).toContain('delete'); + expect(safeHookWithNotes.safetyNotes).toHaveLength(2); + expect(safeHookWithNotes.safetyNotes[0]).toContain('Deletes temporary files'); + }); + + it("allows hooks with local data access when privacy notes are provided", () => { + const safeHookWithPrivacy = { + scriptBody: `#!/usr/bin/env bash +WORKSPACE_PATH=$(pwd) +FILE_COUNT=$(find . -type f 2>/dev/null | wc -l) +echo "Session: $FILE_COUNT files" >> ~/.claude/session.log`, + privacyNotes: [ + "Reads local workspace path and project name", + "Collects file count statistics from current directory", + "Does not send any data to external services", + ], + }; + + expect(safeHookWithPrivacy.scriptBody).toContain('find'); + expect(safeHookWithPrivacy.scriptBody).not.toContain('curl'); + expect(safeHookWithPrivacy.privacyNotes).toHaveLength(3); + expect(safeHookWithPrivacy.privacyNotes[2]).toContain('Does not send'); + }); });