ข้ามไปยังเนื้อหา
KoishiAI
EN
← สารบัญ

บทที่ 10 จาก 12 · Agentic Engineering — คู่มือปฏิบัติเขียนซอฟต์แวร์ระดับ Production ด้วย AI Agent

ความปลอดภัยของ Key, Token และ Secret ในงาน Agentic

หลักการเดียวคือ agent ไม่ควรต้องเห็นค่าของ secret เลย บทนี้ชำแหละช่องโหว่ของการเก็บ key ในไฟล์ .md แล้วพึ่ง permission แยก registry ออกจาก value วิธีฉีดค่าตอนรัน เก้าชั้นการป้องกัน hook ตรวจ secret สองทาง และ egress allowlist

หลักการเดียวที่ต้องจำจากทั้งบทนี้: agent ไม่ควรต้องเห็นค่าของ secret เลย มันต้องรู้แค่ว่า secret ชื่ออะไร ใช้ที่ไหน และหาได้จากที่ไหน ส่วนค่าจริงถูกฉีดเข้าไปตอนรัน โดยกระบวนการที่ agent ไม่ได้อยู่ในเส้นทางนั้น

10.1 ทบทวนวิธีที่ทีมส่วนใหญ่ใช้อยู่ — และช่องโหว่ของมัน

วิธีที่พบบ่อยคือเขียนไฟล์ .md หรือ .txt ที่มี key จริงไว้ในโปรเจกต์ แล้วตั้ง permission ในไฟล์ config ของ agent ว่าให้เฉพาะบางตัวอ่านได้ วิธีนี้ดีกว่าไม่ทำอะไรเลยมาก และเจตนาถูกต้อง แต่มันวางการป้องกันไว้ผิดชั้น

สมมติฐานของวิธีนี้ความจริง
”agent อื่นอ่านไม่ได้เพราะตั้ง deny ไว้”deny ทำงานที่ระดับ tool ของ agent ตัวนั้น ไม่ใช่ระดับ OS เครื่องมืออื่น สคริปต์อื่น หรือ agent ที่ config ต่างกันบนเครื่องเดียวกัน ยังอ่านไฟล์นั้นได้ตามปกติ
”deny Read บนไฟล์ = agent เข้าไม่ถึง”ถ้า agent รัน shell ได้ มันมีทางอ้อมอีกมาก เช่น grep ในไฟล์อื่น, printenv, อ่าน log, อ่าน backup, อ่าน git history, รันสคริปต์ที่อ่านไฟล์นั้นแทน denylist จึงเป็นด่านชะลอ ไม่ใช่กำแพง
”secret อยู่แค่บนเครื่องผม”ทันทีที่ค่านั้นเข้าไปอยู่ใน context window มันถูกส่งออกจากเครื่องไปยังผู้ให้บริการโมเดลแล้ว และมักถูกบันทึกไว้ใน transcript ในเครื่องด้วย
”ไฟล์อยู่ใน .gitignore แล้ว ปลอดภัย”.gitignore กัน commit ใหม่ ไม่ได้กันสิ่งที่เคย commit ไปแล้ว และไม่ได้กันการที่ agent คัดลอกค่านั้นไปวางในไฟล์อื่นที่ไม่ได้ ignore เช่น test fixture หรือคอมเมนต์
”เป็น dev key ไม่เป็นไร”จริงก็ต่อเมื่อ key นั้นแยกจาก production จริง เข้าถึงข้อมูลจริงไม่ได้จริง และหมดอายุเอง ซึ่งส่วนใหญ่ไม่เป็นเช่นนั้น

วิธีเดิมที่ปรับนิดเดียวแล้วดีขึ้นมาก

