gitleaks หา secret ที่หลุดเข้า git ส่วน Dependabot หาช่องโหว่ใน package ที่เราติดตั้ง แต่ยังไม่มีตัวไหนอ่านโค้ดที่เราเขียนเอง เช่น orderByRaw() ที่รับชื่อคอลัมน์มาจาก query string หรือ component ที่เอา HTML ไปใส่ใน dangerouslySetInnerHTML ตรง ๆ งานนี้เป็นของ SAST
SAST คือการอ่านโค้ดหาจุดเสี่ยงโดยไม่ต้องรัน
SAST ย่อมาจาก Static Application Security Testing เครื่องมืออ่านซอร์สโค้ดแล้วหารูปแบบที่มักเป็นช่องโหว่ ไม่ต้องเปิดเว็บหรือยิง request ใส่
Semgrep อ่านโค้ดเป็นโครงสร้างของภาษา ไม่ได้ค้นข้อความแบบ grep กฎหนึ่งข้อเขียนเป็นรูปแบบโค้ด เช่น "เรียก whereRaw() ด้วยค่าที่ไม่ใช่ string คงที่" บางกฎเป็นแบบ taint คือไล่ดูว่าค่าจากผู้ใช้ (source) ไหลไปถึงจุดอันตราย (sink) อย่าง query หรือคำสั่ง shell ได้ไหม
ฟังดูคล้าย PHPStan แต่สองตัวมองคนละเรื่อง PHPStan ดูว่าชนิดข้อมูลถูกไหม เช่น ส่ง null เข้าเมธอดที่รับ string ส่วน Semgrep ดูว่าโค้ดเข้ารูปแบบที่เสี่ยงไหม โค้ดที่ชนิดข้อมูลถูกทุกตัวก็ยังเปิดช่อง SQL injection ได้
Semgrep CE ใช้ฟรี แต่ไล่ค่าได้แค่ในไฟล์เดียว
Semgrep มีสองรุ่น ที่ใช้ในโพสต์นี้คือ Community Edition (CE) ซึ่งเป็น CLI แบบ open source รันบนเครื่องเราได้เลย ไม่ต้องสมัครบัญชี และโหลดชุดกฎจาก Semgrep Registry มาใช้ เช่น p/php, p/javascript, p/react
ส่วน Semgrep Code เป็นบริการของบริษัท ไล่ค่าข้ามไฟล์และข้ามฟังก์ชันได้ ตามหน้าเปรียบเทียบของ Semgrep CE ไล่ taint ได้แค่ในฟังก์ชันเดียว ส่วน Semgrep Code ใช้ฟรีถึง 10 contributors แต่ต้องสมัครและเชื่อม repo เข้ากับระบบของเขา
สำหรับโปรเจกต์ Laravel ขนาดเล็กถึงกลาง CE ก็พอให้เริ่ม ลองติดตั้งด้วย brew install semgrep หรือ pipx install semgrep แล้วรันที่ root ของโปรเจกต์
semgrep scan --metrics=off --config p/php --config p/javascript --config p/react \
app routes config resources/js
--metrics=off ปิดการส่งสถิติการใช้งานกลับไปที่ Semgrep ส่วนโค้ดของเราไม่ได้ออกจากเครื่องอยู่แล้ว
ตั้งใน GitHub Actions แบบรายงานอย่างเดียว
เราใส่ Semgrep เป็นอีก job หนึ่งใน workflow เดียวกับ gitleaks ซึ่งรันทุกวันตอนตีสอง
env:
SEMGREP_VERSION: 1.178.0
jobs:
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# Python ของระบบไม่ยอมให้ pip ติดตั้งตรง ๆ (PEP 668) จึงใช้ venv
- name: Install Semgrep CE
run: |
python3 -m venv "$RUNNER_TEMP/semgrep"
"$RUNNER_TEMP/semgrep/bin/pip" install --quiet "semgrep==${SEMGREP_VERSION}"
echo "$RUNNER_TEMP/semgrep/bin" >> "$GITHUB_PATH"
- name: Scan app, routes, config and resources/js
run: |
set -uo pipefail
semgrep scan --metrics=off --oss-only --disable-version-check \
--config p/php --config p/javascript --config p/react \
--exclude 'resources/js/routes' --exclude 'resources/js/actions' \
app routes config resources/js | tee semgrep.txt
{
echo '### Semgrep CE (report-only)'
grep -E 'Ran [0-9]+ rules|findings?' semgrep.txt | tail -3 || true
} >> "$GITHUB_STEP_SUMMARY"
- รายงานอย่างเดียว
semgrep scanจบด้วย exit code 0 แม้จะเจอ finding ถ้าไม่ได้ใส่--errorjob จึงเขียวเสมอ แล้วเขียนสรุปลงหน้า run ให้เปิดดู ต่างจาก gitleaks ที่ secret หนึ่งตัวคือเรื่องที่ต้องแก้ทันที finding ของ Semgrep ส่วนใหญ่ต้องให้คนไล่ดูก่อนว่าค่ามาจากไหน ถ้าตั้งให้ CI แดงตั้งแต่วันแรก เราจะชินกับสีแดงเร็วมาก - ตรึงเวอร์ชัน เวอร์ชันใหม่อาจมีกฎเพิ่มหรือเปลี่ยนผลลัพธ์ เราจึงขยับเวอร์ชันเองแล้วค่อยดูว่ามี finding อะไรเพิ่ม
- ไม่สแกนไฟล์ที่ generate โฟลเดอร์
resources/js/routesกับresources/js/actionsเป็นไฟล์ที่ Wayfinder สร้างให้ ไม่มีใครแก้ด้วยมือ สแกนไปก็ได้แต่ finding ที่เราแก้ไม่ได้
รอบแรกเจอ 4 จุด มาจากกฎเดียวกันทั้งหมด
repo ของเว็บนี้ได้ผลแบบนี้
Ran 91 rules on 720 files: 4 findings.
ทั้ง 4 จุดมาจากกฎ react-dangerouslysetinnerhtml ซึ่งเตือนทุกครั้งที่ component ใส่ HTML ที่ไม่ใช่ค่าคงที่ลงหน้าเว็บ เพราะถ้า HTML นั้นมีส่วนที่ผู้ใช้พิมพ์มาเอง ก็เป็นช่อง XSS ได้
พอไล่ดูทีละจุด สามก้อนเป็น HTML ที่ Laravel แปลงจาก markdown ฝั่ง server ด้วย 'html_input' => 'escape' ซึ่งแปลง HTML ดิบที่ผู้ใช้พิมพ์มาเป็นข้อความธรรมดา อีกก้อนเป็นรูป SVG ที่ lightbox copy ออกมาจากเนื้อโพสต์ ซึ่ง server คัดเหลือเฉพาะ tag และ attribute ที่อนุญาตไว้ตั้งแต่ตอนแปลงเนื้อโพสต์ วันนี้จึงยังไม่มีจุดไหนเป็นช่องโหว่ แต่กฎนี้ก็ยังมีประโยชน์ ถ้าวันหนึ่งมีคนเปลี่ยนค่านั้นหรือส่ง HTML จากที่อื่นเข้ามา finding จะกลับมาให้เห็นอีก
เมื่อตรวจแล้วว่าปลอดภัย ให้ใส่คอมเมนต์ nosemgrep พร้อมเหตุผลไว้เหนือบรรทัดนั้น ใน JSX ใส่ระหว่าง attribute ได้เลย
<div
// nosemgrep: typescript.react.security.audit.react-dangerouslysetinnerhtml.react-dangerouslysetinnerhtml -- HTML จาก MarkdownRenderer ที่ escape HTML ดิบแล้ว
dangerouslySetInnerHTML={{ __html: html }}
/>
ระบุชื่อกฎทุกครั้ง อย่าใส่ nosemgrep เปล่า ๆ ซึ่งปิดทุกกฎในบรรทัดนั้น และเขียนเหตุผลไว้ให้คนที่มาอ่านทีหลังรู้ว่าตรวจอะไรไปแล้ว
ชุดกฎ p/php ยังจับ SQL ดิบแบบ Laravel ไม่ได้
เราลองเขียน controller ที่มีช่อง SQL injection ชัด ๆ สามแบบ คือ User::whereRaw() ที่ต่อ string จาก $request, orderByRaw($request->input('sort')) และ DB::select() ที่แทรกค่าลงใน string ตรง ๆ แล้วรัน p/php ผลคือ ไม่เจอสักจุด
Ran 23 rules on 1 file: 0 findings.
เปิดกฎ laravel-sql-injection ใน p/php ดูก็เห็นเหตุผล กฎนี้นับเป็นค่าจากผู้ใช้เฉพาะ $_GET, $_POST และ superglobal อื่น ๆ และดูเฉพาะรูปแบบ DB::table(...)->whereRaw(...) ส่วนโค้ด Laravel ที่เราเขียนกันจริงอ่านค่าผ่าน $request และเรียก whereRaw() จาก model จึงหลุดไปทั้งหมด
ทางแก้ที่ง่ายคือเขียนกฎของโปรเจกต์เอง ข้อนี้เตือนทุกครั้งที่ส่งค่าที่ไม่ใช่ string คงที่เข้าเมธอด SQL ดิบ
rules:
- id: laravel-raw-sql-not-literal
languages: [php]
severity: WARNING
message: >-
SQL ดิบที่ไม่ใช่ string คงที่ ถ้ามีค่าจากผู้ใช้ปนเข้าไปจะโดน SQL injection
ให้ใส่ ? แล้วส่งค่าเป็น binding แทน
patterns:
- pattern-either:
- pattern: $OBJ->$METHOD($SQL, ...)
- pattern: $CLASS::$METHOD($SQL, ...)
- metavariable-regex:
metavariable: $METHOD
regex: ^(whereRaw|orWhereRaw|havingRaw|orHavingRaw|orderByRaw|groupByRaw|selectRaw|fromRaw|raw|select|statement|unprepared)$
- metavariable-pattern:
metavariable: $SQL
patterns:
- pattern-not: '"..."'
เพิ่ม --config .semgrep/ ต่อท้ายคำสั่งเดิม กฎนี้จับได้ครบทั้งสามแบบ และไม่เตือน whereRaw('lower(email) = ?', [$email]) ที่ส่งค่าผ่าน binding
แลกกับการที่กฎกว้างแบบนี้เตือนเยอะ พอรันกับโค้ดของเว็บนี้ก็เจอ 15 จุด ส่วนใหญ่อยู่ในรายงาน analytics ที่แทรกชื่อคอลัมน์หรือค่าของ enum ลงใน selectRaw() จุดที่รับค่าจาก request อย่างการเรียงตาราง ก็ผ่าน Rule::in() ใน FormRequest มาก่อน จึงไม่มีจุดไหนเป็นช่องโหว่จริง นี่คืออีกเหตุผลที่เราให้ Semgrep รายงานอย่างเดียว
ให้ AI agent ไล่ finding และเช็กเฉพาะโค้ดใหม่
การไล่ 15 จุดข้างบนคือการตามว่าค่าแต่ละตัวมาจากไหน ซึ่งเป็นงานที่ AI agent ทำได้ดี ให้ Semgrep ออกผลเป็น JSON ด้วย --json แล้วให้ agent ไล่ทีละจุดว่าค่าผ่าน validation อะไรมาบ้าง agent สรุปมาให้เราอ่านได้เร็วกว่าเปิดไฟล์เองทีละไฟล์ แต่การใส่ nosemgrep ควรเป็นการตัดสินใจของเรา และอ่านเหตุผลที่ agent เขียนก่อนรับทุกครั้ง
อีกทางหนึ่งคือเช็กเฉพาะสิ่งที่ agent เพิ่งเขียน --baseline-commit รายงานเฉพาะ finding ที่ไม่มีใน commit ที่ระบุ ของเดิมที่ตรวจไปแล้วจึงไม่โผล่มากวน
semgrep scan --metrics=off --config p/php --config .semgrep/ --baseline-commit origin/main app
Semgrep ดูเฉพาะไฟล์ที่ git รู้จัก ไฟล์ใหม่ที่ agent สร้างต้อง git add ก่อน Semgrep ถึงจะสแกนให้ และ Semgrep ใช้เวลาเริ่มหลายวินาที จึงเหมาะกับตอนจบงานแต่ละชิ้นมากกว่าใส่ใน hook ที่รันทุกครั้งที่แก้ไฟล์ แบบที่เราทำกับ Pint และ gitleaks
Semgrep ช่วยชี้จุดที่ควรดูให้ทุกวันโดยไม่ต้องจำเอง ส่วน code review และ test ก็ยังต้องมีเหมือนเดิม ถ้ายังไม่มี CI เลย ลองเริ่มจากตั้ง GitHub Actions ให้รัน test ก่อน deploy ก่อนได้ครับ






