WHAT
Purpose and context
- Purpose: A static security scanning workflow, read-only (Trivy does not modify code when scanning), usable both locally and in CI/Jenkins. Covers: dependency CVE (Go/Node/Python lockfile), misconfig (Dockerfile/compose/nginx), and secrets exposed in files. Beyond that static layer there are two more layers: DYNAMIC scanning to verify dev's default assumptions with real requests, and OUTWARD scanning (supply chain/branding, keyless).
- Trigger (when to use):
- User says "security scan", "scan", "trivy", "check CVE", "security scan", "are there vulnerabilities"
- Before release / merging the deploy branch
- Periodically in CI (
trivy fs . --config trivy.yaml --exit-code 1) - Diff falls into the Trigger-conditions table (auth, user input, secret, new endpoint, payment/sensitive data, exposing app to public domain) → MANDATORY scan; user says "are there vulnerabilities", "is it safe yet" → always a trigger.
- Non-goals: no mass auto-bump when the submodule has uncommitted changes; do not remove findings from the report (if accepted, write
.trivyignore); do not commit reports to the deploy server; do not investigate threats beyond the broad scope in the cti-expert style (only a keyless supply-watch snippet).
Mental model
Trivy có chưa? → scope (lockfile) → quét static HIGH/CRITICAL → report JSON+HTML → tóm tắt + đề xuất fix → re-scan NONE, in parallel giả định dev → request thật (curl/dig/git diff) → xác nhận hoặc lỗ hổng, plus optional tên thương hiệu → biến thể tất định → DNS sống + CT log. A clean static scan does NOT mean it is safe.
Input and output contract
| Field | Required? | Meaning | |
|---|---|---|---|
| In | source code directory (be, fe or .) |
has | scan target; trivy.yaml if the repo has |
| In | HOST / domain currently running | for dynamic scanning | to curl/dig and verify for real |
| In | brand name | no | for supply-watch.py |
| Out | security/trivy-report.json + security/trivy-report.html |
yes | full report, just a review artifact |
| Out | HIGH/CRITICAL table by target + suggested fix | yes | bump deps, non-root USER, rotate secrets |
| Out | dynamic scan results | yes | each assumption: true/false with the command + actual HTTP/DNS code |
| Out | re-scan results | when there is a fix | remaining = NONE |
| Out | supply-watch report | optional | rc 0 clean, 2 has findings; crt.sh error → SKIP |
Rules and capabilities
- RULE-01 (MUST): Static (Trivy) + Dynamic (checklist above) are TWO different layers — run BOTH. Trivy catches CVE/misconfig/secret-in-file; it does NOT catch auth bypass, public exposure, CORS/CSRF logic, branch drift. Do not treat a clean Trivy report = safe.
- RULE-02 (MUST): Do not trust the dev's default assumptions — every "login is already in place", "it's internal so it's safe", "it runs already" must be verified with a real request before concluding.
- RULE-03 (MUST): Trivy does not modify code when scanning — safe to run at any time.
- RULE-04 (MUST): Finding reviewed & accepted → record it in
.trivyignore(1 ID/line + reason comment + date), do NOT remove it from the report. - RULE-05 (MUST): Do not commit
security/*.json|htmlto the deploy server — it is only a review artifact. - RULE-06 (MUST): When deploying bonbon-ai: push only
beandfe(2 submodules) to the server; tooling (trivy.yaml, security/, skill) does not go to the server. - RULE-07 (MUST): Every fix change must go through build/verify before commit (
go build ./...,pnpm i, …). - Capabilities: install local scanning tools if missing; static scan read-only of source code; send real HTTP/DNS requests to the project's own host; modify manifest/Dockerfile when a fix is applied; read public CT log/DNS (keyless).
Failure boundaries
- Cannot install Trivy → blocked, report to user.
- Submodule has uncommitted changes → only propose fixes, no auto-bump.
- Fix does not build (
go build ./...,pnpm i) → failed, no commit. - No HOST to scan dynamically → partial: static only, clearly state the dynamic layer has NOT run, do not conclude "safe".
- Secret flagged → rotate the key immediately (not just remove it from the file).
- supply-watch cannot call crt.sh → SKIP, do not judge it clean.
HOW
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | effect | machine | Step 0 — Check + Install Trivy (MANDATORY to run first) | trivy --version printed out |
install error → blocked |
| W02 | deterministic | repo | Step 1 — Detect language via lockfile, skip build/vendor directories | scope | — |
| W03 | deterministic | scope | Step 2 — Scan vuln + misconfig + secret, HIGH/CRITICAL first | findings | — |
| W04 | deterministic | findings | Step 3 — Export JSON + HTML report to security/ |
2 report files | — |
| W05 | judgment | findings | Step 4 — Summarize HIGH/CRITICAL by target + propose fixes | table + proposals | dirty submodule → propose only |
| W06 | deterministic | target fixed | Step 5 — Re-scan to confirm | remaining = NONE |
still there → go back to W05 |
| W07 | effect | HOST, diff | DYNAMIC scan: run the checklist of 8 dev assumptions with real requests | each assumption has evidence | no HOST → partial |
| W08 | deterministic | brand name | Scan OUTWARD with supply-watch.py (B03) |
rc 0 or 2 | crt.sh error → SKIP |
Details of each step (source of truth for W01–W08):
Workflow
Step 0 — Check + Install Trivy (MANDATORY run first)
if ! command -v trivy >/dev/null 2>&1; then
echo "Trivy chưa có → cài…"
if [[ "$(uname)" == "Darwin" ]]; then
brew install trivy
else
# Linux: dùng script chính thức Aqua (không cần root nếu set BINDIR)
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
fi
fi
trivy --version # xác nhận; DB vuln tự cập nhật lần chạy đầu
Step 1 — Detect language (scan scope)
Find lockfile to know the ecosystem: go.sum/go.mod (Go), package-lock.json/pnpm-lock.yaml (Node), requirements.txt/poetry.lock (Python). Skip node_modules, .next, .contentlayer, _archives, .git.
Step 2 — Scan (HIGH+CRITICAL first for speed)
trivy fs <dir> \
--scanners vuln,misconfig,secret \
--severity HIGH,CRITICAL \
--skip-dirs '**/node_modules' --skip-dirs '**/.next' \
--skip-dirs '**/.contentlayer' --skip-dirs '_archives' \
--no-progress
Repo bonbon-ai: scan separately be and fe (each is a submodule). Prioritize --config trivy.yaml if the repo already has it.
Step 3 — Export full report
mkdir -p security
TPL=$(find $(brew --prefix trivy 2>/dev/null || echo /opt/homebrew) -name html.tpl 2>/dev/null | head -1)
trivy fs . <cờ skip-dirs như trên> --no-progress -f json -o security/trivy-report.json
trivy fs . <cờ skip-dirs như trên> --no-progress -f template --template "@$TPL" -o security/trivy-report.html
Step 4 — Summarize & propose fixes
Count HIGH/CRITICAL per target, print table. Then propose (DO NOT auto-bump en masse if the submodule has uncommitted changes):
- Easy dep bump (direct dep, edit manifest directly): e.g.
python-multipartinrequirements.txt. - Indirect dep:
go get <pkg>@<ver>thengo build ./...verify. Notego getmay raise the directivego; if it exceeds the base image in the Dockerfile then bumpFROM golang:X-alpineto match. - Misconfig DS-0002 (root user): add
USERnon-root to the Dockerfile (adduser -Dalpine,useradd -mdebian, orUSER nodefor node-alpine image; rememberchownwrite directories like volume/data). - Secret: if Trivy flags → rotate the key immediately, not just remove it from the file.
Step 5 — Re-scan to confirm
Re-run Step 2 on the fixed target, confirm remaining = NONE.
Scan OUTWARD — supply chain & brand (third key, keyless)
The two sections above both look INWARD at the project. Nothing looks OUTWARD: who is setting up a domain or package similar to your name to deceive users, or to inject a fake dependency into your own install command.
python3 fdk/tools/supply-watch.py <tên-thương-hiệu> # sweep mặc định 7 TLD
python3 fdk/tools/supply-watch.py <tên> --tld com,dev --json # cho CI
python3 fdk/tools/supply-watch.py --self-test
Generate variants deterministic, offline the following six patterns — character removal, character repetition, character swapping, adjacent-key typos (QWERTY keyboard), lookalike characters (o→0, l→1), adding a hyphen — then ask DNS which one is live. Plus a CT monitor that reads Certificate Transparency (crt.sh) to see whether any certificate was recently issued for a similar domain.
No API key needed, no .venv. Exit code: 0 clean · 2 findings detected. If crt.sh can't be reached it says SKIPPED, rather than declaring it clean.
Narrow ingestion from
7onez/cti-expert(workflow 08). Deliberately take only this piece: cti-expert is 121 ops + 78 MCP tools for investigating external threats, requiring.venv+ API key + outbound network. Merging the whole block would drag key/network dependencies into a gateway that is currently keyless and able to run offline.
DYNAMIC scanning — verifying developer assumptions (complements Trivy)
Principle: Trivy (static) does NOT catch architecture/logic/runtime-configuration errors. The most severe vulnerabilities often lie in developer default assumptions — they must be verified with real requests, not taken on faith. "It runs" ≠ "it's secure".
Distilled from the 22/06/2026 incident: the payroll ETL API had no auth (auth was only at the web layer; the path /etl through nginx bypassed it) → anyone could curl retrieve salaries + PII of 3,637 employees; it was thought to be "internal" but a public domain devtingting.coteccons.vn (public IP) bridged it out to the entire internet. Trivy did not detect it — only curl/dig real tests revealed it.
"DON'T TRUST — verify" checklist (dev assumption → reality → how to verify)
-
"The API behind the reverse proxy is protected because the web/UI has login." → FALSE: auth is usually only at the web layer; the proxy path (
/etl,/api...) is called directly by the browser, so it BYPASSES login. → Verify:curlEACH API route DIRECTLY without cookies/tokens → it must401. If200+data = vulnerability. Auth must be at the API/proxy layer (e.g. nginxauth_request), NOT only at the UI.for p in data/stats/all report/export sync/status; do curl -k -o /dev/null -w "$p -> %{http_code}\n" https://HOST/etl/$p; done # mong đợi 401 -
"It's an internal server / private IP (192.168.x), so outsiders can't get in." → FALSE: a public domain /
tailscale funnel/ port-forward can bridge in. → Verify:dig +short <mọi subdomain khả dĩ>→ does it have a public IP? Try curl from an external network. Do NOT trust "internal" — check DNS for real. -
"Opening CORS is a vulnerability / closing CORS is enough to be secure." → WRONG DIRECTION: CORS only blocks the browser from READING a cross-origin response. It does NOT block
curl/script; it does NOT block WRITE CSRF (POST query-param / no-body = simple request → no preflight → server runs regardless of CORS). The real hole is usually no-auth + open port. Don't waste effort on CORS when the front door (auth) isn't locked. -
"Leaving the default config as-is is fine." → Check for default variables leaking into prod:
CORS_ORIGINSstilllocalhost:3000? secret/flag still at sample value?BIND_HOSTaccidentally0.0.0.0?curl -k -D- -o /dev/null -H "Origin: http://localhost:3000" https://HOST/etl/health | grep -i access-control -
"Any branch deploys the same." → WRONG: CI (Jenkins) clones branch X but the server runs branch Y → drift. Merge conflicts can drop an entire block (e.g.
networks:in compose →undefined network, deploy fails). → Verify: branch CI clones == branch currently running?git diff <ci-branch> <running-branch> -- docker-compose.yml .env* deploy/. -
"As long as paramiko/SSH connects, it's fine." →
Transport(...)/AutoAddPolicyDoes NOT verify host key → MITM steals password + files. Verify: must pin known_hosts +RejectPolicy. -
"Token/secret can be passed any way." → Token in URL
?token=→ ends up in nginx/uvicorn access-log, Referer, history. Verify: secrets go via header/cookie, NOT via query string. -
"
SELECT *for convenience." → Returns every column including PII (national ID/tax code/social insurance/salary) +searchall-columns acts as an oracle for probing values. Verify: whitelist returned columns; search doesn't scan sensitive columns.
Trigger-conditions — which code behaviors MUST be scanned
Don't wait for anyone to remember to invoke the skill — if a diff falls into the table, scanning is mandatory (distilled from WorldFlowAI/everything-claude-code security-review):
| Change just made | Required | Why |
|---|---|---|
| Add/modify auth, session, authorization | Trivy + dynamic scan | Token in localStorage is exposed to XSS, missing role check before sensitive operations — the authorization class of bugs is what Trivy doesn't see. |
| Accept user input (form, param, upload) | Trivy + dynamic scan | Input not validated by schema + file upload without size/type/extension limits is an entry point for SQLi/XSS/RCE. |
| Touch secret / credential / API key | Trivy (secret scan) | Key hardcoded in source or leaked into git history — must be in env var and platform secret, .env must be gitignored. |
| Open a new API endpoint | Trivy + dynamic scan | A new endpoint by default LACKS rate-limit + CSRF + permission check; error messages easily leak internal details/stack trace. |
| Payment / sensitive data | Trivy + dynamic scan + manual review | PII/money must not go into logs, errors must be generic; one flaw compromises the whole platform — err on the side of caution. |
| Expose app to public domain / change reverse proxy | Dynamic scan | The no-auth "internal" hole turns into internet-exposed exactly at that moment (the checklist assumes the dev above). |
Not falling into the table → scanning at release cadence is enough. Self-check question: "does this diff do one of the 6 things above?" — answerable in under a minute. User says "is there a vulnerability", "is it safe yet" → always a trigger.
Branches
| ID | Kind | Guard | Behavior | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | repo bonbon-ai | scan separately be and fe (each is a submodule); deploy only pushes those 2 submodules |
other repo → scan . |
W04 |
| B02 | conditional_required | repo already has trivy.yaml |
prioritize --config trivy.yaml |
none → default skip-dirs flag | W04 |
| B03 | user_optional | user wants to inspect the supply chain / brand | supply-watch.py <tên> sweep 7 TLDs + CT monitor |
not requested → skip | end |
| B04 | conditional_required | finding has been reviewed and accepted | write .trivyignore (1 ID/line + reason + date), do not remove from report |
— | W06 |
| B05 | conditional_required | Trivy flags secret | rotate the key immediately, not just remove it from the file | — | W06 |
Validation and stopping
Deterministic: rc/output of Trivy (re-scan remaining = NONE), real HTTP code of curl (an API route without a token must be 401), dig real, git diff branch, rc supply-watch.py. Fix only commits after build/verify is green. Needs a human: decision to accept a finding into .trivyignore, manual review of payment flows/sensitive data. Stop when the re-scan is clean and every assumption in the checklist has evidence; do not conclude "safe" from Trivy alone.
Examples
- Positive: "security scan the bonbon-ai repo" → Trivy already present → scan separately
be,fe→ 2 HIGH:python-multipartinrequirements.txt+ DS-0002 root user → bump dep + addUSER node→ build green → re-scanremaining = NONE; dynamic scan curl/etl/data/stats/allwithout token returns401. - Boundary/failure: Trivy is clean but curl directly
/etl/report/exportwithout cookie returns200+ salary data → auth-bypass vulnerability, report as severe even though the static report is clean (RULE-01);dig +shortsubdomain resolves to a public IP → "internal" is wrong. - Boundary:
supply-watch.py acmewhen crt.sh does not respond → print SKIPPED for CT monitor, do not report clean.
Origin
- Absorb 2026-07-17 (adapt_mode: dissolve, T-260717-02): trigger-conditions table distilled from
WorldFlowAI/everything-claude-code(skills/security-review/SKILL.md— When to Activate + 10 checklist groups). Clone depth-1 in scratchpad/, do not vendor bytes.