今天用 Semgrep 對一個最小可執行的範例進行 SAST:site/index.html
(頁面) + site/vuln.js
(刻意有洞的 JS)。
Push / PR 後,CI 會掃描並把結果丟到 GitHub → Security → Code scanning alerts。
可以直接git clone今天的進度喔
git clone https://github.com/and910805/devsecops_Sast
site/index.html
(外掛 vuln.js
)<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<title>Day 6 - SAST Demo (Vulnerable)</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>body{font-family:ui-sans-serif,system-ui;max-width:820px;margin:40px auto;padding:0 16px}</style>
</head>
<body>
<h1>SAST Demo - Vulnerable Page</h1>
<div id="hello"></div>
<div id="hash"></div>
<h2>2) Open Redirect</h2>
<button onclick="go()">跳轉(讀取 ?next=...)</button>
<h2>3) HTTP 請求</h2>
<pre id="http"></pre>
<h2>4) 其他危險用法</h2>
<button onclick="renderUserHtml()">插入使用者提供的 HTML</button>
<div id="slot"></div>
<!-- 外掛的危險程式碼 -->
<script src="vuln.js"></script>
</body>
</html>
site/vuln.js
(刻意含弱點)本次範例放的弱點(含 OWASP 參考)
在site/vuln.js
中,我刻意塞了多種常見的 Web 弱點,涵蓋 OWASP Top 10 的經典案例:
範例:localStorage.setItem("jwt", "...")
說明:JWT/Token 不該放在 localStorage,容易被 XSS 讀取。
參考:
範例:document.cookie = "session=abc123"
說明:缺少 HttpOnly
/Secure
可能被竊取或在非加密通道外洩。
參考:
innerHTML
範例:element.innerHTML = userInput
說明:未過濾的使用者輸入插入 HTML,易被注入腳本。
參考:
location.hash
注入
範例:hashEl.innerHTML = location.hash.slice(1)
說明:攻擊者可控制 URL 片段,直接插入 HTML。
參考:
範例:location.href = next
說明:使用者可指定外部惡意網址,導致釣魚 / 轉址攻擊。
參考:
範例:fetch("http://httpbin.org/get?x=" + userInput)
說明:流量未加密,易遭竊聽與竄改。
參考:
eval
)
範例:eval(code)
說明:可執行任意 JavaScript,風險極高。
參考:
document.write()
直接寫入 HTML
範例:document.write(userHtml)
說明:不受控內容插入,常見造成 XSS。
參考:
window.open
未加 rel=noopener
(Reverse Tabnabbing)
範例:window.open(ext, "_blank")
說明:新開頁能反制原頁(釣魚 / 竄改)。
參考:
// ❌ 將敏感資料放在 localStorage(示範)
localStorage.setItem("jwt", "header.payload.signature");
// ❌ 以 JS 設 Cookie(無 Secure/HttpOnly;HttpOnly 也無法用 JS 設定)
document.cookie = "session=abc123; path=/";
const qs = new URLSearchParams(location.search);
// ❌ DOM XSS:直接把使用者輸入塞進 innerHTML
const name = qs.get("name") || "Guest";
document.getElementById("hello").innerHTML = `Hello, ${name}`;
if (location.hash) {
document.getElementById("hash").innerHTML = "Hash says: " + location.hash.slice(1);
}
// ❌ Open Redirect:未驗證就導頁
function go() {
const next = qs.get("next");
if (next) location.href = next; // e.g., ?next=https://evil.example
}
window.go = go;
// ❌ HTTP 明文請求 + 串連使用者輸入
const x = qs.get("x") || "demo";
fetch("http://httpbin.org/get?x=" + x)
.then(r => r.text())
.then(t => (document.getElementById("http").textContent = t))
.catch(console.log);
// ❌ 動態執行:eval
const code = qs.get("code");
if (code) {
// 例如:?code=alert(1)
eval(code); // 危險!任意 JS 執行
}
// ❌ 直接寫入整段 HTML
function renderUserHtml() {
const html = qs.get("html") || "<b>no user html</b>";
document.write(html); // 危險
document.getElementById("slot").innerHTML = html; // 危險
}
window.renderUserHtml = renderUserHtml;
// ❌ window.open 未加 rel=noopener(reverse tabnabbing 風險)
const ext = qs.get("ext");
if (ext) {
window.open(ext, "_blank"); // e.g., ?ext=https://example.com
}
.semgrep/custom-rules.yml
(自訂規則)rules:
- id: js-eval-usage
message: "避免 eval(),可能造成任意程式碼執行"
severity: HIGH
languages: [javascript, typescript]
pattern: eval(...)
- id: dom-innerhtml-assignment
message: "直接賦值 innerHTML 可能造成 XSS,請改用 textContent 或先消毒"
severity: HIGH
languages: [javascript, typescript]
pattern: $EL.innerHTML = $X
- id: document-write-usage
message: "避免使用 document.write(),容易引入 XSS/不受控內容"
severity: MEDIUM
languages: [javascript, typescript]
pattern: document.write(...)
- id: open-redirect-location-href
message: "未驗證來源即導頁(Open Redirect)"
severity: HIGH
languages: [javascript, typescript]
pattern: location.href = $X
- id: window-open-noopener
message: "window.open 需搭配 noopener/noreferrer"
severity: MEDIUM
languages: [javascript, typescript]
pattern: window.open($URL, "_blank")
- id: fetch-http-insecure
message: "HTTP 請求不安全,請改 HTTPS"
severity: HIGH
languages: [javascript, typescript]
patterns:
- pattern: fetch($U, ...)
- metavariable-regex:
metavariable: $U
regex: "^['\"]http://"
- id: localstorage-token
message: "避免將 Token/JWT 存在 localStorage"
severity: MEDIUM
languages: [javascript, typescript]
patterns:
- pattern: localStorage.setItem($K, $V)
- metavariable-regex:
metavariable: $K
regex: "(?i)(token|jwt|auth)"
.github/workflows/sast-semgrep.yml
(GitHub Actions)name: SAST - Semgrep (HTML Demo)
on:
pull_request:
branches: [ "main", "mainer" ]
push:
branches: [ "main", "mainer" ]
workflow_dispatch:
permissions:
contents: read
security-events: write
pull-requests: write
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install Semgrep
run: pip install --upgrade semgrep jq
# Run scan,即使有 Findings 也繼續(避免中斷 SARIF 上傳)
- name: Run Semgrep
id: semgrep_scan
continue-on-error: true
run: |
semgrep scan \
--include '**/*.js' \
--include '**/*.ts' \
--include '**/*.tsx' \
--include '**/*.jsx' \
--include '**/*.html' \
--config p/owasp-top-ten \
--config .semgrep/custom-rules.yml \
--sarif-output=semgrep.sarif \
--error
# 一律上傳 SARIF
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
- name: Keep artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: semgrep.sarif
path: semgrep.sarif
# 最後根據 SARIF 內容決定 fail
- name: Fail if findings > 0
if: always()
run: |
COUNT=$(jq '.runs[0].results | length' semgrep.sarif)
echo "Semgrep findings: $COUNT"
if [ "$COUNT" -gt 0 ]; then
echo "Failing the job due to findings."
exit 1
fi
Checkout → 把程式碼抓下來。
Setup Python → 準備 Semgrep 的環境。
Install Semgrep → 安裝 Semgrep + jq。
Run Semgrep → 執行掃描,把結果輸出成 SARIF。
Upload SARIF → 把掃描結果丟到 GitHub Security。
Keep artifact → 把 SARIF 檔保存起來(方便下載檢查)。
Fail if findings > 0 → 如果發現弱點,工作流標記為失敗(exit code 1)。
在 Actions 會看到 Findings > 0
,而在 Security → Code scanning alerts 也會列出剛才那 9 條左右的問題(依內容略有差異)。
明天換SCA實作?