เก็บไฟล์ .md ต่อไป แต่ให้มันเป็น ทะเบียน secret ไม่ใช่ที่เก็บ secret โดยระบุชื่อตัวแปร ใช้ที่ไหน ใครเป็นเจ้าของ ขอได้จากที่ไหน และหมดอายุเมื่อไร แต่ไม่มีค่าจริงแม้แต่ตัวเดียว ไฟล์แบบนี้ commit เข้า git ได้ ให้ agent อ่านได้เต็มที่ และมีประโยชน์กับ agent มากกว่าเดิมด้วย เพราะมันบอกว่า “ต้องใช้ตัวไหน” ซึ่งเป็นสิ่งที่ agent ต้องรู้จริง ๆ ส่วนค่าจริง agent ไม่เคยต้องใช้

10.2 แยกให้ขาด: reference กับ value

# Secrets Registry
ไฟล์นี้ไม่มีค่าจริงของ secret ใด ๆ ถ้าคุณเห็นค่าจริงในไฟล์นี้ ถือว่าเป็นเหตุการณ์
ความปลอดภัย ให้หยุด แจ้งเจ้าของระบบ และหมุน key นั้นทันที

| ตัวแปร             | ใช้ที่ไหน             | ขอบเขต/สิทธิ์        | เจ้าของ  | อายุ    | ขอได้จาก             |
|--------------------|-----------------------|----------------------|----------|---------|----------------------|
| DATABASE_URL       | src/infra/db.py       | rw เฉพาะ dev schema  | platform | 90 วัน  | op://dev/pg/url      |
| TAVILY_API_KEY     | tools/web.py          | search อย่างเดียว     | you      | ไม่หมด  | op://dev/tavily/key  |
| STRIPE_SECRET_KEY  | src/infra/payments.py | test mode เท่านั้น    | payments | 30 วัน  | op://dev/stripe/test |
| GITHUB_TOKEN       | scripts/release.sh    | repo:read, no write  | platform | 7 วัน   | gh auth token        |

## กติกาสำหรับ agent
- ใช้ชื่อตัวแปรจากตารางนี้เท่านั้น อย่าคิดชื่อใหม่เอง
- อ่านค่าผ่าน os.environ[...] เสมอ ห้าม hardcode ห้ามเขียนค่าลงไฟล์
- ถ้าตัวแปรที่ต้องใช้ไม่มีในตารางนี้ ให้หยุดแล้วถาม อย่าสร้าง key ใหม่หรือหาค่าจากที่อื่น
- ถ้าโค้ดรันแล้วขึ้น KeyError เพราะไม่มีตัวแปร ให้รายงานชื่อตัวแปรที่ขาด
  อย่าพยายามหาค่ามาเติมเอง

## ห้ามใช้บนเครื่อง dev เด็ดขาด
- key ของ production ทุกชนิด
- credential ที่อ่านข้อมูลลูกค้าจริงได้
- token ที่มีสิทธิ์ write บน main หรือ deploy
# .env.template ← ไฟล์นี้อยู่ใน git, agent อ่านได้, บอกว่าต้องมีอะไรบ้าง
DATABASE_URL=
TAVILY_API_KEY=
STRIPE_SECRET_KEY=
GITHUB_TOKEN=

# .env ← ไฟล์นี้มีค่าจริง อยู่ใน .gitignore, agent อ่านไม่ได้ และไม่จำเป็นต้องอ่าน
# ทางที่ดีกว่าคือไม่มีไฟล์นี้เลย ให้ค่าฉีดเข้ามาตอนรันแทน

10.3 ฉีด secret ตอนรัน โดยไม่ให้ค่าค้างบนดิสก์

ถ้าค่าจริงไม่เคยอยู่บนดิสก์ ก็ไม่มีไฟล์ให้ใครอ่านผิดพลาด นี่คือการยกระดับที่ให้ผลมากที่สุดต่อความพยายามที่น้อยที่สุด

# --- 1Password CLI: ค่าอยู่ในวอลต์ ฉีดเข้า process ตอนรันเท่านั้น ---
# .env.template ใช้ syntax op:// แทนค่าจริง
#   TAVILY_API_KEY=op://dev/tavily/key
op run --env-file=.env.template -- uv run pytest
op run --env-file=.env.template -- ./scripts/verify.sh

