SLOPSHOPPER

cyber-guard-th

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

newguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cyber-guard-th
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ cyber-guard-th: 🛡️ Cyber Guard TH (Mod 15) ⎿ cyber-guard-th: ======================================== ⎿ cyber-guard-th: /guard scan [path] สแกนทั้งหมด (secret + โค้ดเสี่ยง + package.json) ⎿ cyber-guard-th: /guard secrets สแกนหา secret อย่างเดียว ⎿ cyber-guard-th: /guard code สแกนหาโค้ดเสี่ยงอย่างเดียว ⎿ cyber-guard-th: /guard deps ตรวจ package.json ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Mod 15: Cyber Guard TH

ยามความปลอดภัยของโปรเจกต์ บล็อกคำสั่ง 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 <คำสั่ง>ลองดูว่าคำสั่งนี้จะถูกบล็อกไหม (ไม่รันจริง)

อัตโนมัติ (ไม่ต้องสั่ง):

  • Claude จะเขียน API key / private key ลงไฟล์ → ถามก่อนว่าจะให้ใช้ .env แทนไหม (ถ้าไม่มีคนตอบ เช่น claude -p จะเขียนแล้วเตือน)
  • Claude จะรัน SQL ทำลายข้อมูลผ่าน 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. regex ของ secret แต่ละเจ้า (AWS, GitHub, Anthropic, OpenAI, Stripe, Google, Slack, private key)
  2. regex ของโค้ดเสี่ยงและรูปแบบมัลแวร์
  3. รายการคำสั่ง Bash ที่ทำลายระบบ
  4. ตรวจ package.json: install script และ typosquatting ด้วย Levenshtein distance
  5. เดินสแกนไฟล์ทั้งโปรเจกต์ด้วย $.fs
  6. ตรวจไฟล์ลับใน git ด้วย $.process.run(['git', 'ls-files'])
  7. คำนวณ Risk Score และทำรายงาน
  8. ทำด่านกั้น (guard) ที่ถูกต้อง: ตัดสินก่อน next, ตอบ { deny } และมี .catch ไว้ ถ้าตัวตรวจพังจะปฏิเสธไว้ก่อน
  9. ส่ง context บอก Claude เมื่อเขียน secret ลงไฟล์

หมายเหตุ

  • ตรวจด้วย regex จึงอาจจับผิดหรือหลุดได้ ใช้เป็นด่านแรก ไม่ใช่ตัวแทนของเครื่องมือ security เต็มรูปแบบ
  • การบล็อก Bash เป็นด่านเพิ่มเติม Claude Code ยังถามอนุญาตก่อนรันคำสั่งตามปกติ
  • ค่า secret ที่เจอจะแสดงแค่ 4 ตัวแรกเสมอ ทั้งในรายงานและไฟล์ export
Source 1 files
hooks/register.tsx 410 lines
1/**
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