Files
AriWeb-Suite/modules/webmaker/hooks/guard.js
T

64 lines
5.9 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
'use strict';
// ============================================================================
// Claude Code PreToolUse 훅 (v1.6.31, 요구 3) — AI 작업을 사이트 폴더 안으로 가둔다. hooks/guard.sh 가 이 파일을 node 로 실행한다.
//
// 1.5.x 까지는 Write/Edit 계열의 file_path 만 검사해, Read/Glob/Grep 으로 다른 사이트·설정 파일·인증 정보를 읽거나 Bash 로 무엇이든 할 수 있었다(검토 S1).
// 이제 다음을 막는다(차단 = 종료 코드 2, Claude 가 그 도구 호출을 거부한다):
// - Read/Write/Edit/MultiEdit/NotebookEdit/Glob/Grep: 대상 경로가 SITE_DIR(실경로) 밖이거나 숨김 파일/폴더(.git·.claude 등; .refs 는 읽기만 허용)이거나 심볼릭 링크가 밖을 가리키면 차단
// - Bash: 상위 경로(..), 절대 경로(/etc /proc /sys /root /home /var /srv /opt /usr/bin …), 네트워크·원격·프로세스·권한 명령(curl wget nc ssh scp sftp git systemctl kill sudo su chmod 777 …),
// 환경·비밀 파일 이름(session.secret config.json users.json credentials vault setup.token .ssh .env), 셸 치환으로 경로를 숨기는 패턴($( ` ) 을 차단. cwd 밖으로 cd 금지
// - WebFetch/WebSearch: 사설·루프백·링크로컬 IP, localhost, .local/.internal 호스트, http(s) 외 스킴 차단 (SSRF — 관리 화면·내부 서비스 접근)
// 입력: stdin 에 Claude 가 주는 JSON { tool_name, tool_input }. 환경 SITE_DIR 은 lib/claude.js 가 넣는다.
// ============================================================================
const fs = require('fs'); const path = require('path'); const net = require('net');
let input = ''; try { input = fs.readFileSync(0, 'utf8'); } catch (_) {}
let payload = {}; try { payload = JSON.parse(input || '{}'); } catch (_) { process.stderr.write('guard: invalid JSON\n'); process.exit(2); }
const tool = String(payload.tool_name || ''); const ti = payload.tool_input || {};
const siteDir = process.env.SITE_DIR; if (!siteDir) { process.stderr.write('guard: SITE_DIR not set\n'); process.exit(2); }
const siteReal = (() => { try { return fs.realpathSync(siteDir); } catch (_) { return path.resolve(siteDir); } })();
const deny = (m) => { process.stderr.write(`Blocked by site guard: ${m}\n`); process.exit(2); };
const insideSite = (p) => { const rel = path.relative(siteReal, p); return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); };
/** 경로 하나 검사 (쓰기 도구는 숨김 전부 금지, 읽기 도구는 .refs 만 허용) */
function checkPath(raw, write) {
if (!raw) return;
const abs = path.resolve(siteReal, String(raw));
if (!insideSite(abs)) deny(`path outside the site folder: ${raw}`);
const rel = path.relative(siteReal, abs); const parts = rel ? rel.split(path.sep) : [];
if (parts.some((s, i) => s.startsWith('.') && !(i === 0 && s === '.refs' && !write))) deny(`hidden file/folder not allowed: ${raw}`);
// 심볼릭 링크가 밖을 가리키면 차단 (존재하는 가장 깊은 조상까지 실경로 확인)
let probe = abs; while (probe !== siteReal && !fs.existsSync(probe)) probe = path.dirname(probe);
try { const real = fs.realpathSync(probe); if (!insideSite(real)) deny(`symlink escapes the site folder: ${raw}`); } catch (_) {}
}
if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) { checkPath(ti.file_path || ti.notebook_path, true); process.exit(0); }
if (tool === 'Read') { checkPath(ti.file_path, false); process.exit(0); }
if (tool === 'Glob' || tool === 'Grep') { if (ti.path) checkPath(ti.path, false); const pat = String(ti.pattern || ''); if (/^\/|^~|\.\.\//.test(pat)) deny(`pattern must stay inside the site folder: ${pat}`); process.exit(0); }
if (tool === 'WebFetch' || tool === 'WebSearch') {
const u = String(ti.url || ti.query || ''); if (tool === 'WebSearch') process.exit(0);
let url; try { url = new URL(u); } catch (_) { deny(`invalid url: ${u}`); }
if (!/^https?:$/.test(url.protocol)) deny(`scheme not allowed: ${url.protocol}`);
const h = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.localhost')) deny(`internal host not allowed: ${h}`);
if (net.isIP(h)) { const v = net.isIP(h); const priv = v === 4 ? /^(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.|22[4-9]\.|2[3-5]\d\.)/.test(h) : /^(::1$|::$|fc|fd|fe[89ab])/.test(h); if (priv) deny(`private address not allowed: ${h}`); }
process.exit(0);
}
if (tool === 'Bash') {
const cmd = String(ti.command || '');
const bad = [
[/(^|[\s;&|(`'"])\.\.(\/|$)/, 'parent directory reference'],
[/(^|[\s=;&|(`'"])\/(etc|proc|sys|root|home|var|srv|opt|boot|dev|run|tmp|usr\/local|mnt|media)(\/|\s|$)/, 'absolute system path'],
[/(^|[\s;&|(])(curl|wget|nc|ncat|netcat|ssh|scp|sftp|rsync|telnet|ftp|socat|nmap|dig|nslookup)(\s|$)/, 'network command'],
[/(^|[\s;&|(])(sudo|su|chown|chgrp|mount|umount|systemctl|service|kill|pkill|killall|reboot|shutdown|crontab|at|nohup|setsid|docker|podman|certbot|nginx|apt|apt-get|dpkg|snap|pip|npm\s+(i|install|publish)|npx)(\s|$)/, 'privileged/process command'],
[/(^|[\s;&|(])git\s+(push|remote|fetch|pull|clone|config)(\s|$)/, 'git remote operation'],
[/(^|[\s;&|(])chmod\s+(-R\s+)?[0-7]*7[0-7]*(\s|$)/, 'world-writable chmod'],
[/(^|[\s;&|(])cd\s+(\/|~|\.\.)/, 'cd outside the site folder'],
[/\b(session\.secret|config\.json|users\.json|settings\.json|credentials|credentials\.json|vault|setup\.token|deploy-keys|deploy-targets|\.ssh|\.env|id_rsa|id_ed25519|authorized_keys|known_hosts)\b/i, 'sensitive file name'],
[/\$\(|`|\$\{|<\(|>\(/, 'command substitution'],
[/\b(env|printenv|export)\b\s*$/m, 'environment dump'],
[/(^|\s)~\//, 'home directory path'],
];
for (const [re, why] of bad) if (re.test(cmd)) deny(`${why} in shell command`);
process.exit(0);
}
process.exit(0);