# --- direnv: โหลดเมื่อ cd เข้าโฟลเดอร์ ออกแล้วหลุด ---
# .envrc (อยู่ใน .gitignore)
#   export TAVILY_API_KEY="$(op read op://dev/tavily/key)"
direnv allow

# --- sops + age: เก็บไฟล์เข้ารหัสใน git ได้ ถอดรหัสตอนรัน ---
sops -e .env.plain > .env.enc          # .env.enc commit ได้
sops exec-env .env.enc './scripts/verify.sh'

# --- cloud: ดึงตอนรัน ไม่เก็บบนเครื่อง ---
export DATABASE_URL=$(aws secretsmanager get-secret-value \
  --secret-id dev/db --query SecretString --output text)

# --- CI: ใช้ OIDC แลก credential ชั่วคราว แทน long-lived key ---
#   permissions: { id-token: write }
#   uses: aws-actions/configure-aws-credentials@v4
#   with: { role-to-assume: arn:aws:iam::...:role/ci, aws-region: ap-southeast-1 }

ทำไมวิธีนี้เข้ากับงาน agentic เป็นพิเศษ

เพราะ agent ทำงานผ่าน subprocess ที่สืบทอด environment ของ process แม่ เมื่อคุณรัน op run -- claude หรือ op run -- ./scripts/agent-loop.sh โค้ดที่ agent เขียนจะเรียก os.environ["TAVILY_API_KEY"] แล้วทำงานได้ปกติ โดยที่ตัว agent ไม่เคยเห็นค่านั้นในบทสนทนา ไม่มีไฟล์ให้มันอ่าน และค่าหายไปเมื่อ process จบ งานเดินได้เหมือนเดิมทุกประการ แต่ผิวสัมผัสที่ secret จะรั่วหดลงมาก

10.4 ชั้นการป้องกันที่ควรมีครบ

ชั้นมาตรการกันอะไร / ทำอย่างไร
L0ลดสิ่งที่ต้องปกป้องอย่าให้ credential ที่มีสิทธิ์จริงอยู่บนเครื่อง dev ตั้งแต่แรก ใช้ test mode, sandbox account, ข้อมูลปลอม
L1Scope + อายุสั้นtoken แบบ fine-grained, read-only, หมดอายุ 7–30 วัน ทำให้การรั่วมีเพดานความเสียหาย
L2ไม่ให้ค่าอยู่บนดิสก์ฉีดตอนรันด้วย op run / direnv / sops / secret manager
L3แยกสิทธิ์ระดับ OSรัน agent ในคอนเทนเนอร์หรือ user แยกที่อ่าน ~/.ssh, ~/.aws, ~/.config/gh ไม่ได้
L4Denylist ของ agentด่านชะลอที่จับความผิดพลาดโดยสุจริตได้ดี แต่ห้ามพึ่งเป็นด่านเดียว
L5คุมทางออกของข้อมูลallowlist โดเมนขาออก ทำให้ต่อให้ค่ารั่วเข้า context ก็ไม่มีที่ให้ส่งออก
L6ตรวจจับก่อน commitgitleaks ใน pre-commit และใน CI จับตอนที่ยังแก้ง่าย
L7Redaction ใน logfilter ที่ปิดบังค่าใน log และ error message ก่อนถูกเขียนหรือส่งออก
L8แผนหมุน keyสมมติว่ารั่วแล้วจะทำอะไร ใครกด ใช้เวลากี่นาที ซ้อมไว้ก่อน

10.5 การตั้ง permission ให้ agent (ชั้น L4)

