ไฟล์ลับคุม 600 ครบ แต่ log 0644 ถูกอัปออกนอกเครื่องทุกคืน
ระบบที่ผมตรวจเจอรูนี้เป็นระบบที่ทำเรื่อง secret ได้ดีมาก รหัสทุกตัวเข้ารหัสไว้ ไฟล์ลับทุกไฟล์เป็น 600 เจ้าของถูกคน แต่ข้อมูลลูกค้ายังไหลออกนอกเครื่องทุกคืนตีสองอยู่ดี ผ่านทางที่ไม่มีใครเคยจัดหมวดให้มันว่าเป็นความลับ
สรุปใน 1 นาที
- อาการ/ปัญหา
- ข้อมูลลูกค้าใน log รั่วออกนอกเครื่องทุกคืน ทั้งที่ไฟล์ลับคุมสิทธิ์ 600 ครบ
- ใครควรอ่าน
- คนดูแลเซิร์ฟเวอร์ที่ backup ขึ้น object storage ทุกคืน
- ทางแก้แบบสั้น
- ห้ามเขียนข้อมูลส่วนบุคคลลง log กรองด้วย regex ในโค้ด ตั้งสิทธิ์ log กับ logrotate และตัด log ออกจากก้อนที่อัปออกนอกเครื่อง
พอดีผมไล่ตรวจระบบหนึ่งที่ทำเรื่องการเก็บรหัสไว้ค่อนข้างแน่น ไฟล์ตั้งค่าเข้ารหัสไว้หมด สิทธิ์ไฟล์เป็น 0600 ครบทุกตัว เจ้าของถูกคน ผมเลยหันไปดูทางอื่นที่ยังไม่มีใครแตะ แล้วไปสะดุดกับบรรทัดเดียวในสคริปต์ backup ที่บอกว่ามันมัดทั้งโฟลเดอร์แอปขึ้น object storage ทุกคืน ซึ่งแปลว่าไฟล์ log ก็ขึ้นไปด้วย และไฟล์ log ตัวนั้นเป็น 0644 อ่านได้ทั้งเครื่อง แถมในนั้นมีข้อความ error ที่พ่วงข้อมูลของลูกค้าตัวจริงติดไปเต็มบรรทัด
เรื่องนี้ต่างจากการเข้ารหัสไฟล์ตั้งค่าที่ผมเคยเขียนไว้ตรงที่มันไม่ได้อยู่ในหมวดความลับตั้งแต่แรก ไม่มีใครทำ checklist แล้วเขียนบรรทัดว่า "ตรวจสิทธิ์ไฟล์ log" เพราะ log ไม่ใช่ secret ในความรู้สึกของคนส่วนใหญ่ แต่ในทางกฎหมายข้อมูลส่วนบุคคล ไฟล์ที่มีชื่อคนกับตัวเลขทางการเงินอยู่ในบรรทัดเดียวกันนั้นหนักกว่ารหัสฐานข้อมูลเสียอีก
ตัวอย่างข้อความ log ทุกบรรทัดในบทความนี้ผมแต่งขึ้นมาใหม่ทั้งหมด ชื่อ ตัวเลข และรหัสอ้างอิงเป็นของสมมุติ ส่วนรูปแบบของปัญหาและวิธีปิดคือของจริง
ทางเดินของหนึ่งบรรทัด log
ก่อนจะแก้ ผมวาดทางเดินของข้อมูลออกมาก่อน เพราะจุดที่ควรปิดไม่ใช่จุดที่เจอปัญหา
สามจุดแรกเป็นเรื่องในเครื่อง ยังพอคุมได้ แต่จุดที่สี่คือจุดที่ย้อนกลับไม่ได้ ของที่ขึ้นไปแล้วเมื่อคืนที่แล้วก็คือขึ้นไปแล้ว ต่อให้วันนี้เราแก้โค้ดเสร็จ สำเนาเก่ายังนอนอยู่บนนั้นอีก 90 วันตามนโยบายการเก็บ
หน้าตาของบรรทัดที่ไม่ควรมี
นี่คือรูปแบบที่ผมเจอบ่อยที่สุด เขียนด้วยความหวังดีล้วนๆ คือคนเขียนอยากให้ตอนไล่บั๊กแล้วรู้เลยว่าเคสไหน
[2026-10-19 02:14:07] production.ERROR: ออกเอกสารไม่สำเร็จ
ผู้รับ "นายทดสอบ ตัวอย่างดี" เลขอ้างอิง AB-0000-1234
ยอดรวม 12,345.00 คงเหลือ 6,789.00 ช่องทาง "บัญชีสาขาตัวอย่าง"
{"phone":"08X-XXX-XXXX","email":"[email protected]"}
บรรทัดแบบนี้ตอบโจทย์คนไล่บั๊กได้ดีมาก และนั่นแหละคือเหตุผลที่มันถูกเขียนขึ้นมา ปัญหาคือมันไม่ได้มีอยู่แค่ตอนที่เราไล่บั๊ก มันอยู่ในไฟล์นั้นไปอีกหลายเดือน และเดินทางไปทุกที่ที่ไฟล์นั้นเดินทางไป
ไฟล์ log เดินทางไกลกว่าที่คนเขียนโค้ดคิดไว้เสมอ มันขึ้นไปกับ backup ทุกคืน มันถูกก๊อปตอนย้ายเครื่อง มันถูกแนบส่งให้คนนอกตอนเปิดเคสกับผู้ให้บริการ และบางทีมันถูกวางไว้ในโฟลเดอร์ที่ web server เสิร์ฟได้โดยไม่มีใครรู้ตัว ทุกอย่างที่เขียนลง log ให้ถือว่าจะมีคนนอกทีมได้อ่านสักวันหนึ่ง ถ้ายอมรับข้อนี้ไม่ได้ ก็อย่าเขียนมันลงไปตั้งแต่แรก
กฎข้อเดียวที่ตัดปัญหาได้เกือบหมด
กฎที่ผมยึดคือ log บันทึกแค่ "อะไรพัง" ไม่บันทึก "พังกับใคร" ตัวข้อความเต็มที่มีบริบทของลูกค้าอยู่ ส่งขึ้นหน้าเว็บให้คนที่มีสิทธิ์ดูอ่านตรงนั้นได้ตามปกติ เพราะหน้าเว็บมีระบบสิทธิ์คุมอยู่แล้ว ส่วน log ที่ไม่มีระบบสิทธิ์อะไรเลยให้ได้แค่รหัส
log ข้อความที่มนุษย์อ่านรู้เรื่อง
ได้บริบทครบ ไล่บั๊กง่ายที่สุด แต่บริบทนั้นคือข้อมูลของคนจริง และมันจะติดไปกับทุกสำเนาของไฟล์ตลอดอายุการเก็บ ย้อนกลับไปลบทีหลังแทบเป็นไปไม่ได้
log รหัสผลลัพธ์กับ id ภายใน
ได้ประเภทของ error ได้จุดที่พัง ได้ id ของแถวที่เอาไปเปิดดูต่อในระบบที่มีสิทธิ์คุมได้ ไล่บั๊กได้เท่าเดิมโดยไม่ต้องพก PII ติดตัวไปด้วย
หน้าตาโค้ดที่ผมเปลี่ยนไปใช้
try {
$doc = $this->issue($order);
} catch (IssueFailed $e) {
// log: รหัส + id เท่านั้น ห้ามมีชื่อ ห้ามมีตัวเลขเงิน ห้ามมีเบอร์
Log::error('doc.issue.failed', [
'code' => $e->code(), // เช่น CHANNEL_CLOSED
'order_id' => $order->id, // id ภายใน ไม่ใช่เลขที่เอกสารจริง
'stage' => $e->stage(),
]);
// ข้อความเต็มไปโผล่บนหน้าจอของคนที่มีสิทธิ์ดู ไม่ลงไฟล์
return back()->withErrors(['issue' => $e->userMessage()]);
}
จุดที่ต้องระวังคือ $e->getMessage() ของ exception ที่เราสร้างเอง มักจะถูกเขียนให้เป็นประโยคภาษาคนที่มีบริบทเต็ม ถ้าเผลอส่งทั้งก้อนเข้า Log::error($e->getMessage()) ก็กลับไปที่เดิม ผมเลยแยกให้ exception มี code() กับ userMessage() คนละตัวกันไปเลย บังคับกลายๆ ว่าคนเขียนต้องเลือกว่าจะเอาอันไหนไปไว้ตรงไหน
regex กรองในโค้ด ห้ามพึ่งการสั่ง
ในระบบที่มีการให้โมเดลภาษาช่วยอ่านเอกสาร คนมักจะแก้ปัญหานี้ด้วยการเขียนในคำสั่งว่า "ห้ามใส่เลขบัตรประชาชนหรือเลขบัญชีในผลลัพธ์" แล้วคิดว่าจบ ผมคิดว่าอันนั้นไม่นับเป็นมาตรการครับ เพราะมันไม่ใช่การบังคับ มันคือการขอ
ตัวที่บังคับได้จริงคือชั้นกรองในโค้ดที่วางคั่นก่อนเขียนลงไฟล์ และควรเป็นชั้นที่เลี่ยงไม่ได้ ไม่ใช่ฟังก์ชันที่คนเขียนโค้ดต้องจำว่าต้องเรียก
namespace App\Logging;
use Monolog\LogRecord;
final class RedactPii
{
/** @var list<array{0:string,1:string}> */
private const RULES = [
['/\b\d{13}\b/u', '[id-redacted]'], // เลขประจำตัว 13 หลัก
['/\b\d{3}-\d-\d{5}-\d\b/u', '[acct-redacted]'], // เลขบัญชีมีขีด
['/\b\d{10,12}\b/u', '[num-redacted]'], // เลขยาวที่ไม่รู้ว่าอะไร
['/\b0\d{1,2}-?\d{3}-?\d{4}\b/u', '[tel-redacted]'], // เบอร์โทร
['/[\w.+-]+@[\w-]+\.[\w.-]+/u', '[mail-redacted]'], // อีเมล
];
public function __invoke(LogRecord $record): LogRecord
{
return $record->with(
message: self::scrub($record->message),
context: self::walk($record->context),
);
}
public static function scrub(string $s): string
{
foreach (self::RULES as [$re, $to]) {
$s = preg_replace($re, $to, $s) ?? $s;
}
return $s;
}
private static function walk(array $a): array
{
foreach ($a as $k => $v) {
$a[$k] = is_array($v) ? self::walk($v) : (is_string($v) ? self::scrub($v) : $v);
}
return $a;
}
}
แล้วผูกเข้าที่ channel ให้มันวิ่งทุกบรรทัดโดยที่โค้ดฝั่งเรียกใช้ไม่ต้องรู้เรื่อง
// config/logging.php
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/app.log'),
'days' => 14,
'permission' => 0640,
'tap' => [\App\Logging\TapRedact::class],
],
regex กรองได้เฉพาะรูปแบบที่เรานึกออก ชื่อคนไทยไม่มีรูปแบบตายตัวให้จับ ที่อยู่ก็ไม่มี เพราะฉะนั้นชั้นกรองนี้คือตาข่ายรองชั้นสุดท้าย ไม่ใช่มาตรการหลัก มาตรการหลักยังคงเป็นการไม่เขียนของพวกนั้นลงไปตั้งแต่แรก ถ้าใครเอาตัวกรองไปเป็นเหตุผลว่า "มีตัวกรองแล้ว log อะไรก็ได้" แปลว่าเข้าใจผิดตั้งแต่ต้น
สิทธิ์ไฟล์ log และ logrotate
ปิดที่เนื้อหาแล้ว ต้องปิดที่สิทธิ์ด้วย ค่าเริ่มต้นของหลายเฟรมเวิร์กคือ 0644 ซึ่งแปลว่าทุกบัญชีบนเครื่องอ่านได้ รวมถึงบัญชีของลูกค้ารายอื่นถ้าเป็น shared host
# ดูว่ามีไฟล์ log ตัวไหนที่คนนอกอ่านได้บ้าง
find /srv/app/storage/logs /var/log/app -type f -perm -o+r -printf '%M %u:%g %p\n'
# ปิดให้หมด แล้วให้ของใหม่เกิดมาถูกตั้งแต่แรก
sudo chmod 0640 /srv/app/storage/logs/*.log
sudo chown appuser:webgroup /srv/app/storage/logs/*.log
chmod ตอนนี้แก้ได้แค่ไฟล์ที่มีอยู่ ของที่เกิดใหม่พรุ่งนี้จะกลับไป 0644 เหมือนเดิมถ้าไม่ไปตั้งที่ต้นทาง ฝั่ง Laravel ตั้งที่ permission ในไฟล์ตั้งค่าอย่างที่โชว์ไปข้างบน ส่วนของที่ระบบหมุนให้เอง ต้องไปตั้งที่ logrotate
/srv/app/storage/logs/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0640 appuser webgroup
su appuser webgroup
}
บรรทัด create 0640 คือหัวใจ เพราะ logrotate เป็นคนสร้างไฟล์ตัวใหม่หลังหมุน ถ้าไม่ระบุไว้มันจะลอกโหมดของไฟล์เดิมมา ซึ่งวันแรกอาจจะยังถูก แต่พอมีใครไปแตะอะไรเข้าสักครั้งก็เพี้ยนได้
ตัด log ออกจากก้อนที่อัปออกนอกเครื่อง
มาถึงจุดที่เป็นชื่อบทความ สคริปต์ backup ส่วนใหญ่เขียนแบบ "มัดทั้งโฟลเดอร์แล้วตัดของหนักออก" ซึ่งรายการที่ตัดมักจะมีแต่ node_modules กับไฟล์วิดีโอ ไม่มีใครนึกถึง log เพราะมันเล็ก
# แบบเดิม — ตัดแค่ของหนัก log ติดขึ้นไปด้วยทุกคืน
tar czf "$OUT/app-$TS.tar.gz" \
--exclude='node_modules' --exclude='vendor' --exclude='*.mp4' \
-C /srv app
# แบบที่ควรเป็น — ตัด log ออกจากก้อนหลักไปเลย
tar czf "$OUT/app-$TS.tar.gz" \
--exclude='node_modules' --exclude='vendor' --exclude='*.mp4' \
--exclude='storage/logs' --exclude='*.log' --exclude='*.log.gz' \
-C /srv app
คำถามที่ตามมาคือแล้วจะเก็บ log ไว้ไหม คำตอบของผมคือเก็บครับ แต่แยกก้อน แยกอายุ และแยกปลายทาง เพราะ log กับ backup มีเหตุผลในการมีอยู่คนละเรื่องกัน backup มีไว้กู้ระบบ ส่วน log มีไว้ไล่เหตุการณ์ย้อนหลังในกรอบเวลาสั้นๆ
# ก้อน log แยก เก็บสั้นกว่ามาก และไม่ปนกับก้อนที่เอาไว้กู้ระบบ
tar czf "$OUT/logs-$TS.tar.gz" -C /srv/app storage/logs
rclone copy "$OUT/logs-$TS.tar.gz" "remote:$(cat /etc/app/backup-bucket)/logs/"
rclone delete "remote:$(cat /etc/app/backup-bucket)/logs/" --min-age 30d
ชื่อปลายทางกับคีย์ผมอ่านจากไฟล์ตั้งค่าที่คุมสิทธิ์ไว้ ไม่เขียนลงในสคริปต์ตรงๆ เพราะสคริปต์อยู่ใน repo และ repo มีคนอ่านได้มากกว่าเครื่อง
ไฟล์ชั่วคราวคือ log อีกแบบหนึ่ง
ระหว่างไล่เรื่อง log ผมเจอพี่น้องของมันอีกตัวคือไฟล์ชั่วคราวที่แอปสร้างระหว่างประมวลผล พวกไฟล์ที่แตกออกมาจากเอกสารที่ผู้ใช้อัปโหลด หรือไฟล์กลางที่ส่งให้เครื่องมือภายนอกอ่าน ไฟล์พวกนี้มีเนื้อหาเต็มรูปแบบของลูกค้า และมักถูกทิ้งค้างไว้เมื่อโค้ดออกทางลัด
$dir = storage_path('app/private/tmp/'.bin2hex(random_bytes(6)));
mkdir($dir, 0770, true);
$file = $dir.'/in.bin';
try {
file_put_contents($file, $payload);
chmod($file, 0600);
if (! $this->precheck($payload)) {
return self::SKIPPED; // early return — จุดที่คนลืมลบบ่อยที่สุด
}
return $this->run($file);
} finally {
// ลบทุกเส้นทาง รวมทั้ง early return และตอนโยน exception
@unlink($file);
@rmdir($dir);
}
สามอย่างที่ต้องมีพร้อมกันคือ โฟลเดอร์ 0770 ไฟล์ 0600 และชื่อที่เดาไม่ได้ ส่วนการลบต้องอยู่ใน finally เท่านั้น เพราะถ้าวางไว้ท้ายฟังก์ชัน มันจะไม่ทำงานในเส้นทางที่ออกก่อน ซึ่งเป็นเส้นทางที่เกิดบ่อยกว่าเส้นทางปกติเสียอีกในระบบที่มีการตรวจความถูกต้องเยอะๆ
ตรวจว่ารั่วไปแล้วแค่ไหน
หลังปิดรูเสร็จ ผมคิดว่าต้องตอบให้ได้ด้วยว่าที่ผ่านมามันรั่วไปเท่าไหร่ ไม่งั้นก็ไม่รู้ว่าต้องแจ้งใครไหม
- ดึงก้อน backup ของสองสามคืนล่าสุดลงมาแตกในโฟลเดอร์ชั่วคราวที่คุมสิทธิ์ไว้ อย่าแตกในที่ที่คนอื่นเดินผ่าน
- ยิง regex ชุดเดียวกับตัวกรองในโค้ดลงไปบนไฟล์ log ที่แตกออกมา นับจำนวนบรรทัดที่ตรง ไม่ต้องอ่านเนื้อ
- ถ้านับได้มากกว่าศูนย์ ให้ไล่ย้อนว่ารูปแบบบรรทัดนั้นมาจากโค้ดตรงไหน แล้วปิดที่ต้นทางก่อนทำอย่างอื่น
- ลบก้อนเก่าที่มี log ปนออกจากปลายทางนอกเครื่องเท่าที่นโยบายอนุญาต แล้วบันทึกไว้ว่าลบอะไรไปเมื่อไหร่
- ลบโฟลเดอร์ที่แตกไว้ให้เกลี้ยงทันทีที่ตรวจเสร็จ อย่าทิ้งค้าง เพราะตอนนี้มันคือกองข้อมูลลูกค้าที่นอนอยู่กลางเครื่อง
คำสั่งนับที่ผมใช้ ตั้งใจให้มันพิมพ์ออกมาแค่จำนวนกับชื่อไฟล์ ไม่พิมพ์เนื้อ
for f in /srv/tmp/audit/*/storage/logs/*.log; do
n=$(grep -cE '\b[0-9]{13}\b|\b[0-9]{3}-[0-9]-[0-9]{5}-[0-9]\b|[[:alnum:]._+-]+@[[:alnum:].-]+' "$f")
[ "$n" -gt 0 ] && printf '[HIT] %-48s %s บรรทัด\n' "$(basename "$f")" "$n"
done
rm -rf /srv/tmp/audit
ตอนไล่ตรวจว่าข้อมูลรั่วไหม คนมักจะ cat หรือ tail ไฟล์ขึ้นมาดูด้วยตา ซึ่งแปลว่าข้อมูลชุดนั้นไปโผล่ใน scrollback ของเทอร์มินัล ไปอยู่ใน session log ของเครื่องมือที่ใช้ต่อ และถ้าแชร์จอให้คนอื่นดูอยู่ก็ไปถึงคนนั้นด้วย ให้นับ ให้วัด อย่าอ่าน เอาจำนวนกับชื่อไฟล์พอ เปิดดูเนื้อเฉพาะตอนที่จำเป็นจริงๆ แล้วปิดทันที
สรุป
รูนี้ไม่ได้เกิดจากใครทำพลาด แต่เกิดจากช่องว่างระหว่างหมวด คนที่ดูแลเรื่องรหัสก็ดูแลรหัสไปเต็มที่ คนที่เขียน backup ก็ทำให้ครบที่สุดเท่าที่ทำได้ คนที่เขียน log ก็เขียนให้ไล่บั๊กง่ายที่สุด ทุกคนทำงานของตัวเองดีหมด แต่ไม่มีใครได้เห็นเส้นทางทั้งเส้นพร้อมกัน
สิ่งที่ผมเพิ่มเข้าไปในเอกสารของระบบเลยไม่ใช่กฎใหม่ แต่เป็นคำถามเดียวที่ต้องตอบทุกครั้งที่เขียนบรรทัด log ใหม่ — ถ้าบรรทัดนี้ไปโผล่นอกเครื่องพรุ่งนี้ เรารับได้ไหม ถ้าตอบว่ารับไม่ได้ ก็เหลือทางเดียวคือตัดของที่รับไม่ได้ออกจากบรรทัดนั้นตั้งแต่ตอนเขียน ส่วนตัวกรองกับสิทธิ์ไฟล์เป็นตาข่ายรองไว้เฉยๆ ไม่ใช่ตัวหลัก
ใครจะปรับชุด regex หรือปรับอายุการเก็บไปตามกติกาของงานตัวเองก็ตามสะดวกครับ หลักการเดียวกันหมด ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย