diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8b0b551..a2ffc44f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,9 @@ jobs: ENABLE_NETWORK_TESTS: "1" run: npm run test + - name: Run docs security checks + run: npm run security:docs + artifact-preview: if: github.event_name == 'pull_request' runs-on: ubuntu-latest @@ -220,6 +223,9 @@ jobs: ENABLE_NETWORK_TESTS: "1" run: npm run test + - name: Run docs security checks + run: npm run security:docs + - name: Build catalog run: npm run catalog diff --git a/apps/web-app/refresh-skills-plugin.js b/apps/web-app/refresh-skills-plugin.js index ff17a99a..90d6c527 100644 --- a/apps/web-app/refresh-skills-plugin.js +++ b/apps/web-app/refresh-skills-plugin.js @@ -4,6 +4,7 @@ import path from 'path'; import { execSync } from 'child_process'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; +import crypto from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -44,6 +45,57 @@ function isGitAvailable() { return _gitAvailable; } +function normalizeHost(hostValue = '') { + return String(hostValue).trim().toLowerCase().replace(/^\[|\]$/g, ''); +} + +function isLoopbackHost(hostname) { + const host = normalizeHost(hostname); + return host === 'localhost' + || host === '::1' + || host.startsWith('127.'); +} + +function getRequestHost(req) { + const hostHeader = req.headers?.host || ''; + + if (!hostHeader) { + return ''; + } + + try { + return new URL(`http://${hostHeader}`).hostname; + } catch { + return normalizeHost(hostHeader); + } +} + +function isDevLoopbackRequest(req) { + return isLoopbackHost(getRequestHost(req)); +} + +function isTokenAuthorized(req) { + const expectedToken = (process.env.SKILLS_REFRESH_TOKEN || '').trim(); + + if (!expectedToken) { + return true; + } + + const providedToken = req.headers?.['x-skills-refresh-token']; + if (typeof providedToken !== 'string' || !providedToken) { + return false; + } + + const expected = Buffer.from(expectedToken); + const provided = Buffer.from(providedToken); + + if (expected.length !== provided.length) { + return false; + } + + return crypto.timingSafeEqual(expected, provided); +} + /** Run a git command in the project root. */ function git(cmd) { return execSync(`git ${cmd}`, { cwd: ROOT_DIR, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); @@ -272,12 +324,30 @@ export default function refreshSkillsPlugin() { return; } + if (!req.headers?.host || !req.headers?.origin) { + res.statusCode = 400; + res.end(JSON.stringify({ success: false, error: 'Missing request host or origin headers' })); + return; + } + + if (!isDevLoopbackRequest(req)) { + res.statusCode = 403; + res.end(JSON.stringify({ success: false, error: 'Only local loopback requests are allowed' })); + return; + } + if (!isAllowedDevOrigin(req)) { res.statusCode = 403; res.end(JSON.stringify({ success: false, error: 'Forbidden origin' })); return; } + if (!isTokenAuthorized(req)) { + res.statusCode = 401; + res.end(JSON.stringify({ success: false, error: 'Invalid or missing refresh token' })); + return; + } + try { let result; diff --git a/apps/web-app/src/__tests__/refresh-skills-plugin.security.test.js b/apps/web-app/src/__tests__/refresh-skills-plugin.security.test.js index 2f3b4ae1..4830b099 100644 --- a/apps/web-app/src/__tests__/refresh-skills-plugin.security.test.js +++ b/apps/web-app/src/__tests__/refresh-skills-plugin.security.test.js @@ -62,6 +62,7 @@ async function loadRefreshHandler() { describe('refresh-skills plugin security', () => { beforeEach(() => { execSync.mockClear(); + delete process.env.SKILLS_REFRESH_TOKEN; }); it('rejects GET requests for the sync endpoint', async () => { @@ -95,4 +96,87 @@ describe('refresh-skills plugin security', () => { expect(res.statusCode).toBe(403); }); + + it('rejects non-loopback POST requests for the sync endpoint', async () => { + const handler = await loadRefreshHandler(); + const req = { + method: 'POST', + headers: { + host: '192.168.1.1:5173', + origin: 'http://192.168.1.1:5173', + }, + }; + const res = createResponse(); + + await handler(req, res); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).error).toMatch('loopback'); + }); + + it('rejects token-less requests when refresh token is configured', async () => { + process.env.SKILLS_REFRESH_TOKEN = 'super-secret-token'; + const handler = await loadRefreshHandler(); + const req = { + method: 'POST', + headers: { + host: 'localhost:5173', + origin: 'http://localhost:5173', + }, + }; + const res = createResponse(); + + await handler(req, res); + + expect(res.statusCode).toBe(401); + }); + + it('accepts local requests by default without a refresh token', async () => { + const handler = await loadRefreshHandler(); + const req = { + method: 'POST', + headers: { + host: 'localhost:5173', + origin: 'http://localhost:5173', + }, + }; + const res = createResponse(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + }); + + it('accepts IPv6 loopback requests by default without a refresh token', async () => { + const handler = await loadRefreshHandler(); + const req = { + method: 'POST', + headers: { + host: '[::1]:5173', + origin: 'http://[::1]:5173', + }, + }; + const res = createResponse(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).success).toBe(true); + }); + + it('rejects POST requests with missing host/origin headers', async () => { + const handler = await loadRefreshHandler(); + const req = { + method: 'POST', + headers: { + host: 'localhost:5173', + }, + }; + const res = createResponse(); + + await handler(req, res); + + expect(res.statusCode).toBe(400); + }); }); diff --git a/package.json b/package.json index 5c174ec2..ad249797 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "sync:all": "npm run sync:metadata && npm run chain", "catalog": "node tools/scripts/build-catalog.js", "build": "npm run chain && npm run catalog", + "security:docs": "node tools/scripts/tests/docs_security_content.test.js", "pr:preflight": "node tools/scripts/pr_preflight.js", "release:preflight": "node tools/scripts/release_workflow.js preflight", "release:prepare": "node tools/scripts/release_workflow.js prepare", diff --git a/skills/apify-actor-development/SKILL.md b/skills/apify-actor-development/SKILL.md index 3df9de06..0dc09cf7 100644 --- a/skills/apify-actor-development/SKILL.md +++ b/skills/apify-actor-development/SKILL.md @@ -3,6 +3,8 @@ name: apify-actor-development description: "Develop, debug, and deploy Apify Actors - serverless cloud programs for web scraping, automation, and data processing. Use when creating new Actors, modifying existing ones, or troubleshooting Acto..." --- + + # Apify Actor Development **Important:** Before you begin, fill in the `generatedBy` property in the meta section of `.actor/actor.json`. Replace it with the tool and model you're currently using, such as "Claude Code with Claude Sonnet 4.5". This helps Apify monitor and improve AGENTS.md for specific AI tools and models. diff --git a/skills/audit-skills/SKILL.md b/skills/audit-skills/SKILL.md index 0422605b..621e5fd8 100644 --- a/skills/audit-skills/SKILL.md +++ b/skills/audit-skills/SKILL.md @@ -10,6 +10,8 @@ tags: [security, audit, skills, bundles, cross-platform] tools: [claude, gemini, gpt, llama, mistral, etc] --- + + # Audit Skills (Premium Universal Security) ## Overview diff --git a/skills/bun-development/SKILL.md b/skills/bun-development/SKILL.md index 284159ee..8753cea8 100644 --- a/skills/bun-development/SKILL.md +++ b/skills/bun-development/SKILL.md @@ -6,6 +6,8 @@ source: community date_added: "2026-02-27" --- + + # ⚡ Bun Development > Fast, modern JavaScript/TypeScript development with the Bun runtime, inspired by [oven-sh/bun](https://github.com/oven-sh/bun). diff --git a/skills/claude-code-expert/SKILL.md b/skills/claude-code-expert/SKILL.md index fedfa637..79fca373 100644 --- a/skills/claude-code-expert/SKILL.md +++ b/skills/claude-code-expert/SKILL.md @@ -18,6 +18,8 @@ tools: - codex-cli --- + + # CLAUDE CODE EXPERT - Potencia Maxima ## Overview diff --git a/skills/cloud-penetration-testing/SKILL.md b/skills/cloud-penetration-testing/SKILL.md index 4bf29a6e..adad3bca 100644 --- a/skills/cloud-penetration-testing/SKILL.md +++ b/skills/cloud-penetration-testing/SKILL.md @@ -7,6 +7,8 @@ author: zebbern date_added: "2026-02-27" --- + + # Cloud Penetration Testing ## Purpose diff --git a/skills/evolution/SKILL.md b/skills/evolution/SKILL.md index 45448567..9dc6fbda 100644 --- a/skills/evolution/SKILL.md +++ b/skills/evolution/SKILL.md @@ -7,6 +7,8 @@ description: | hooks, hook system, auto-trigger, skill... --- + + # Makepad Skills Evolution This skill enables makepad-skills to self-improve continuously during development. diff --git a/skills/linkerd-patterns/SKILL.md b/skills/linkerd-patterns/SKILL.md index 5e825d15..be0b9637 100644 --- a/skills/linkerd-patterns/SKILL.md +++ b/skills/linkerd-patterns/SKILL.md @@ -6,6 +6,8 @@ source: community date_added: "2026-02-27" --- + + # Linkerd Patterns Production patterns for Linkerd service mesh - the lightweight, security-first service mesh for Kubernetes. diff --git a/skills/linux-privilege-escalation/SKILL.md b/skills/linux-privilege-escalation/SKILL.md index 03c94396..d427678e 100644 --- a/skills/linux-privilege-escalation/SKILL.md +++ b/skills/linux-privilege-escalation/SKILL.md @@ -7,6 +7,8 @@ author: zebbern date_added: "2026-02-27" --- + + # Linux Privilege Escalation ## Purpose diff --git a/skills/varlock/SKILL.md b/skills/varlock/SKILL.md index f110bf91..63bdffda 100644 --- a/skills/varlock/SKILL.md +++ b/skills/varlock/SKILL.md @@ -4,6 +4,8 @@ description: Secure environment variable management with Varlock. Use when handl --- 1.0.0 --- + + # Varlock Security Skill Secure-by-default environment variable management for Claude Code sessions. @@ -431,4 +433,4 @@ Add these to your package.json: --- *Last updated: December 22, 2025* -*Secure-by-default environment management for Claude Code* \ No newline at end of file +*Secure-by-default environment management for Claude Code* diff --git a/tools/scripts/tests/docs_security_content.test.js b/tools/scripts/tests/docs_security_content.test.js index a3d953dd..2d303510 100644 --- a/tools/scripts/tests/docs_security_content.test.js +++ b/tools/scripts/tests/docs_security_content.test.js @@ -1,21 +1,171 @@ -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); -const repoRoot = path.resolve(__dirname, "../..", ".."); +const repoRoot = path.resolve(__dirname, '../..', '..'); const apifySkill = fs.readFileSync( - path.join(repoRoot, "skills", "apify-actorization", "SKILL.md"), - "utf8", + path.join(repoRoot, 'skills', 'apify-actorization', 'SKILL.md'), + 'utf8', ); const audioExample = fs.readFileSync( - path.join(repoRoot, "skills", "audio-transcriber", "examples", "basic-transcription.sh"), - "utf8", + path.join(repoRoot, 'skills', 'audio-transcriber', 'examples', 'basic-transcription.sh'), + 'utf8', ); -assert.strictEqual(/\|\s*(bash|sh)\b/.test(apifySkill), false, "SKILL.md must not recommend pipe-to-shell installs"); -assert.strictEqual(/\|\s*iex\b/i.test(apifySkill), false, "SKILL.md must not recommend PowerShell pipe-to-iex installs"); -assert.strictEqual(/apify login -t\b/.test(apifySkill), false, "SKILL.md must not put tokens on the command line"); +function findSkillFiles(skillsRoot) { + const files = []; + const queue = [skillsRoot]; -assert.match(audioExample, /python3 << 'EOF'/, "audio example should use a quoted heredoc for Python"); -assert.match(audioExample, /AUDIO_FILE_ENV/, "audio example should pass shell variables through the environment"); + while (queue.length > 0) { + const current = queue.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + + if (entry.isDirectory()) { + queue.push(fullPath); + continue; + } + + if (entry.isFile() && entry.name === 'SKILL.md') { + files.push(fullPath); + } + } + } + + return files; +} + +function parseAllowlist(content) { + const allowAllRe = //i; + const explicitRe = //gi; + const allow = new Set(); + + if (allowAllRe.test(content)) { + allow.add('all'); + return allow; + } + + let match; + while ((match = explicitRe.exec(content)) !== null) { + const raw = match[1] || ''; + raw + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .forEach((value) => { + allow.add(value.toLowerCase().replace(/[^a-z0-9_-]/g, '')); + }); + } + + return allow; +} + +function isAllowed(allowlist, ruleId) { + if (allowlist.has('all')) { + return true; + } + + const normalized = ruleId.toLowerCase().replace(/[^a-z0-9_-]/g, ''); + + return allowlist.has(normalized) + || allowlist.has(normalized.replace(/[-_]/g, '')) + || allowlist.has(`allow${normalized}`) + || allowlist.has(`risk${normalized}`); +} + +const rules = [ + { + id: 'curl-pipe-bash', + message: 'curl ... | bash|sh', + regex: /\bcurl\b[^\n]*\|\s*(?:bash|sh)\b/i, + }, + { + id: 'wget-pipe-sh', + message: 'wget ... | sh', + regex: /\bwget\b[^\n]*\|\s*sh\b/i, + }, + { + id: 'irm-pipe-iex', + message: 'irm ... | iex', + regex: /\birm\b[^\n]*\|\s*iex\b/i, + }, + { + id: 'commandline-token', + message: 'command-line token arguments', + regex: /\s(?:--token|--api[_-]?(?:key|token)|--access[_-]?token|--auth(?:entication)?[_-]?token|--secret|--api[_-]?secret|--refresh[_-]?token)\s+['\"]?([A-Za-z0-9._=\-:+/]{16,})['\"]?/i, + }, +]; + +function collectSkillFiles(basePaths) { + const files = new Set(); + + for (const basePath of basePaths) { + if (!fs.existsSync(basePath)) { + continue; + } + + for (const filePath of findSkillFiles(basePath)) { + files.add(filePath); + } + } + + return [...files]; +} + +const rootsToScan = [path.join(repoRoot, 'skills')]; +if ((process.env.DOCS_SECURITY_INCLUDE_PUBLIC || '').trim() === '1') { + rootsToScan.push(path.join(repoRoot, 'apps/web-app/public/skills')); +} + +const skillFiles = collectSkillFiles(rootsToScan); + +assert.ok(skillFiles.length > 0, 'Expected SKILL.md files in configured scan roots'); + +const violations = []; +const seen = new Set(); + +function addViolation(relativePath, lineNumber, rule) { + const key = `${relativePath}:${lineNumber}:${rule.id}`; + if (seen.has(key)) { + return; + } + + seen.add(key); + violations.push(`${relativePath}:${lineNumber}: ${rule.message}`); +} + +for (const filePath of skillFiles) { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + const allowlist = parseAllowlist(content); + const relativePath = path.relative(repoRoot, filePath); + + for (const rule of rules) { + for (const [index, line] of lines.entries()) { + if (!rule.regex.test(line)) { + continue; + } + + if (isAllowed(allowlist, rule.id)) { + continue; + } + + addViolation(relativePath, index + 1, rule); + rule.regex.lastIndex = 0; + } + } +} + +assert.strictEqual(violationCount(violations), 0, violations.join('\n')); +assert.match(audioExample, /python3 << 'EOF'/, 'audio example should use a quoted heredoc for Python'); +assert.match(audioExample, /AUDIO_FILE_ENV/, 'audio example should pass shell variables through the environment'); +assert.strictEqual(/\|\s*(bash|sh)\b/.test(apifySkill), false, 'SKILL.md must not recommend pipe-to-shell installs'); +assert.strictEqual(/\|\s*iex\b/i.test(apifySkill), false, 'SKILL.md must not recommend PowerShell pipe-to-iex installs'); +assert.strictEqual(/apify login -t\b/.test(apifySkill), false, 'SKILL.md must not put tokens on the command line'); + +function violationCount(list) { + return list.length; +} diff --git a/tools/scripts/tests/run-test-suite.js b/tools/scripts/tests/run-test-suite.js index c5b53ef2..1e7f5b9b 100644 --- a/tools/scripts/tests/run-test-suite.js +++ b/tools/scripts/tests/run-test-suite.js @@ -11,6 +11,7 @@ const LOCAL_TEST_COMMANDS = [ [path.join(TOOL_TESTS, "jetski_gemini_loader.test.js")], [path.join(TOOL_TESTS, "validate_skills_headings.test.js")], [path.join(TOOL_TESTS, "workflow_contracts.test.js")], + [path.join(TOOL_TESTS, "docs_security_content.test.js")], [path.join(TOOL_SCRIPTS, "run-python.js"), path.join(TOOL_TESTS, "test_validate_skills_headings.py")], ]; const NETWORK_TEST_COMMANDS = [