{
  "permissions": {
    "deny": [
      "Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
      "Read(./**/*.pem)", "Read(./**/*.key)", "Read(./**/*.p12)",
      "Read(./**/credentials*)", "Read(./**/*service-account*.json)",
      "Read(~/.ssh/**)", "Read(~/.aws/**)", "Read(~/.config/gh/**)",
      "Read(~/.kube/**)", "Read(~/.docker/config.json)",
      "Read(~/.netrc)", "Read(~/.pgpass)", "Read(~/.npmrc)",
      "Bash(env)", "Bash(printenv:*)", "Bash(set)", "Bash(export:*)",
      "Bash(op:*)", "Bash(aws:*)", "Bash(gcloud:*)", "Bash(vault:*)",
      "Bash(gh auth token)", "Bash(docker inspect:*)",
      "Bash(kubectl get secret:*)", "Bash(journalctl:*)",
      "Bash(history)", "Bash(cat ~/.*)",
      "Bash(curl:*)", "Bash(wget:*)", "Bash(nc:*)", "Bash(ssh:*)",
      "Bash(scp:*)", "Bash(rsync:*)"
    ],
    "allow": [
      "Read(./src/**)", "Read(./tests/**)", "Read(./docs/**)",
      "Read(./.env.template)",
      "Edit(./src/**)", "Edit(./tests/**)",
      "Bash(uv run pytest:*)", "Bash(uv run ruff:*)", "Bash(uv run mypy:*)"
    ]
  }
}

เข้าใจข้อจำกัดของ denylist ให้ตรงกับความจริง

รายการข้างบนมีประโยชน์จริง แต่ประโยชน์หลักคือ กันความผิดพลาดโดยสุจริต agent ที่กำลังไล่หา config แล้วเผลอ cat .env จะถูกหยุด ซึ่งเกิดบ่อยกว่าที่คิด สิ่งที่มันกันไม่ได้คือทางอ้อมที่นับไม่ถ้วน ตราบใดที่ agent ยังรัน shell ได้และค่านั้นยังอ่านได้จากบัญชีผู้ใช้เดียวกัน ให้ถือ L4 เป็นชั้นเสริม และวางน้ำหนักจริงไว้ที่ L0–L3 ซึ่งทำให้ “ไม่มีอะไรให้อ่าน” ตั้งแต่ต้น

#!/usr/bin/env python3
"""ทำงานร่วมกับ guard_pretooluse.py
exit 0 = ผ่าน | exit 2 = บล็อกและส่งเหตุผลกลับให้ agent แก้ตัวเอง"""
import json, re, sys

data = json.load(sys.stdin)
tool = data.get("tool_name", "")
inp  = data.get("tool_input", {})

def block(msg):
    print(f"BLOCKED (secrets): {msg}", file=sys.stderr)
    sys.exit(2)

# --- รูปแบบของ secret ที่พบบ่อย ใช้ตรวจ "ค่าที่กำลังจะถูกเขียน" ---
SECRET_SHAPES = [
    (r"sk-[A-Za-z0-9]{20,}",                              "OpenAI-style key"),
    (r"sk-ant-[A-Za-z0-9\-_]{20,}",                       "Anthropic key"),
    (r"tvly-[A-Za-z0-9]{16,}",                            "Tavily key"),
    (r"gh[pousr]_[A-Za-z0-9]{30,}",                       "GitHub token"),
    (r"AKIA[0-9A-Z]{16}",                                 "AWS access key id"),
    (r"AIza[0-9A-Za-z\-_]{30,}",                          "Google API key"),
    (r"xox[baprs]-[A-Za-z0-9\-]{10,}",                    "Slack token"),
    (r"-----BEGIN [A-Z ]*PRIVATE KEY-----",               "private key"),
    (r"eyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.", "JWT"),
    (r"postgres(ql)?://[^:\s]+:[^@\s]+@",                 "DB URL ที่มีรหัสผ่าน"),
    (r"mongodb(\+srv)?://[^:\s]+:[^@\s]+@",               "MongoDB URI ที่มีรหัสผ่าน"),
]

