ยามความปลอดภัย: บล็อกคำสั่ง Bash ทำลายระบบก่อนรัน สแกนหา secret ที่หลุด โค้ดเสี่ยง และ package.json ที่น่าสงสัย

ยามความปลอดภัยของโปรเจกต์ บล็อกคำสั่ง Bash ที่ทำลายระบบก่อน Claude จะรันจริง (เช่น rm -rf /, fork bomb, mkfs, curl ... | sh) เตือนเมื่อ Claude เขียน secret ลงในโค้ด และสแกนทั้งโปรเจกต์หา API key ที่หลุด โค้ดเสี่ยงโดนโจมตี (eval, SQL ต่อสตริง, innerHTML) และ package.json ที่น่าสงสัย (install script แปลกๆ, ชื่อแพ็กเกจคล้ายของดัง) รวมถึงไฟล์ลับที่ถูก commit เข้า git
| คำสั่ง | ทำอะไร | |
|---|---|---|
/guard scan [path] | สแกนทั้งหมด: secret, โค้ดเสี่ยง, package.json, git | |
/guard secrets / code / deps | สแกนเฉพาะเรื่อง | |
/guard git | ตรวจไฟล์ลับใน git และ .gitignore | |
/guard report / critical | ดูรายงานล่าสุด / เฉพาะระดับร้ายแรง | |
/guard export | บันทึก cyber-guard-report.json (ซ่อนค่า secret แล้ว) | |
/guard watch <ไฟล์> / unwatch <ไฟล์> | เฝ้าไฟล์สำคัญ ถ้า Claude จะแก้จะเด้งเตือน | |
| `/guard bash on\ | off` | เปิด/ปิดการบล็อกคำสั่ง Bash อันตราย (ค่าเริ่มต้น: เปิด) |
/guard test <คำสั่ง> | ลองดูว่าคำสั่งนี้จะถูกบล็อกไหม (ไม่รันจริง) |
อัตโนมัติ (ไม่ต้องสั่ง):
.env แทนไหม (ถ้าไม่มีคนตอบ เช่น claude -p จะเขียนแล้วเตือน)psql / mysql / sqlite3 (DROP, TRUNCATE, DELETE/UPDATE ไม่มี WHERE) → ถามยืนยันก่อน ไม่มีคนตอบ = ไม่รัน/plugin install cyber-guard-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
$.fs$.process.run(['git', 'ls-files'])next, ตอบ { deny } และมี .catch ไว้ ถ้าตัวตรวจพังจะปฏิเสธไว้ก่อนcontext บอก Claude เมื่อเขียน secret ลงไฟล์hooks/register.tsx 410 lines1/**
2 * ============================================================================
3 * MOD 15: Cyber Guard TH - ยามความปลอดภัยของโปรเจกต์ (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: เหตุการณ์ Supply Chain Attack (event-stream, ua-parser-js) และ OWASP Top 10
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. บล็อกคำสั่ง Bash ที่ทำลายระบบก่อนรันจริง (rm -rf /, fork bomb, mkfs, curl | sh ...)
9 * 2. ถามก่อนเมื่อ Claude จะเขียน secret (API key, private key) ลงไฟล์ เสนอให้ใช้ .env แทน
10 * 2.1 ถามก่อนรัน SQL ทำลายข้อมูลผ่าน psql / mysql / sqlite3 (DROP, TRUNCATE, DELETE/UPDATE ไม่มี WHERE)
11 * 3. /guard scan สแกนทั้งโปรเจกต์หา secret ที่หลุด และโค้ดเสี่ยง (eval, SQL ต่อสตริง, innerHTML)
12 * 4. /guard deps ตรวจ package.json: install script น่าสงสัย, ชื่อแพ็กเกจคล้ายของดัง (typosquatting)
13 * 5. /guard git ตรวจว่าไฟล์ลับ (.env, *.pem) ถูก commit เข้า git หรือไม่
14 * 6. เฝ้าไฟล์สำคัญ: ถ้า Claude จะแก้ไฟล์ที่เฝ้าไว้ จะเด้งเตือน
15 *
16 * ข้อจำกัด: ตรวจด้วย regex จึงมีทั้งจับผิดและหลุดได้ ใช้เป็นด่านแรก ไม่ใช่แทนเครื่องมือ security เต็มรูปแบบ
17 */
18
19import type { EngineInterface, FsEntry, Register } from 'claude-code'
20
21type Severity = 'critical' | 'high' | 'medium'
22type Category = 'secret' | 'code' | 'deps' | 'git'
23
24interface Finding {
25 severity: Severity
26 category: Category
27 title: string
28 file?: string
29 line?: number
30 hint: string
31}
32
33const ICON: Record<Severity, string> = { critical: '🔴', high: '🟠', medium: '🟡' }
34const WEIGHT: Record<Severity, number> = { critical: 10, high: 5, medium: 2 }
35
36// ==========================================================================
37// บทที่ 1: รูปแบบของ secret ที่หลุดบ่อย (ตามรูปแบบที่แต่ละเจ้ากำหนด)
38// ==========================================================================
39const SECRET_PATTERNS: { name: string; regex: RegExp }[] = [
40 { name: 'AWS Access Key', regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ },
41 { name: 'GitHub Token', regex: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/ },
42 { name: 'Anthropic API Key', regex: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
43 { name: 'OpenAI API Key', regex: /\bsk-(proj-)?[A-Za-z0-9_-]{32,}/ },
44 { name: 'Stripe Live Key', regex: /\b(sk|rk)_live_[A-Za-z0-9]{20,}/ },
45 { name: 'Google API Key', regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
46 { name: 'Slack Token', regex: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
47 { name: 'LINE Channel Token', regex: /\b[A-Za-z0-9+/]{150,}={0,2}(?=["'\s]|$)/ },
48 { name: 'Private Key', regex: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/ },
49]
50
51// ==========================================================================
52// บทที่ 2: โค้ดที่เสี่ยงโดนโจมตี (injection) หรือดูเหมือนมัลแวร์
53// ==========================================================================
54const CODE_PATTERNS: { name: string; regex: RegExp; severity: Severity; hint: string }[] = [
55 { name: 'eval() / new Function()', regex: /\beval\s*\(|new\s+Function\s*\(/, severity: 'high', hint: 'รันข้อความเป็นโค้ด ถ้าข้อความมาจากผู้ใช้ = Code Injection' },
56 { name: 'SQL ต่อสตริง', regex: /(SELECT|INSERT|UPDATE|DELETE)\b[^;\n]*["'`]\s*\+\s*\w|(SELECT|INSERT|UPDATE|DELETE)\b[^;\n]*\$\{/i, severity: 'high', hint: 'ใช้ parameterized query (?, $1) แทนการต่อสตริง' },
57 { name: 'innerHTML จากตัวแปร', regex: /\.innerHTML\s*=\s*[^'"`\s]/, severity: 'medium', hint: 'เสี่ยง XSS ใช้ textContent หรือ sanitize ก่อน' },
58 { name: 'exec() ประกอบคำสั่งจากตัวแปร', regex: /\bexec(Sync)?\s*\(\s*(`[^`]*\$\{|[^'"`)]*\+)/, severity: 'high', hint: 'เสี่ยง Command Injection ใช้ execFile/spawn ส่ง argument แยก' },
59 { name: 'ดาวน์โหลดแล้วรันทันที', regex: /(curl|wget)[^|\n]*\|\s*(sudo\s+)?(ba)?sh\b/, severity: 'critical', hint: 'ดาวน์โหลดสคริปต์มารันโดยไม่ตรวจ อาจเป็นมัลแวร์' },
60 { name: 'Reverse shell', regex: /\bnc\s+(-\w+\s+)*-e\s|bash\s+-i\s*>&\s*\/dev\/tcp\//, severity: 'critical', hint: 'รูปแบบที่ใช้เปิดช่องให้คนอื่นควบคุมเครื่อง' },
61 { name: 'Crypto miner', regex: /\b(coinhive|cryptonight|xmrig|stratum\+tcp)\b/i, severity: 'critical', hint: 'พบคำที่เกี่ยวกับโปรแกรมขุดเหรียญ' },
62]
63
64// ==========================================================================
65// บทที่ 3: คำสั่ง Bash ที่ต้องบล็อกก่อนรัน (ทำลายระบบ ย้อนกลับไม่ได้)
66// ==========================================================================
67const DANGEROUS_COMMANDS: { name: string; regex: RegExp }[] = [
68 { name: 'ลบทั้งเครื่องหรือทั้ง home', regex: /\brm\s+(-\w*[rf]\w*\s+)+(--no-preserve-root\s+)?(\/|~|\$HOME|\/\*|~\/\*|[A-Za-z]:\\?)(\s|$)/ },
69 { name: 'Fork bomb', regex: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/ },
70 { name: 'ฟอร์แมตดิสก์', regex: /\bmkfs(\.\w+)?\s|\bformat\s+[A-Za-z]:/i },
71 { name: 'เขียนทับดิสก์ตรงๆ', regex: /\bdd\s+[^\n]*of=\/dev\/(sd|nvme|disk|hd)|>\s*\/dev\/(sd|nvme|disk)[a-z0-9]*/ },
72 { name: 'ดาวน์โหลดแล้วรันทันที', regex: /(curl|wget)[^|\n]*\|\s*(sudo\s+)?(ba|z)?sh\b|\biex\s*\(\s*(irm|iwr|Invoke-WebRequest|New-Object\s+Net\.WebClient)/i },
73 { name: 'เปิดสิทธิ์ทั้งเครื่อง', regex: /\bchmod\s+-R\s+0?777\s+\/(\s|$)/ },
74]
75
76function dangerOf(command: string): string | undefined {
77 return DANGEROUS_COMMANDS.find(d => d.regex.test(command))?.name
78}
79
80// SQL ที่ทำลายข้อมูล ส่งผ่าน client ฐานข้อมูล (psql, mysql, sqlite3 ...) - ถามก่อน ไม่บล็อกทันที
81const SQL_CLIENT = /\b(?:psql|mysql|mariadb|sqlite3|sqlcmd|cockroach\s+sql|clickhouse-client|duckdb)\b|\b(?:prisma|knex|sequelize)\b.*\b(?:db\s+execute|raw)\b/i
82
83function sqlRiskOf(command: string): string | undefined {
84 if (!SQL_CLIENT.test(command)) return undefined
85 const sql = command.replace(/\s+/g, ' ')
86 if (/\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i.test(sql)) return 'DROP ลบตาราง/ฐานข้อมูลทั้งก้อน'
87 if (/\bTRUNCATE\b/i.test(sql)) return 'TRUNCATE ลบข้อมูลทุกแถว'
88 // DELETE/UPDATE ที่ไม่มี WHERE ตามหลังในประโยคเดียวกัน
89 for (const m of sql.matchAll(/\b(DELETE\s+FROM|UPDATE)\b([^;"']*)/gi)) {
90 if (!/\bWHERE\b/i.test(m[2] ?? '')) return `${(m[1] ?? '').toUpperCase()} ไม่มี WHERE = ทุกแถว`
91 }
92 return undefined
93}
94
95// ซ่อนค่า secret เวลาแสดง: เหลือ 4 ตัวแรก
96function mask(value: string): string {
97 return value.length <= 8 ? '****' : `${value.slice(0, 4)}…(${value.length} ตัว)`
98}
99
100function secretsIn(text: string): { name: string; value: string; line: number }[] {
101 const found: { name: string; value: string; line: number }[] = []
102 text.split('\n').forEach((line, i) => {
103 for (const p of SECRET_PATTERNS) {
104 const m = p.regex.exec(line)
105 // LINE token pattern กว้างมาก นับเฉพาะบรรทัดที่มีคำว่า token/secret/key
106 if (m && (p.name !== 'LINE Channel Token' || /token|secret|key/i.test(line))) found.push({ name: p.name, value: m[0], line: i + 1 })
107 }
108 })
109 return found
110}
111
112// ==========================================================================
113// บทที่ 4: ตรวจ package.json - install script และ typosquatting
114// ==========================================================================
115const POPULAR = ['react', 'react-dom', 'lodash', 'express', 'axios', 'moment', 'vue', 'next', 'typescript', 'webpack', 'chalk', 'commander', 'dotenv', 'jquery', 'request', 'cross-env', 'eslint', 'prettier', 'mongoose', 'socket.io', 'electron', 'discord.js', 'colors', 'ua-parser-js', 'event-stream', 'node-fetch', 'uuid', 'debug', 'body-parser', 'cors']
116
117// ระยะห่างของคำ (Levenshtein): ต่างกันกี่ตัวอักษร
118function distance(a: string, b: string): number {
119 const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array<number>(b.length).fill(0)])
120 for (let j = 1; j <= b.length; j++) (dp[0] as number[])[j] = j
121 for (let i = 1; i <= a.length; i++) {
122 for (let j = 1; j <= b.length; j++) {
123 const row = dp[i] as number[]
124 const prev = dp[i - 1] as number[]
125 row[j] = Math.min((prev[j] ?? 0) + 1, (row[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1))
126 }
127 }
128 return dp[a.length]?.[b.length] ?? 0
129}
130
131function checkPackageJson(text: string, file: string): Finding[] {
132 const findings: Finding[] = []
133 let pkg: { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
134 try {
135 pkg = JSON.parse(text)
136 } catch {
137 return [{ severity: 'medium', category: 'deps', title: 'package.json อ่านไม่ได้ (JSON ผิดรูปแบบ)', file, hint: 'ตรวจ syntax ของไฟล์' }]
138 }
139 for (const hook of ['preinstall', 'install', 'postinstall', 'prepare']) {
140 const script = pkg.scripts?.[hook]
141 if (script && /curl|wget|node\s+-e|powershell|\|\s*(ba)?sh|base64|https?:\/\//i.test(script)) {
142 findings.push({ severity: 'critical', category: 'deps', title: `install script น่าสงสัย (${hook}): ${script.slice(0, 80)}`, file, hint: 'สคริปต์นี้รันเองตอน npm install แบบที่ event-stream ถูกโจมตี' })
143 }
144 }
145 const deps = { ...pkg.dependencies, ...pkg.devDependencies }
146 for (const [name, version] of Object.entries(deps)) {
147 const lookalike = POPULAR.find(p => p !== name && distance(p, name) === 1 && name.length >= 4)
148 if (lookalike) findings.push({ severity: 'high', category: 'deps', title: `"${name}" ชื่อคล้าย "${lookalike}"`, file, hint: 'อาจเป็น typosquatting ตรวจว่าตั้งใจใช้แพ็กเกจนี้จริงไหม' })
149 if (/^(git\+|github:|https?:)/.test(version)) findings.push({ severity: 'medium', category: 'deps', title: `"${name}" ติดตั้งจาก ${version.slice(0, 60)}`, file, hint: 'ติดตั้งจาก git/URL ตรงๆ ไม่ผ่าน npm registry ตรวจแหล่งที่มา' })
150 }
151 return findings
152}
153
154// ==========================================================================
155// บทที่ 5: เดินสแกนไฟล์ทั้งโปรเจกต์
156// ==========================================================================
157const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'vendor', '.venv', 'venv', '__pycache__', 'coverage'])
158const TEXT_FILE = /\.(js|jsx|ts|tsx|mjs|cjs|py|rb|php|go|java|cs|sh|ps1|env|json|ya?ml|toml|ini|cfg|conf|txt|md|html|vue|svelte|sql|tf)$|^\.env/i
159const MAX_FILES = 2000
160
161let projectRoot = ''
162let findings: Finding[] = []
163let lastScan = { files: 0, at: 0 }
164let watched: string[] = []
165let isBashGuardOn = true
166
167function sep(): string {
168 return projectRoot.includes('\\') ? '\\' : '/'
169}
170
171function relative(path: string): string {
172 return path.toLowerCase().startsWith(projectRoot.toLowerCase()) ? path.slice(projectRoot.length + 1) : path
173}
174
175async function scan($: EngineInterface, root: string, only?: Category): Promise<void> {
176 const queue = [root]
177 let files = 0
178 const found: Finding[] = []
179 while (queue.length > 0 && files < MAX_FILES) {
180 const dir = queue.shift() ?? root
181 let entries: FsEntry[]
182 try {
183 entries = await $.fs.list(dir)
184 } catch {
185 continue
186 }
187 for (const entry of entries) {
188 const path = `${dir}${sep()}${entry.name}`
189 if (entry.kind === 'dir') {
190 if (!SKIP_DIRS.has(entry.name)) queue.push(path)
191 continue
192 }
193 if (!TEXT_FILE.test(entry.name) || entry.size > 1024 * 1024) continue
194 files++
195 const text = await $.fs.read(path).catch(() => '')
196 const file = relative(path)
197 if (!only || only === 'secret') {
198 for (const s of secretsIn(text)) found.push({ severity: 'critical', category: 'secret', title: `${s.name}: ${mask(s.value)}`, file, line: s.line, hint: 'ย้ายไปไว้ใน env และ revoke key นี้ทันที (ถือว่าหลุดแล้ว)' })
199 }
200 if ((!only || only === 'code') && !/\.(md|txt|json)$/i.test(entry.name)) {
201 text.split('\n').forEach((line, i) => {
202 for (const p of CODE_PATTERNS) if (p.regex.test(line)) found.push({ severity: p.severity, category: 'code', title: p.name, file, line: i + 1, hint: p.hint })
203 })
204 }
205 if ((!only || only === 'deps') && entry.name === 'package.json') found.push(...checkPackageJson(text, file))
206 }
207 }
208 findings = [...findings.filter(f => only && f.category !== only), ...found]
209 lastScan = { files, at: await $.clock.now() }
210}
211
212// ==========================================================================
213// บทที่ 6: ตรวจ git - ไฟล์ลับถูก commit ไปแล้วหรือยัง
214// ==========================================================================
215async function checkGit($: EngineInterface): Promise<string> {
216 const run = await $.process.run(['git', 'ls-files'], { timeoutMs: 15_000 }).catch(() => undefined)
217 if (!run || run.exitCode !== 0) return '⚪ ไม่ใช่ git repo (ข้ามการตรวจ git)'
218 const tracked = run.stdout.split('\n').filter(f => /(^|\/)\.env(\.|$)|\.(pem|key|p12|pfx)$|(^|\/)id_(rsa|ed25519|ecdsa)$/.test(f) && !/\.env\.(example|sample|template)$/.test(f))
219 findings = findings.filter(f => f.category !== 'git')
220 for (const f of tracked) findings.push({ severity: 'critical', category: 'git', title: `ไฟล์ลับอยู่ใน git: ${f}`, file: f, hint: 'git rm --cached แล้วเพิ่มใน .gitignore และ revoke ทุก key ในไฟล์ (ถ้า push ไปแล้ว ประวัติยังมีอยู่)' })
221 const ignore = await $.fs.read(`${projectRoot}${sep()}.gitignore`).catch(() => '')
222 if (!/^\s*\.env/m.test(ignore)) findings.push({ severity: 'medium', category: 'git', title: '.gitignore ไม่มี .env', file: '.gitignore', hint: 'เพิ่มบรรทัด .env กันเผลอ commit' })
223 return tracked.length ? `🔴 พบไฟล์ลับใน git ${tracked.length} ไฟล์` : '✅ ไม่พบไฟล์ลับใน git'
224}
225
226// ==========================================================================
227// บทที่ 7: Risk Score และรายงาน
228// ==========================================================================
229function riskLevel(score: number): string {
230 if (score >= 20) return '🔴 CRITICAL'
231 if (score >= 10) return '🟠 HIGH'
232 if (score >= 5) return '🟡 MEDIUM'
233 return '✅ SAFE'
234}
235
236function report(list: Finding[], title: string): string {
237 const score = list.reduce((s, f) => s + WEIGHT[f.severity], 0)
238 const lines = [`🛡️ ${title}`, '----------------------------------------', `${riskLevel(score)} | Risk Score ${score} | สแกน ${lastScan.files} ไฟล์ | พบ ${list.length} ปัญหา`]
239 for (const sev of ['critical', 'high', 'medium'] as const) {
240 const group = list.filter(f => f.severity === sev)
241 if (group.length === 0) continue
242 lines.push('', `${ICON[sev]} ${sev.toUpperCase()} (${group.length}):`)
243 for (const f of group.slice(0, 15)) {
244 lines.push(` • ${f.title}${f.file ? ` (${f.file}${f.line ? `:${f.line}` : ''})` : ''}`, ` 💡 ${f.hint}`)
245 }
246 if (group.length > 15) lines.push(` ... และอีก ${group.length - 15} รายการ (/guard export ดูทั้งหมด)`)
247 }
248 if (list.length === 0) lines.push('', '✅ ไม่พบปัญหา')
249 return lines.join('\n')
250}
251
252const HELP = `🛡️ Cyber Guard TH (Mod 15)
253========================================
254/guard scan [path] สแกนทั้งหมด (secret + โค้ดเสี่ยง + package.json)
255/guard secrets สแกนหา secret อย่างเดียว
256/guard code สแกนหาโค้ดเสี่ยงอย่างเดียว
257/guard deps ตรวจ package.json
258/guard git ตรวจไฟล์ลับใน git และ .gitignore
259/guard report | critical | export | clear
260/guard watch <ไฟล์> | unwatch <ไฟล์> | watch เฝ้าไฟล์สำคัญ
261/guard bash on|off เปิด/ปิดการบล็อกคำสั่ง Bash อันตราย + ถามก่อนรัน SQL ทำลายข้อมูล (ค่าเริ่มต้น: เปิด)
262/guard test <คำสั่ง> ลองดูว่าคำสั่งนี้จะถูกบล็อกไหม (ไม่รันจริง)`
263
264export const register: Register = on => {
265 on('session.start', async ($, e, next) => {
266 projectRoot = e.cwd.replace(/[\\/]$/, '')
267 findings = []
268 const savedWatch = await $.store.get('watched')
269 watched = Array.isArray(savedWatch) ? (savedWatch as string[]) : []
270 isBashGuardOn = (await $.store.get('bashGuard')) !== false
271 await $.command.register({ name: 'guard', description: 'ยามความปลอดภัย: สแกน secret โค้ดเสี่ยง และบล็อกคำสั่งอันตราย (/guard help)', argumentHint: 'scan|secrets|deps|git|report|watch|bash' })
272 return next(e)
273 })
274
275 // ==========================================================================
276 // บทที่ 8: ด่านกั้นก่อนรัน Bash - ตัดสินก่อน next ถ้าอันตรายตอบ { deny } เลย
277 // ==========================================================================
278 // .catch: ถ้าตัวตรวจพังเองก่อนเรียก next ให้ปฏิเสธไว้ก่อน (ปลอดภัยกว่าปล่อยผ่าน)
279 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
280 const danger = isBashGuardOn ? dangerOf(e.command) : undefined
281 if (danger) {
282 $.ui.toast(`🛡️ บล็อกคำสั่งอันตราย: ${danger}`)
283 return { deny: `${$.plugin.name} blocked this command (${danger}). Ask the user to run it themselves if it is really intended.` }
284 }
285 return next(e)
286 }).catch(($, e, next) => (next.called ? next(e) : { deny: `${$.plugin.name}: its command check failed, so the command was not run.` }))
287
288 // SQL อันตราย: ถามผ่านหน้าต่างของ Claude Code ก่อน (ไม่มีคนตอบ = ไม่รัน)
289 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
290 const risk = isBashGuardOn ? sqlRiskOf(e.command) : undefined
291 if (!risk) return next(e)
292 const answer = await $.ui.ask(`🗄️ Claude จะรัน SQL ที่ ${risk} ยืนยันไหม?`, { header: 'SQL Guard', options: ['รันเลย', 'ยกเลิก'] }).catch(() => '')
293 if (answer === 'รันเลย') return next(e)
294 return { deny: `${$.plugin.name}: SQL นี้ (${risk}) ต้องให้ผู้ใช้ยืนยันก่อน และผู้ใช้ไม่ได้ยืนยัน อย่าลองวิธีอื่นที่ลบข้อมูลแบบเดียวกัน ให้ถามผู้ใช้หรือเพิ่ม WHERE ที่เจาะจง` }
295 })
296
297 // ==========================================================================
298 // บทที่ 9: ถามก่อนเขียน secret ลงไฟล์ และเตือนเมื่อแตะไฟล์ที่เฝ้าไว้
299 // ==========================================================================
300 on('tool.call', async ($, e, next) => {
301 const target = e.tool === 'Write' || e.tool === 'Edit' ? e.file_path : undefined
302 if (!target) return next(e)
303
304 const rel = relative(target)
305 if (watched.some(w => rel === w || rel.startsWith(w.replace(/[\\/]$/, '') + sep()))) {
306 $.ui.toast(`👀 Claude กำลังแก้ไฟล์ที่เฝ้าไว้: ${rel}`)
307 }
308
309 const written = e.tool === 'Write' ? e.content : e.tool === 'Edit' ? e.new_string : ''
310 const secrets = /(^|[\\/])\.env(\.|$)/.test(target) ? [] : secretsIn(written)
311 if (secrets.length > 0) {
312 // ถามก่อนเขียน: ถ้าไม่มีใครตอบ (เช่น claude -p) ปล่อยเขียนแล้วเตือนแบบเดิม
313 const answer = await $.ui
314 .ask(`🥺 อุ๊ยๆ Claude จะเขียน ${secrets[0]?.name} ลงใน ${rel} ตรงๆ ให้ย้ายไปใช้ .env แทนไหม?`, { header: 'Secret', options: ['ให้ใช้ .env แทน (แนะนำ)', 'เขียนไปเลย (เป็นค่าตัวอย่าง/ทดสอบ)'] })
315 .catch(() => '')
316 if (answer.startsWith('ให้ใช้')) {
317 return { deny: `${$.plugin.name}: ผู้ใช้ไม่ให้เขียน ${secrets.map(s => s.name).join(', ')} ลงในโค้ด ให้อ่านค่าจาก environment variable แทน (เช่น process.env.X / os.environ["X"]) แล้วใส่ค่าจริงในไฟล์ .env ที่อยู่ใน .gitignore จากนั้นเขียนไฟล์นี้ใหม่` }
318 }
319 if (answer) return next(e)
320 }
321
322 const ran = await next(e)
323 if (secrets.length > 0 && ran.deny === undefined && !ran.isError) {
324 $.ui.toast(`🔑 พบ ${secrets[0]?.name} ถูกเขียนลง ${rel} ควรย้ายไปไว้ใน .env`)
325 const note = `[cyber-guard-th] ${rel} now contains what looks like a ${secrets.map(s => s.name).join(', ')}. Move it to an environment variable or a git-ignored .env file instead of source code.`
326 return { ...ran, context: [...(ran.context ?? []), note] }
327 }
328 return ran
329 })
330
331 on('command.run', { command: 'guard' }, async ($, e) => {
332 const [sub = 'help', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
333 const arg = rest.join(' ')
334 const target = arg && arg !== '.' ? (/^([A-Za-z]:|[\\/])/.test(arg) ? arg : `${projectRoot}${sep()}${arg}`) : projectRoot
335
336 switch (sub) {
337 case 'scan': {
338 const stat = await $.fs.stat(target).catch(() => undefined)
339 if (!stat) return { text: `❌ ไม่พบ ${arg}` }
340 if (stat.kind === 'file') {
341 const text = await $.fs.read(target)
342 const file = relative(target)
343 findings = [
344 ...secretsIn(text).map(s => ({ severity: 'critical' as const, category: 'secret' as const, title: `${s.name}: ${mask(s.value)}`, file, line: s.line, hint: 'ย้ายไปไว้ใน env และ revoke key นี้' })),
345 ...text.split('\n').flatMap((line, i) => CODE_PATTERNS.filter(p => p.regex.test(line)).map(p => ({ severity: p.severity, category: 'code' as const, title: p.name, file, line: i + 1, hint: p.hint }))),
346 ]
347 lastScan = { files: 1, at: await $.clock.now() }
348 } else {
349 findings = []
350 await scan($, target)
351 await checkGit($)
352 }
353 return { text: report(findings, `Cyber Guard รายงาน: ${relative(target) || '.'}`) }
354 }
355 case 'secrets':
356 case 'code':
357 case 'deps': {
358 const category: Category = sub === 'secrets' ? 'secret' : sub
359 await scan($, target, category)
360 return { text: report(findings.filter(f => f.category === category), `ผลสแกน ${sub}`) }
361 }
362 case 'git':
363 return { text: `${await checkGit($)}\n\n${report(findings.filter(f => f.category === 'git'), 'ผลตรวจ git')}` }
364 case 'report':
365 return { text: lastScan.at ? report(findings, 'Cyber Guard รายงานล่าสุด') : 'ยังไม่ได้สแกน ลอง /guard scan' }
366 case 'critical':
367 return { text: report(findings.filter(f => f.severity === 'critical'), 'เฉพาะ CRITICAL (ต้องแก้ทันที)') }
368 case 'export': {
369 const path = `${projectRoot}${sep()}cyber-guard-report.json`
370 await $.fs.write(path, JSON.stringify({ scannedAt: new Date(lastScan.at).toISOString(), files: lastScan.files, findings }, null, 2) + '\n')
371 return { text: `💾 บันทึกแล้ว: ${path} (ค่า secret ถูกซ่อนไว้แล้ว)` }
372 }
373 case 'clear':
374 findings = []
375 lastScan = { files: 0, at: 0 }
376 return { text: '🧹 ล้างรายงานแล้ว' }
377 case 'watch':
378 if (!arg) return { text: watched.length ? ['👀 ไฟล์ที่เฝ้าอยู่', ...watched.map(w => ` ${w}`)].join('\n') : 'ยังไม่ได้เฝ้าไฟล์ไหน (/guard watch .env)' }
379 watched = [...new Set([...watched, arg])]
380 await $.store.set('watched', watched)
381 return { text: `👀 เฝ้า ${arg} แล้ว ถ้า Claude จะแก้จะเด้งเตือน` }
382 case 'unwatch':
383 watched = watched.filter(w => w !== arg)
384 await $.store.set('watched', watched)
385 return { text: `🙈 เลิกเฝ้า ${arg} แล้ว` }
386 case 'bash':
387 if (arg !== 'on' && arg !== 'off') return { text: `การบล็อกคำสั่ง Bash อันตราย: ${isBashGuardOn ? 'เปิด' : 'ปิด'} (/guard bash on|off)` }
388 isBashGuardOn = arg === 'on'
389 await $.store.set('bashGuard', isBashGuardOn)
390 return { text: isBashGuardOn ? '🛡️ เปิดการบล็อกคำสั่งอันตรายแล้ว' : '⚠️ ปิดการบล็อกคำสั่งอันตรายแล้ว' }
391 case 'test': {
392 const danger = dangerOf(arg)
393 const sqlRisk = sqlRiskOf(arg)
394 return { text: danger ? `🔴 จะถูกบล็อก: ${danger}` : sqlRisk ? `🟠 จะถามยืนยันก่อน: ${sqlRisk}` : '✅ ไม่ถูกบล็อก (แต่ Claude Code ยังถามอนุญาตตามปกติ)' }
395 }
396 default:
397 return { text: HELP }
398 }
399 })
400}
401
402/**
403 * ========================================================================
404 * การบ้าน / ต่อยอด
405 * ========================================================================
406 * 1. ตรวจช่องโหว่ของ dependency จริงด้วย $.process.run(['npm', 'audit', '--json'])
407 * 2. สแกนประวัติ git ทั้งหมดหา secret ที่เคย commit ด้วย git log -p (ไฟล์ลบไปแล้วก็ยังอยู่ในประวัติ)
408 * 3. ตรวจ SQL ใน migration file ที่ Claude เขียนด้วย (ไม่ใช่แค่คำสั่ง Bash)
409 */
410