# --- 1. กันการเขียนค่าจริงลงไฟล์ (ผิดพลาดที่พบบ่อยที่สุด) ---
if tool in ("Edit", "Write"):
    body = inp.get("new_string", "") or inp.get("content", "")
    for pattern, name in SECRET_SHAPES:
        if re.search(pattern, body):
            block(f"กำลังจะเขียน {name} ลงไฟล์ — ใช้ os.environ[...] แทน "
                  f"และถ้าค่านี้เป็นค่าจริง ให้แจ้งฉันเพื่อหมุน key ทันที")

# --- 2. กันคำสั่งที่ดึงค่า secret ออกมาทางอ้อม ---
if tool == "Bash":
    cmd = inp.get("command", "")
    LEAKY = [
        (r"\b(printenv|env)\b(?!\s*\|?\s*grep\s+-c)", "พิมพ์ environment ทั้งชุด"),
        (r"cat\s+.*\.env",                            "อ่านไฟล์ .env"),
        (r"\bhistory\b",                              "อ่าน shell history"),
        (r"(curl|wget|nc)\b.*\$\{?[A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)",
         "ส่งค่า secret ออกทางเครือข่าย"),
        (r"echo\s+.*\$\{?[A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)", "พิมพ์ค่า secret"),
        (r"git\s+log\s+.*-S",                         "ค้น secret ใน git history"),
    ]
    for pattern, why in LEAKY:
        if re.search(pattern, cmd, re.I):
            block(f"{why} | คำสั่ง: {cmd[:120]}")

    for pattern, name in SECRET_SHAPES:
        if re.search(pattern, cmd):
            block(f"พบ {name} ในคำสั่ง — ห้ามใส่ค่าจริงในคำสั่ง "
                  f"ให้อ้างผ่านตัวแปรแวดล้อมแทน")

sys.exit(0)

10.6 ตรวจจับก่อนขึ้น git (ชั้น L6)

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: detect-private-key
      - id: check-added-large-files
# ── STAGE 0 ของ verify.sh: ความปลอดภัย (รันก่อนสุด) ──
run_stage "secrets"      gitleaks protect --staged --redact -v
run_stage "secrets-diff" bash -c \
  'gitleaks detect --source . --redact --log-opts="${BASE_REF:-origin/main}..HEAD"'
run_stage "no-secret-files" bash -c \
  '! git ls-files | grep -Ei "(^|/)\.env$|\.pem$|\.key$|credentials"'

# ตรวจย้อนหลังทั้ง history หนึ่งครั้ง
gitleaks detect --source . -v

ถ้า agent เผลอเขียน key จริงลงไฟล์แล้ว commit ไปแล้ว

การลบไฟล์ใน commit ถัดไปไม่ได้ทำให้ key ปลอดภัย ค่ายังอยู่ใน git history และถ้า push ไปแล้ว ให้ถือว่าถูกเปิดเผยแล้วเสมอ ลำดับที่ถูกคือหมุน key ก่อน แล้วค่อยไปจัดการ history ทีหลัง ไม่ใช่ทางกลับกัน เพราะการล้าง history ใช้เวลาเป็นสิบนาที ส่วนการหมุน key ใช้เวลาไม่กี่นาทีและหยุดความเสียหายได้ทันที

10.7 Redaction ใน log และ error (ชั้น L7)

ช่องรั่วที่คนมองข้ามบ่อยที่สุดคือ error message เพราะเวลาโค้ดพัง agent จะ paste stack trace เข้าไปในบทสนทนาเพื่อวิเคราะห์ ซึ่งลาก connection string หรือ header ที่มี token เข้าไปด้วย

import logging, re

PATTERNS = [
    (re.compile(r"(sk-ant-|sk-|tvly-|gh[pousr]_)[A-Za-z0-9\-_]{8,}"), r"\1***"),
    (re.compile(r"(://[^:/\s]+:)[^@\s]+(@)"), r"\1***\2"),
    (re.compile(r"(?i)(authorization|x-api-key|cookie)(['\"]?\s*[:=]\s*['\"]?)[^\s'\",}]+"),
     r"\1\2***"),
    (re.compile(r"(?i)((?:api[_-]?key|token|secret|password)['\"]?\s*[:=]\s*['\"]?)[^\s'\",}]+"),
     r"\1***"),
]

class RedactFilter(logging.Filter):
    def filter(self, record):
        msg = record.getMessage()
        for pattern, repl in PATTERNS:
            msg = pattern.sub(repl, msg)
        record.msg, record.args = msg, ()
        return True

logging.getLogger().addFilter(RedactFilter())

# ทดสอบด้วย — ไม่ใช่แค่เขียนแล้วเชื่อว่าทำงาน
def test_redacts_db_url_password():
    out = _apply("postgres://app:hunter2@db:5432/x")
    assert "hunter2" not in out

10.8 Egress control — ตัดปลายทางของการรั่ว (ชั้น L5)

นี่คือมาตรการที่ให้ผลสูงที่สุดในกรณีที่แย่ที่สุด ถ้า agent ถูกหลอกด้วย prompt injection จากหน้าเว็บหรือ issue ให้ส่งข้อมูลออก แต่เครือข่ายอนุญาตปลายทางไว้เพียงไม่กี่โดเมน การโจมตีก็ไม่มีที่ให้ส่งไป

┌─ container ของ agent ─────────────────────────────────┐
│ ไม่มี ~/.ssh, ~/.aws, ~/.config/gh mount เข้ามา        │
│ ไม่มีไฟล์ .env                                        │
│ env มีเฉพาะตัวแปรที่งานนี้ต้องใช้จริง                   │
│ รันด้วย user ที่ไม่ใช่ root                            │
└──────────────────┬────────────────────────────────────┘
                   │ ทุก request ผ่าน proxy เท่านั้น

┌─ egress proxy (allowlist) ────────────────────────────┐
│ อนุญาต: api.anthropic.com, pypi.org, registry.npmjs   │
│         github.com, api.tavily.com, docs ภายใน        │
│ ปฏิเสธ: อย่างอื่นทั้งหมด + บันทึกทุกครั้งที่ปฏิเสธ       │
└───────────────────────────────────────────────────────┘

ตัวชี้วัดที่ควรเฝ้าคือ จำนวนครั้งที่ proxy ปฏิเสธ ถ้าตัวเลขนี้พุ่งขึ้นผิดปกติ แปลว่ามีบางอย่างพยายามติดต่อออกนอกรายการ ต้องดูทันที

services:
  agent:
    image: agent-runtime:latest
    user: "1000:1000"              # ไม่ใช่ root
    working_dir: /work
    volumes:
      - ./src:/work/src:rw
      - ./tests:/work/tests:rw
      - ./docs:/work/docs:ro
      - ./scripts:/work/scripts:ro # agent แก้ gate ไม่ได้
      # ไม่ mount: .env, ~/.ssh, ~/.aws, ~/.config, .git/config
    environment:                   # ฉีดเฉพาะตัวที่งานนี้ต้องใช้ ไม่ยกมาทั้งชุด
      - TAVILY_API_KEY=${TAVILY_API_KEY}
      - HTTPS_PROXY=http://egress-proxy:3128
      - NO_PROXY=localhost,127.0.0.1
    networks: [agent-net]
    cap_drop: [ALL]

สรุปบทนี้

เก็บชื่อ ไม่เก็บค่า ฉีดค่าตอนรันเพื่อให้ไม่มีไฟล์ให้ใครอ่านผิดพลาด ถือ denylist เป็นด่านชะลอไม่ใช่กำแพง วาง egress allowlist ไว้เพื่อให้การรั่วไม่มีปลายทาง และถ้า key หลุดขึ้น git ไปแล้ว ให้หมุน key ก่อนล้าง history เสมอ

บทถัดไปว่าด้วย Git และกระบวนการทำงานเมื่อทุกคนในทีมมี agent ของตัวเอง