Bảo mật CI/CD & Supply ChainCI/CD & Supply Chain Security
Thuộc bộ kiến thức DevSecOps Roadmap.
Tổng quan
Bảo mật CI/CD và supply chain là phần lõi vận hành của DevSecOps — nơi “shift-left” không còn là khẩu hiệu mà trở thành automation cụ thể chạy trên từng commit. Một build pipeline không chỉ là dây chuyền lắp ráp; đó là một hệ thống có quyền truy cập (privileged) vào source code, secrets, credentials cloud, và có khả năng đẩy artifact thẳng vào production. Nếu attacker chiếm được pipeline, mọi security control ở downstream (code review, testing, monitoring) đều có thể bị bỏ qua hoàn toàn, vì lúc này attacker đang “ship” chính artifact của họ thông qua quy trình mà bạn tin tưởng.
Đây không phải chuyện lý thuyết. Vụ SolarWinds Orion (2020) liên quan đến việc attacker cấy mã độc trực tiếp vào build process, khiến phần mềm được ký hợp lệ, phát hành chính thức lại mang backdoor tới ~18.000 khách hàng. Vụ xâm nhập Codecov bash uploader (2021) sửa đổi một CI script mà hàng nghìn pipeline thực thi, từ đó exfiltrate environment variables (bao gồm cả secrets) từ mọi pipeline chạy script đó. Vụ backdoor xz-utils (2024) cho thấy ngay cả build script của một thư viện nén mã nguồn mở được tin cậy rộng rãi cũng có thể bị phá hoại bởi một kẻ chiếm quyền maintainer kiên nhẫn, có kỹ thuật social engineering. Mỗi vụ này đều là một supply chain attack: mục tiêu không bao giờ là nạn nhân trực tiếp, mà là một thành phần upstream được tin cậy — source repository, build system, dependency, hay kênh phân phối — mà nạn nhân ngầm định tin tưởng.
Bài viết này bao quát hai mối quan tâm gắn chặt với nhau:
- Pipeline hardening — bảo mật chính hệ thống CI/CD (runners, secrets, branch protection, tính toàn vẹn của commit) để attacker không thể can thiệp vào build process.
- Supply chain security — bảo mật mọi thứ mà pipeline tiêu thụ và tạo ra: nguồn gốc (provenance) của source code, dependency bên thứ ba, build output, SBOM, chữ ký (signature), và attestation, để người tiêu thụ phần mềm của bạn có thể xác minh nó chứa gì và đến từ đâu.
Bộ công cụ scanning bảo mật (SAST, DAST, SCA, IaC scanning, secrets scanning, container scanning) chính là lớp automation biến cả hai điều trên thành hoạt động liên tục thay vì một cuộc audit một lần. Phần còn lại của bài viết sẽ đi qua từng lớp, ghép chúng lại trong một pipeline GitHub Actions thực tế, và trình bày các khái niệm supply chain (SBOM, SLSA, Sigstore) cho phép bạn chứng minh — chứ không chỉ tuyên bố — rằng một artifact đủ an toàn để deploy.
Chủ đề liên quan: Secure Coding and Web Application Security, Container and Kubernetes Security, DevOps — CI/CD, DevOps — Infrastructure as Code.
Kiến thức nền tảng
Pipeline hardening
Pipeline là mục tiêu giá trị cao chính vì nó vốn được thiết kế để có quyền truy cập rộng: nó checkout source code, pull dependencies, thường nắm giữ credentials deploy cloud, và tạo ra artifact sẽ chạy trên production. Hardening pipeline nghĩa là áp dụng cùng kỷ luật access-control và isolation mà bạn áp dụng cho một hệ thống production.
| Control | Nó làm gì | Vì sao quan trọng |
|---|---|---|
| CI runner theo least-privilege | Giới hạn credentials của mỗi job (IAM role của cloud, registry token, deploy key) chỉ ở mức job đó thực sự cần, dùng token ngắn hạn, có phạm vi hẹp thay vì token tĩnh dài hạn | Một job bị xâm phạm (ví dụ qua một dependency độc hại) chỉ có thể gây thiệt hại trong giới hạn quyền hẹp đó |
| Build environment ephemeral / isolated | Dùng container hoặc VM mới, dùng một lần cho mỗi job (GitHub-hosted runners, ephemeral Kubernetes pod runners, EC2 dùng-rồi-bỏ) thay vì build server chia sẻ, tồn tại lâu dài | Ngăn trạng thái còn sót lại hoặc việc bị xâm phạm ở một job ảnh hưởng tới build tiếp theo; loại bỏ mục tiêu tĩnh để attacker cấy persistence |
| Isolation cho self-hosted runner | Nếu tự host runner (vì chi phí, network access, hay phần cứng đặc thù), không bao giờ chia sẻ runner giữa PR từ fork công khai và branch tin cậy, và không tái sử dụng runner cho các workload không tin cậy | Một pull request độc hại từ fork nếu không sẽ có thể thực thi mã tùy ý với quyền truy cập vào secrets vốn chỉ dành cho branch tin cậy (một lỗi cấu hình GitHub Actions rất phổ biến trong thực tế) |
| Signed commits | Yêu cầu commit (và lý tưởng là cả tag) được ký bằng GPG, SSH, hoặc gitsign của Sigstore, và enforce việc verify ở phía server (ví dụ rule “Require signed commits” của GitHub) | Chứng minh commit thực sự đến từ key của tác giả được khai báo, khiến việc âm thầm chèn lịch sử độc hại khó hơn nhiều, và cung cấp non-repudiation cho audit |
| Protected branch / branch policy | Yêu cầu pull request review (kèm CODEOWNERS cho các đường dẫn nhạy cảm), status check phải pass, branch phải up-to-date, lịch sử tuyến tính, và tắt force-push/xóa trên branch main/release | Ngăn một tài khoản duy nhất bị xâm phạm hoặc độc hại push thẳng vào branch dẫn tới production, đảm bảo mọi thay đổi đều qua review + gate tự động |
| Secrets không bao giờ nằm trong code hay log | Lưu secrets trong secret manager chuyên dụng (GitHub/GitLab CI secrets, HashiCorp Vault, AWS Secrets Manager) — không bao giờ trong source, config file, hay Docker layer; che (mask) giá trị secret trong log output; dọn dẹp debug/verbose logging có thể vô tình in ra environment variables | Source repo và CI logs thường tồn tại lâu dài, nhiều người đọc được, và được backup/cache ở những nơi bạn không kiểm soát — một secret bị lộ ở đó có thể tồn tại lâu hơn cả sự cố gây ra nó, tính bằng năm |
| Ưu tiên OIDC federation thay vì cloud credentials tĩnh | Dùng token ngắn hạn, được liên kết workload-identity (GitHub OIDC → AWS IAM role / GCP Workload Identity Federation / Azure federated credentials) thay vì lưu access key cloud dài hạn dưới dạng CI secret | Loại bỏ hoàn toàn một loại secret (cloud key tĩnh) có thể bị lộ, exfiltrate, hoặc tái sử dụng ngoài phạm vi pipeline; token có phạm vi hẹp, ngắn hạn, và gắn về mặt cryptographic với một workflow run cụ thể |
| Pin Actions/plugin bên thứ ba theo commit SHA | Tham chiếu các bước workflow tái sử dụng (ví dụ actions/checkout@<full-sha>) bằng commit hash bất biến, không dùng tag có thể thay đổi như @v4 | Một tag có thể bị chính maintainer của action (hoặc attacker chiếm được tài khoản của họ) trỏ sang mã độc mà không cần thay đổi gì trong workflow file của bạn |
| Yêu cầu approval cho workflow từ fork | Cấu hình “Require approval for first-time contributors” / “Require approval for all outside collaborators” trên repo công khai | Ngăn pull request “đánh nhanh rút gọn” thực thi mã CI tùy ý (ví dụ cryptomining, exfiltrate secrets) trước khi maintainer review |
Nguyên tắc xuyên suốt tất cả những điều trên là: coi pipeline như một hệ thống production, không phải một tiện ích nội bộ. Bất kỳ ai có thể sửa cấu hình pipeline, thêm dependency, hoặc merge vào branch được bảo vệ đều có, một cách gián tiếp, khả năng ảnh hưởng tới những gì được ship ra production — access control cần phản ánh điều đó.
Bộ công cụ scanning bảo mật
Scanning tự động là thứ biến shift-left thành một quá trình liên tục thay vì một cuộc audit một lần. Mỗi nhóm dưới đây trả lời một câu hỏi khác nhau, chạy ở một điểm khác nhau trong vòng đời, và bắt được một loại vấn đề khác nhau — không loại nào thay thế được loại nào.
SAST — Static Application Security Testing
SAST phân tích source code (hoặc bytecode/binary) mà không thực thi nó, tìm các pattern không an toàn: nối chuỗi SQL có thể dẫn đến injection, dùng thuật toán mã hóa yếu, deserialization không an toàn, credential hardcode, thiếu input validation, và các API được biết là nguy hiểm. SAST chạy nhanh và sớm nhất — thường trên mỗi pull request, thậm chí ngay trong IDE trước khi commit.
- Semgrep — dựa trên rule, nhẹ, nhanh; bộ rule cộng đồng/mã nguồn mở lớn theo từng ngôn ngữ/framework; dễ viết custom rule cho anti-pattern đặc thù của tổ chức; tỷ lệ tín hiệu/nhiễu tốt khi được tune kỹ.
- CodeQL — engine phân tích code ngữ nghĩa của GitHub; coi code như dữ liệu có thể query (một “database” của codebase) và chạy các query dataflow sâu, xuyên file; là nền tảng cho “code scanning” alerts tích hợp sẵn của GitHub; rất mạnh trong việc trace dữ liệu tainted qua ranh giới hàm.
- Khác: SonarQube (bao gồm cả code quality/maintainability), Checkmarx, Fortify.
Điểm mạnh của SAST: phát hiện vấn đề sớm, trước cả khi code chạy, với khả năng nhìn thấy toàn bộ logic. Điểm yếu của SAST: không có context runtime, nên không thể thấy app thực sự hoạt động ra sao khi deploy (quyền runtime bị cấu hình sai, mức độ expose network thực tế), và có xu hướng tạo false positive trên các pattern thực chất an toàn trong ngữ cảnh cụ thể.
DAST — Dynamic Application Security Testing
DAST kiểm thử một ứng dụng đang chạy từ bên ngoài, theo đúng cách một attacker bên ngoài sẽ làm — gửi input độc hại tới các HTTP endpoint đang sống, fuzz tham số, dò tìm injection, broken authentication, cấu hình sai, và các vấn đề khác thuộc lớp OWASP Top 10, mà không cần biết gì về source code (black-box). DAST bắt buộc chạy muộn hơn trong pipeline, nhắm vào một instance đã được deploy (staging, review app, hoặc môi trường ephemeral dựng riêng cho lần chạy pipeline).
- OWASP ZAP — miễn phí, được dùng rộng rãi, scriptable, có chế độ “baseline scan” thân thiện với CI và chế độ “active scan” đầy đủ.
- Burp Suite — tiêu chuẩn ngành cho kiểm thử web app thủ công và bán tự động, có bản Enterprise tích hợp được vào CI.
Xem Container and Kubernetes Security để biết cách harden môi trường runtime mà DAST scan vào. Chi tiết cách dùng công cụ: xem bài viết về security testing tools (dự kiến; chưa được xuất bản trong knowledge base này — phần thiết lập công cụ DAST và tuning scan sẽ nằm ở đó).
SCA — Software Composition Analysis
SCA scan dependency khai báo và transitive (npm, pip, Maven, Go modules, v.v.) đối chiếu với các cơ sở dữ liệu lỗ hổng (NVD, GitHub Advisory Database, OSV) để phát hiện CVE đã biết, và thường cũng gắn cờ các license open-source có vấn đề (ví dụ GPL trong codebase độc quyền). Điều này rất quan trọng vì phần lớn code trong một ứng dụng hiện đại không phải do bạn viết — mà là dependency, và dependency-của-dependency.
- Dependabot — tích hợp sẵn trong GitHub; tự động mở pull request nâng cấp dependency có lỗ hổng lên bản đã patch, kèm alert trên tab Security.
- Snyk — SCA thương mại (có gói miễn phí); phủ hệ sinh thái rộng hơn, reachability analysis (đánh dấu liệu đường code có lỗ hổng có thực sự được gọi hay không), và scan container/IaC trong cùng sản phẩm.
- Khác: OWASP Dependency-Check, Trivy (cũng làm được container/IaC), Renovate (tập trung vào tự động patch, xem phần Automated Patching bên dưới).
SCA là nơi phần lớn các vụ breach thực tế bắt nguồn — một dependency có lỗ hổng (ví dụ Log4Shell trong log4j, 2021) có thể gây ảnh hưởng lớn hơn nhiều so với một bug trong chính code của bạn, vì một thư viện có lỗ hổng thường được hàng nghìn dự án downstream dùng cùng lúc. Xem thêm hướng dẫn về secure coding và dependency risk management trong Secure Coding and Web Application Security.
IaC scanning
IaC scanning (Infrastructure as Code scanning) phân tích tĩnh Terraform, CloudFormation, Kubernetes manifest, và các định nghĩa hạ tầng khai báo tương tự để tìm cấu hình không an toàn trước khi bất cứ thứ gì được provision — một security group mở, một S3 bucket không mã hóa, một IAM policy quá rộng quyền, một container chạy với quyền root. Về bản chất đây là “SAST cho hạ tầng”, và nên nằm cùng giai đoạn pipeline với SAST vì nó cũng static và nhanh.
- Checkov — bao phủ rộng, policy-as-code (kiểu gần với rego), hỗ trợ Terraform/CloudFormation/Kubernetes/Serverless/Dockerfile.
- tfsec (nay là một phần của Trivy) — tập trung vào Terraform, nhanh, dễ áp dụng dần dần.
- Khác: KICS, Terrascan.
Nội dung đầy đủ về thiết kế IaC, quản lý state, và module nằm trong DevOps — Infrastructure as Code; bài viết này chỉ tập trung vào khía cạnh scanning/gating khi nó gắn vào pipeline.
Secrets scanning
Secrets scanning phát hiện credential hardcode, API key, private key, và token được commit vào source — cả trong working tree hiện tại lẫn, quan trọng không kém, trong lịch sử commit (một secret đã bị xóa ở commit sau vẫn bị lộ với bất kỳ ai có thể git log/git clone toàn bộ lịch sử). Đây là một trong những loại scan có ROI cao nhất, ít nhiễu nhất, vì một kết quả khớp gần như không bao giờ là false positive.
- gitleaks — nhanh, dựa trên regex + entropy, dễ chạy như pre-commit hook và trong CI; có thể scan toàn bộ lịch sử git.
- TruffleHog — mục tiêu tương tự, có thêm khả năng verify credential đang sống (kiểm tra xem key tìm được có còn active thật với API của provider hay không), giúp việc triage: một key đã chết/đã xoay vòng có độ ưu tiên thấp hơn một key còn sống.
- Secret scanning gốc của GitHub (kèm push protection) chặn các pattern secret đã biết ngay từ trước khi được push lên — điểm sớm nhất có thể trong pipeline.
Best practice là chạy secrets scanning trước cả khi commit rời khỏi máy developer (pre-commit hook), lại chạy trên mỗi pull request, và định kỳ chạy full-history scan, vì các pattern phát hiện mới được bổ sung theo thời gian và có thể bắt được secret đã tồn tại từ trước khi rule đó ra đời.
Container image scanning
Container image scanning kiểm tra các OS package, dependency ở tầng ngôn ngữ, và cấu hình của một image đã build để tìm lỗ hổng đã biết và cấu hình sai (chạy với quyền root, thiếu HEALTHCHECK, capability không cần thiết, secrets bị nướng vào layer). Nó chạy sau khi image được build, trước khi push lên registry hoặc deploy.
- Trivy — nhanh, all-in-one (OS package, app dependency, IaC, secrets, Kubernetes manifest, sinh SBOM) — một lựa chọn mặc định mạnh nếu muốn một công cụ duy nhất phủ nhiều loại scan.
- Grype — scanner của Anchore, kết hợp tự nhiên với Syft để sinh SBOM.
- Clair — được nhiều container registry sử dụng (Quay, Harbor) cho scan-on-push.
Hướng dẫn đầy đủ về hardening container và Kubernetes (chọn base image, non-root user, read-only filesystem, admission control, runtime security) nằm trong Container and Kubernetes Security; bài viết này chỉ tập trung vào vị trí image scanning trong pipeline và cách nó gate việc deploy.
So sánh các nhóm công cụ
| Nhóm | Câu hỏi nó trả lời | Bắt được gì | Chạy khi nào | Điểm mù |
|---|---|---|---|---|
| SAST | ”Code của tôi có được viết an toàn không?” | Pattern injection, mã hóa yếu, deserialization không an toàn, secret hardcode trong logic | Trên mỗi commit/PR, feedback nhanh nhất | Không có context runtime; false positive với code an toàn trong ngữ cảnh |
| DAST | ”Ứng dụng đang chạy của tôi có hành xử an toàn khi bị tấn công không?” | Broken auth, injection có thể tiếp cận qua HTTP, cấu hình sai, OWASP Top 10 | Nhắm vào instance đã deploy (staging/review app), chậm hơn | Cần target đang chạy; độ phủ code giới hạn ở các đường được thực thi |
| SCA | ”Dependency của tôi có an toàn không?” | CVE đã biết trong dependency trực tiếp/transitive, vi phạm license | Trên mỗi build, và liên tục khi CVE mới được công bố | Không bắt được lỗ hổng chưa có trong CVE database (0-day), hay việc lạm dụng một thư viện an toàn |
| IaC scanning | ”Hạ tầng của tôi có được định nghĩa an toàn không?” | Security group mở, bucket công khai, IAM quá rộng quyền, thiếu mã hóa | Trên mỗi commit/PR chạm vào IaC, nhanh | Không kiểm tra được sự lệch (drift) thực tế giữa hạ tầng đã deploy và định nghĩa IaC |
| Secrets scanning | ”Tôi có commit nhầm credential không?” | API key, token, private key, password trong code hoặc lịch sử | Pre-commit và trên mỗi push, rất nhanh | Có thể bỏ sót secret có định dạng/entropy khác thường; không bắt được secret chỉ được inject lúc runtime |
| Container image scanning | ”Image đã build của tôi có an toàn để chạy không?” | Package OS/app có lỗ hổng trong image, chạy quyền root, Dockerfile viết không tốt | Sau khi build, trước khi push/deploy | Không bắt được lỗ hổng ở logic ứng dụng (đó là việc của SAST/DAST) |
Không nhóm nào thay thế được nhóm khác — một pipeline trưởng thành chạy tất cả, mỗi nhóm đóng vai trò một lớp defense-in-depth khác nhau áp dụng riêng cho quá trình build/release.
Khái niệm chính
Một pipeline GitHub Actions thực tế: build → scan → gate → deploy
Workflow dưới đây ghép SAST, SCA, secrets scanning, container scanning, sinh SBOM, và ký artifact vào một pipeline duy nhất, gate job deploy dựa trên việc mọi scan đều pass. Pipeline dùng OIDC để assume một cloud role thay vì credentials tĩnh, và pin action để ví dụ sát thực tế (SHA rút gọn được dùng ở đây cho dễ đọc — trong thực tế nên pin theo SHA đầy đủ 40 ký tự).
name: build-scan-deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read # least privilege by default; elevate per-job as needed
id-token: write # required for OIDC federation to cloud provider
security-events: write # required to upload SARIF to the Security tab
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}/app
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4 # pin to full commit SHA in production
- name: Set up build tooling
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies (locked, no scripts)
run: npm ci --ignore-scripts
- name: Build image
id: build
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
outputs: type=docker,dest=/tmp/app-image.tar
- name: Upload image artifact for downstream jobs
uses: actions/upload-artifact@v4
with:
name: app-image
path: /tmp/app-image.tar
retention-days: 1
sast:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Run CodeQL analysis
uses: github/codeql-action/analyze@v3 # uploads SARIF automatically
- name: Semgrep scan
uses: semgrep/semgrep-action@v1
with:
config: p/ci # curated default ruleset
secrets-scan:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so gitleaks can scan past commits too
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true
# fails the job (non-zero exit) if any secret is detected
sca:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Dependency vulnerability scan (Trivy fs mode)
uses: aquasecurity/trivy-action@0.24.0
with:
scan-type: fs
scan-ref: .
severity: CRITICAL,HIGH
exit-code: "1" # fail the build on critical/high findings
format: sarif
output: trivy-fs.sarif
- name: Upload SCA results to Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-fs.sarif
container-scan:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/download-artifact@v4
with:
name: app-image
path: /tmp
- name: Load image for scanning
run: docker load --input /tmp/app-image.tar
- name: Scan built image (Trivy)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: "1"
format: sarif
output: trivy-image.sarif
input: /tmp/app-image.tar
- name: Upload image scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-image.sarif
- name: Generate SBOM (SPDX, via Syft)
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE_NAME }}:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.spdx.json
# deploy only runs once every scanning gate above has passed
deploy:
needs: [sast, secrets-scan, sca, container-scan]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # environment protection rule = manual approval gate
permissions:
contents: read
id-token: write # OIDC token for cloud auth, no static keys
packages: write
steps:
- uses: actions/download-artifact@v4
with:
name: app-image
path: /tmp
- name: Load and push image
run: |
docker load --input /tmp/app-image.tar
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign image (keyless, via Sigstore/Fulcio + Rekor)
run: cosign sign --yes ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Attach SBOM attestation to image
uses: actions/download-artifact@v4
with:
name: sbom
path: .
- run: cosign attest --yes --predicate sbom.spdx.json --type spdxjson ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Assume cloud deploy role via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gh-actions-deploy
aws-region: us-east-1
- name: Deploy
run: ./scripts/deploy.sh ${{ env.IMAGE_NAME }}:${{ github.sha }}
Vài quyết định thiết kế đáng chú ý:
permissionsđược giới hạn theo từng job, không phải một mức tối đa dùng chung. Khối top-level đặt mặc định hạn chế; mỗi job chỉ nâng quyền đúng cái nó cần (ví dụ chỉdeploycóid-token: writecho một mục đích khác so với việcsastdùng cùng permission đó để upload SARIF — tên permission trong GitHub Actions dùng chung nhưng quyền cấp cho mỗi job là độc lập).- Định dạng SARIF được dùng để upload kết quả SAST/SCA/image-scan lên tab “Security” gốc của GitHub, tạo một nơi thống nhất (kiểu single pane of glass “nhà nghèo”) để triage finding từ nhiều công cụ thay vì rải rác trên nhiều dashboard riêng.
exit-code: "1"trong các bước Trivy chính là thứ thực sự làm fail build khi có finding critical/high — nếu không có nó, Trivy chỉ report chứ không gate.- Job
deployphụ thuộc vào mọi job scanning, và thêm nữa dùng GitHubenvironmentvới required reviewers được cấu hình (trong repo settings), tạo một gate phê duyệt của con người bên trên các gate tự động, riêng cho production — gate tự động cho các finding khách quan, gate con người cho các quyết định rủi ro mang tính chủ quan/kinh doanh. - OIDC (
id-token: write+configure-aws-credentials) nghĩa là không cần bất kỳ AWS access key dài hạn nào tồn tại dưới dạng repository secret. - Việc ký và đính kèm SBOM attestation chỉ diễn ra sau khi mọi scan đã pass, và được gắn vào chính digest cụ thể đã được scan — ký một image không có nghĩa “cái này an toàn”, mà có nghĩa “đúng artifact byte-for-byte này là cái đã pass pipeline của chúng ta”, đó mới là đảm bảo thực sự mà supply chain security hướng tới.
Supply chain security: SBOM
SBOM (Software Bill of Materials) là một bản kiểm kê có cấu trúc, máy đọc được, liệt kê mọi thứ cấu thành nên một phần mềm — dependency trực tiếp và transitive, phiên bản, license, và nguồn gốc của chúng. SBOM trả lời câu hỏi “thực sự bên trong artifact này có gì?”, điều rất quan trọng khi một CVE mới được công bố (ví dụ Log4Shell): có SBOM, bạn có thể grep/query “ai đang dùng log4j-core phiên bản X” trên toàn bộ fleet trong vài phút thay vì phải audit thủ công từng service.
| Định dạng | Được duy trì bởi | Điểm mạnh nổi bật |
|---|---|---|
| SPDX (Software Package Data Exchange) | Linux Foundation, chuẩn ISO/IEC 5962:2021 | Metadata license-compliance mạnh, tooling trưởng thành, được chuẩn hóa ISO |
| CycloneDX | OWASP | Thiết kế tập trung vào bảo mật, hỗ trợ native cho dữ liệu vulnerability (VEX) và provenance, nhẹ hơn |
Cả hai đều được hỗ trợ rộng rãi bởi các công cụ như Syft, Trivy, và các trình sinh SBOM riêng theo ngôn ngữ (ví dụ cyclonedx-npm, cyclonedx-gomod). Sinh SBOM ngay tại thời điểm build và đính kèm vào release (như một build artifact và/hoặc một attestation đã ký, giống trong pipeline ở trên) cho phép người tiêu thụ downstream — kể cả chính đội incident response tương lai của bạn — trả lời “chúng ta có bị ảnh hưởng bởi CVE-X không” mà không cần scan lại từ đầu.
Supply chain security: provenance, attestation, và SLSA
Provenance là metadata có thể xác minh về cách một artifact được build: từ commit source nào, bởi build system nào, dùng bước build nào, trên platform nào — thay vì tin một artifact chỉ vì nó xuất hiện đúng chỗ trong registry với đúng tên. Attestation là một tuyên bố đã được ký khẳng định một sự thật về artifact (ví dụ “SBOM này thuộc về digest image này”, hoặc “image này được build bởi đúng workflow run CI cụ thể này từ đúng commit cụ thể này”), thường được mã hóa bằng định dạng attestation in-toto.
SLSA (Supply-chain Levels for Software Artifacts), đọc là “salsa”, là một framework (ban đầu từ Google, nay thuộc OpenSSF) định nghĩa các mức độ toàn vẹn supply chain tăng dần:
| Mức SLSA | Yêu cầu | Chống lại điều gì |
|---|---|---|
| Build L0 | Không có đảm bảo nào | — |
| Build L1 | Build process được script hóa/tự động và sinh ra provenance | Các bước build thủ công, không tài liệu hóa, mang tính ngẫu nhiên |
| Build L2 | Provenance được sinh bởi một hosted build service và được ký | Việc giả mạo provenance sau khi build xong |
| Build L3 | Build chạy trong môi trường isolated, hardened; provenance không thể giả mạo được kể cả bởi chính người dùng của build service | Một attacker có quyền truy cập vào định nghĩa build can thiệp vào chính build process |
GitHub Actions có thể sinh provenance tuân thủ SLSA bằng reusable workflow slsa-framework/slsa-github-generator, workflow này chạy build thực tế trong một generator workflow isolated, do GitHub kiểm soát mà repository gọi nó không thể can thiệp trực tiếp — thỏa mãn yêu cầu “không thể giả mạo” ở các mức SLSA cao hơn. Người tiêu thụ sau đó có thể verify provenance bằng slsa-verifier trước khi tin một artifact tải về, kiểm tra rằng nó thực sự được build từ đúng source repository và workflow như mong đợi, chứ không chỉ là một artifact có đúng tên file tồn tại ở đâu đó.
Supply chain security: ký artifact với Sigstore
Sigstore là một dự án mã nguồn mở (nay thuộc OpenSSF) giúp việc ký artifact trở nên dễ tiếp cận mà không gặp phải nỗi đau truyền thống của việc quản lý private key dài hạn, thông qua ba thành phần:
- Cosign — công cụ CLI để ký và verify container image, SBOM, và các artifact khác (được dùng trong ví dụ pipeline ở trên).
- Fulcio — một certificate authority cấp chứng chỉ ký ngắn hạn (10 phút) gắn với một danh tính OIDC (ví dụ “đúng workflow run GitHub Actions này, cho đúng repository này, trên đúng commit này”) thay vì một cặp key dài hạn mà developer phải tự bảo vệ mãi mãi — gọi là keyless signing.
- Rekor — một transparency log công khai, chỉ-được-thêm-vào (append-only) (tinh thần tương tự Certificate Transparency) đánh dấu thời gian cho mọi chữ ký, để sự tồn tại của một chữ ký (và thời điểm nó được tạo) có thể được xác minh công khai, độc lập, và một signer bị xâm phạm không thể âm thầm giả mạo lịch sử mà không bị phát hiện.
Tại thời điểm deploy, một admission controller (ví dụ Kyverno hoặc sigstore/policy-controller trong Kubernetes) có thể enforce chính sách “chỉ chạy image được ký bởi đúng danh tính OIDC của CI pipeline của chúng ta”, từ chối bất kỳ image nào — kể cả một image có đúng tên và tag — nếu thiếu chữ ký hợp lệ, có thể verify được từ đúng nguồn mong đợi. Điều này lấp khoảng trống giữa “chúng ta đã scan nó trong CI” và “chúng ta có thể chứng minh tại thời điểm deploy rằng cái đang chạy chính là cái đã được scan”, đó mới là thuộc tính bảo mật thực sự mà tooling supply chain hướng tới — kết quả scan sẽ vô nghĩa nếu không có gì ngăn việc deploy một image khác, chưa được scan, với cùng tag.
Dependency pinning
Pinning nghĩa là khóa một dependency (thư viện, base image, hay CI action) vào đúng một phiên bản bất biến, thay vì một tham chiếu có thể thay đổi và trỏ tới thứ khác vào ngày mai.
| Cần pin cái gì | Mutable (nên tránh) | Immutable (nên dùng) |
|---|---|---|
| Application dependency | Version range lỏng, không có lockfile (^1.2.0 không có package-lock.json) | Lockfile được commit (package-lock.json, poetry.lock, go.sum) resolve về đúng phiên bản/hash |
| Container base image | FROM node:20 (tag có thể bị trỏ lại) | FROM node:20@sha256:<digest> (content-addressed, bất biến) |
| CI/CD reusable action | uses: actions/checkout@v4 | uses: actions/checkout@<full-commit-sha> |
Pinning đánh đổi một chút tiện lợi (bạn phải chủ động bump pin) để lấy về khả năng dự đoán và audit lớn hơn nhiều: một build hôm nay và build “giống hệt” một tháng sau dùng đúng input byte-for-byte, và một tag/tài khoản maintainer upstream bị xâm phạm không thể âm thầm thay đổi thứ bạn đang dùng. Điều này cố tình tạo ra căng thẳng với automated patching (bên dưới) — cách giải quyết là vẫn pin, nhưng cập nhật pin thường xuyên và tự động qua PR do bot mở, thay vì để range không được pin.
Automated patching
Nếu pin mọi thứ mà không có gì giữ cho các pin đó được cập nhật, codebase sẽ bị đóng băng theo thời gian — vì vậy pinning và nhịp độ patch tự động là hai nửa của cùng một thực hành, không phải hai thứ đối lập nhau.
- Dependabot / Renovate tự động mở pull request bump dependency đã pin (và cả Docker base image, thậm chí SHA của GitHub Actions) khi phiên bản mới được phát hành, có thể tùy chọn auto-merge với những cái pass CI và khớp policy (ví dụ auto-merge chỉ với patch/minor, major version cần review của con người).
- Nhịp độ cập nhật base image: rebuild và redeploy container image theo lịch (ví dụ hàng tuần) ngay cả khi không có thay đổi code ứng dụng, để các patch bảo mật ở tầng OS trong base image đến được production đều đặn thay vì chỉ khi developer tình cờ đụng vào service đó.
- Cân bằng giữa ổn định và cập nhật (stability vs. currency): theo đuổi “luôn dùng bản mới nhất” thuần túy sẽ có nguy cơ breaking change và regression chưa test lọt vào production; theo đuổi “không bao giờ đụng vào dependency đang chạy tốt” thuần túy sẽ tích lũy CVE chưa vá và khiến các lần nâng cấp về sau lớn hơn, rủi ro hơn. Một điểm cân bằng khả thi:
- Tự động merge các bump patch/minor pass toàn bộ CI (rủi ro thấp, khối lượng cao).
- Gom nhóm và review thủ công các bump major version (rủi ro breaking change cao hơn, khối lượng thấp hơn).
- Ưu tiên xử lý ngay (trong ngày) bất kỳ bump dependency nào vá một CVE critical/đang bị khai thác tích cực, bỏ qua hoàn toàn nhịp độ thông thường.
- Theo dõi một SLA về độ “cũ” tối đa (ví dụ “không dependency nào được phép chậm hơn 90 ngày so với bản patch mới nhất”) như một chính sách rõ ràng, được giám sát, thay vì chỉ là một hy vọng ngầm.
Chiến lược gating: fail vs. warn, và tránh scanner fatigue
Không phải finding nào cũng nên chặn build — một pipeline fail trên mọi finding mức low kiểu linting sẽ huấn luyện developer bỏ qua hoặc lách qua nó hoàn toàn (“scanner fatigue”), điều này phá hỏng mục đích ban đầu còn tệ hơn cả việc không scan gì cả. Một chiến lược gating khả thi cần dựa trên rủi ro và nhận biết môi trường:
| Mức độ nghiêm trọng | Trên feature branch/PR | Khi merge vào main | Trước khi deploy production |
|---|---|---|---|
| Critical / đang bị khai thác tích cực | Fail build | Fail build | Fail deploy, không có ngoại lệ |
| High | Fail build | Fail build | Fail deploy; muốn override phải có exception được ghi lại rõ ràng, có thời hạn |
| Medium | Cảnh báo / annotate PR, không chặn | Cảnh báo, đưa vào backlog kèm SLA | Cảnh báo, nhưng vẫn cho phép deploy |
| Low / thông tin | Chỉ hiển thị trong tab Security | Chỉ hiển thị | Không phải là gate |
Các biện pháp thực tế để giữ điều này bền vững:
- Suppress kèm lý do và hạn dùng — một exception được ghi lại (ví dụ entry trong
.trivyignore,.semgrepignore) phải kèm lý do tại sao được suppress và ngày review/hết hạn, không phải một bypass vĩnh viễn âm thầm; các suppression đã cũ nên tự nó trở thành một finding cần xem lại. - Baseline finding hiện có, gate trên finding mới. Khi lần đầu đưa một scanner vào một codebase đã trưởng thành, gate trên toàn bộ finding hiện có thường tạo ra một backlog không thể xử lý nổi ngay ngày đầu tiên. Hãy baseline các finding hiện tại như “nợ kỹ thuật đã chấp nhận” kèm kế hoạch khắc phục, và chỉ gate nghiêm ngặt trên các finding mới từ thời điểm đó trở đi — đây là cách hiệu quả nhất để đưa scanning vào mà không gây “nổi loạn”.
- Tune ruleset trước khi enforce. Chạy scanner ở chế độ warn-only/report-only trước, loại bỏ các rule không phù hợp với ngữ cảnh của bạn (một rule nhiều false-positive còn tệ hơn không có rule), và chỉ sau đó mới chuyển sang chế độ blocking.
- Gom mọi finding về một nơi. Đưa SARIF từ mọi scanner vào tab Security của GitHub (như trong ví dụ pipeline) hoặc một nền tảng vulnerability management chuyên dụng (ví dụ DefectDojo) để developer có một hàng đợi duy nhất để triage thay vì năm giao diện công cụ rời rạc.
- Đặt SLA khắc phục theo mức độ nghiêm trọng (ví dụ critical: 24-48h, high: 7 ngày, medium: 30 ngày) và báo cáo mức độ tuân thủ SLA, để “gate” không phải là đòn bẩy duy nhất — còn có trách nhiệm giải trình của tổ chức để thực sự sửa những gì đã tìm thấy.
Best Practices
- Đối xử với thay đổi cấu hình pipeline như thay đổi production. Yêu cầu review chính các workflow file (
.github/workflows/*), không chỉ code ứng dụng — một thay đổi độc hại hoặc bất cẩn vào cấu hình CI thường là con đường trực tiếp hơn để xâm phạm hệ thống so với thay đổi vào app. - Không bao giờ để pull request từ fork chạy với cùng secrets như branch tin cậy. Chỉ dùng
pull_request_targetkhi bạn hiểu rõ rủi ro của nó, và ưu tiên dùng workflow riêng, quyền tối thiểu cho các đóng góp không tin cậy. - Pin mọi thứ không phải code của chính bạn — Actions, base image, và ưu tiên lockfile cho dependency — và cập nhật pin qua PR tự động của bot có review, thay vì để nổi trôi.
- Sinh SBOM trên mỗi build, không chỉ ở thời điểm release, và lưu nó cùng artifact để có thể trả lời “chúng ta có bị ảnh hưởng không” ngay khi một CVE mới xuất hiện, cho bất kỳ build lịch sử nào.
- Ký những gì bạn ship và verify những gì bạn chạy. Ký mà không enforce tại thời điểm deploy (admission control) thì không cung cấp bảo vệ thực sự nào — một attacker có thể deploy một image chưa ký sẽ bỏ qua toàn bộ khoản đầu tư vào việc ký.
- Ưu tiên OIDC/workload identity federation thay vì cloud credentials tĩnh trong CI, loại bỏ hoàn toàn một loại secret dài hạn có thể bị lộ.
- Gate theo rủi ro, không theo số lượng. Fail build trên finding critical/high; không fail trên mọi finding thông tin, nếu không developer sẽ tìm cách lách qua gate hoàn toàn.
- Baseline trước khi chặn. Khi thêm scanner mới vào codebase hiện có, gate trên finding mới trước và trả dần backlog hiện có theo lịch trình được theo dõi — một rollout kiểu “fail mọi thứ ngay lập tức” sẽ khiến scanner bị tắt đi chỉ sau tuần thứ hai.
- Giữ nhịp độ patch được tự động hóa và “nhàm chán”. Auto-merge các bump dependency rủi ro thấp pass CI; dành sự chú ý của con người cho major version và bất cứ thứ gì chạm vào authentication, cryptography, hay xử lý dữ liệu.
- Giả định rằng pipeline cuối cùng sẽ trở thành mục tiêu — thiết kế sao cho một bước bị xâm phạm duy nhất không thể chạm tới production mà không bị phát hiện (least privilege, ký + verify, gate phê duyệt của con người cho deploy production).
Tài liệu tham khảo
- roadmap.sh — DevSecOps Roadmap
- OWASP — DevSecOps Guideline
- SLSA — Supply-chain Levels for Software Artifacts
- Sigstore — cosign, Fulcio, Rekor documentation
- SPDX — Software Package Data Exchange specification
- CycloneDX — OWASP Bill of Materials standard
- OpenSSF — Open Source Security Foundation scorecard and best practices
- NIST SP 800-218 — Secure Software Development Framework (SSDF)
Part of the DevSecOps Roadmap knowledge base.
Overview
CI/CD and supply chain security is the operational core of DevSecOps — it is where “shift-left” stops being a slogan and becomes concrete automation running on every commit. A build pipeline is not just an assembly line; it is a privileged system with access to source code, secrets, cloud credentials, and the ability to push artifacts straight into production. If an attacker compromises the pipeline itself, every security control downstream (code review, testing, monitoring) can be bypassed entirely, because the attacker is now shipping their own artifact through your trusted process.
This is not theoretical. The SolarWinds Orion breach (2020) involved attackers implanting malicious code directly into the build process, so that legitimately signed, officially released software carried a backdoor to ~18,000 customers. The Codecov bash uploader compromise (2021) modified a CI script that thousands of pipelines executed, exfiltrating environment variables (including secrets) from every pipeline that ran it. The xz-utils backdoor (2024) showed that even a widely trusted open-source compression library’s build scripts could be subverted by a patient, socially-engineered maintainer takeover. Each of these was a supply chain attack: the target was never the victim directly, but a trusted upstream component — source repository, build system, dependency, or distribution channel — that the victim implicitly trusted.
This note covers two tightly linked concerns:
- Pipeline hardening — securing the CI/CD system itself (runners, secrets, branch protection, commit integrity) so attackers can’t tamper with the build process.
- Supply chain security — securing everything the pipeline consumes and produces: source code provenance, third-party dependencies, build outputs, SBOMs, signatures, and attestations, so that consumers of your software can verify what it contains and where it came from.
The scanning toolchain (SAST, DAST, SCA, IaC scanning, secrets scanning, container scanning) is the automation layer that makes both of these continuous rather than a one-time audit. The rest of this note works through each layer, ties them together in a real GitHub Actions pipeline, and covers the supply chain concepts (SBOM, SLSA, Sigstore) that let you prove — not just claim — that an artifact is safe to deploy.
Related topics: Secure Coding and Web Application Security, Container and Kubernetes Security, DevOps — CI/CD, DevOps — Infrastructure as Code.
Fundamentals
Build pipeline hardening
The pipeline is a high-value target precisely because it has broad access by design: it checks out source code, pulls dependencies, often holds cloud deployment credentials, and produces the artifact that ends up running in production. Hardening it means applying the same access-control and isolation discipline you’d apply to a production system.
| Control | What it does | Why it matters |
|---|---|---|
| Least-privilege CI runners | Scope each job’s credentials (cloud IAM role, registry token, deploy key) to only what that specific job needs, using short-lived, scoped tokens instead of long-lived static ones | A compromised job (e.g., via a malicious dependency) can only do as much damage as its narrow permission set allows |
| Ephemeral / isolated build environments | Use fresh, single-use containers or VMs per job (GitHub-hosted runners, ephemeral Kubernetes pod runners, disposable EC2) instead of long-lived, shared build servers | Prevents one job’s compromise or leftover state from persisting and affecting the next build; removes a static target for attackers to implant persistence on |
| Self-hosted runner isolation | If self-hosting runners (for cost, network access, or specialized hardware), never share runners between public/fork PRs and trusted branches, and never reuse a runner across untrusted workloads | A malicious pull request from a fork can otherwise execute arbitrary code with access to secrets meant for trusted branches (a very common real-world GitHub Actions misconfiguration) |
| Signed commits | Require commits (and ideally tags) to be signed with GPG, SSH, or Sigstore’s gitsign, and enforce verification on the server (e.g., GitHub “Require signed commits” branch rule) | Proves a commit really came from the claimed author’s key, making it much harder to silently inject malicious history, and provides non-repudiation for audits |
| Protected/branch policies | Require pull request review (with CODEOWNERS for sensitive paths), passing status checks, up-to-date branches, linear history, and disable force-push/deletion on main/release branches | Prevents a single compromised or malicious account from pushing directly to production-bound branches, and ensures every change went through review + automated gates |
| Secrets never in code or logs | Store secrets in a dedicated secret manager (GitHub/GitLab CI secrets, HashiCorp Vault, AWS Secrets Manager) — never in source, config files, or Docker layers; mask secret values in log output; scrub debug/verbose logging that might echo environment variables | Source repos and CI logs are frequently long-lived, widely readable, and backed up/cached in places you don’t control — a leaked secret there can outlive the incident that caused it by years |
| Prefer OIDC federation over static cloud credentials | Use short-lived, workload-identity-federated tokens (GitHub OIDC → AWS IAM role / GCP Workload Identity Federation / Azure federated credentials) instead of storing long-lived cloud access keys as CI secrets | Removes an entire class of secret (the static cloud key) that can be leaked, exfiltrated, or reused outside the pipeline; tokens are scoped, short-lived, and tied cryptographically to a specific workflow run |
| Pin third-party Actions/plugins to a commit SHA | Reference reusable workflow steps (e.g., actions/checkout@<full-sha>) by immutable commit hash, not a mutable tag like @v4 | A tag can be moved by the action’s maintainer (or an attacker who compromises their account) to point at malicious code without changing your workflow file at all |
| Require approval for workflows from forks | Configure “Require approval for first-time contributors” / “Require approval for all outside collaborators” on public repos | Prevents drive-by pull requests from executing arbitrary CI code (e.g., cryptomining, secret exfiltration) before a maintainer reviews them |
The unifying principle across all of these is: treat the pipeline as a production system, not an internal convenience. Anyone who can modify pipeline configuration, add a dependency, or merge to a protected branch has, transitively, some ability to influence what ships to production — access controls should reflect that.
The security scanning toolchain
Automated scanning is what makes shift-left continuous instead of a one-off audit. Each category below answers a different question, runs at a different point in the lifecycle, and catches a different class of issue — none of them is a substitute for the others.
SAST — Static Application Security Testing
SAST analyzes source code (or bytecode/binaries) without executing it, looking for insecure patterns: SQL string concatenation that could allow injection, use of weak cryptographic primitives, unsafe deserialization, hardcoded credentials, missing input validation, and known-dangerous API usage. It runs fastest and earliest — typically on every pull request, sometimes even in the IDE before commit.
- Semgrep — rule-based, lightweight, fast; large open-source and community rule sets per language/framework; easy to write custom rules for org-specific anti-patterns; good signal-to-noise ratio when tuned.
- CodeQL — GitHub’s semantic code analysis engine; treats code as queryable data (a “database” of the codebase) and runs deep, cross-file dataflow queries; powers GitHub’s built-in “code scanning” alerts; excellent for tracing tainted data across function boundaries.
- Others: SonarQube (also covers code quality/maintainability), Checkmarx, Fortify.
SAST strength: finds issues early, before code even runs, with full visibility into logic. SAST weakness: no runtime context, so it can’t see how the app actually behaves once deployed (misconfigured runtime permissions, real network exposure), and tends to produce false positives on patterns that are actually safe in context.
DAST — Dynamic Application Security Testing
DAST tests a running application from the outside, the same way an external attacker would — sending malicious inputs to live HTTP endpoints, fuzzing parameters, probing for injection, broken authentication, misconfigurations, and other OWASP Top 10-class issues, without any knowledge of the source code (black-box). It necessarily runs later in the pipeline, against a deployed instance (staging, a review app, or an ephemeral environment spun up for the pipeline run).
- OWASP ZAP — free, widely used, scriptable, has a CI-friendly “baseline scan” and full “active scan” mode.
- Burp Suite — industry-standard for manual and semi-automated web app testing, with a CI-integrable Enterprise edition.
See Container and Kubernetes Security for hardening the runtime environment DAST scans against. Detailed tool usage: see the security testing tools note (planned; not yet published in this knowledge base — DAST tool setup and scan tuning will live there).
SCA — Software Composition Analysis
SCA scans your declared and transitive dependencies (npm, pip, Maven, Go modules, etc.) against vulnerability databases (NVD, GitHub Advisory Database, OSV) to flag known CVEs, and often also flags problematic open-source licenses (e.g., GPL in a proprietary codebase). This matters enormously because the vast majority of a modern application’s code is not code you wrote — it’s dependencies, and dependencies-of-dependencies.
- Dependabot — built into GitHub; opens automated pull requests bumping vulnerable dependencies to a patched version, plus alerts on the Security tab.
- Snyk — commercial SCA (with a free tier); broader ecosystem coverage, reachability analysis (flags whether the vulnerable code path is actually called), and container/IaC scanning in the same product.
- Others: OWASP Dependency-Check, Trivy (also does container/IaC), Renovate (patch automation focus, see Automated Patching below).
SCA is where most real-world breaches originate in practice — a vulnerable dependency (e.g., Log4Shell in log4j, 2021) can be far more impactful than a bug in your own code, because a single vulnerable library is often used by thousands of downstream projects simultaneously. See secure coding and dependency risk management guidance in Secure Coding and Web Application Security.
IaC scanning
Infrastructure as Code scanning statically analyzes Terraform, CloudFormation, Kubernetes manifests, and similar declarative infra definitions for insecure configuration before anything is provisioned — an open security group, an unencrypted S3 bucket, an overly permissive IAM policy, a container running as root. This is essentially “SAST for infrastructure,” and it belongs in the same pipeline stage as SAST since it’s static and fast.
- Checkov — broad, policy-as-code (rego-adjacent), supports Terraform/CloudFormation/Kubernetes/Serverless/Dockerfiles.
- tfsec (now part of Trivy) — Terraform-focused, fast, simple to adopt incrementally.
- Others: KICS, Terrascan.
Full coverage of IaC design, state management, and modules lives in DevOps — Infrastructure as Code; this note focuses only on the scanning/gating aspect as it plugs into the pipeline.
Secrets scanning
Secrets scanning detects hardcoded credentials, API keys, private keys, and tokens committed to source — both in the current working tree and, critically, in commit history (a secret removed in a later commit is still exposed to anyone who can git log/git clone the full history). This is one of the highest ROI, lowest-noise scans, because a match is very rarely a false positive.
- gitleaks — fast, regex + entropy-based, easy to run as a pre-commit hook and in CI; can scan full git history.
- TruffleHog — similar goal, adds live credential verification (checks whether a found key is actually still active against the real provider API), which helps triage: a dead/rotated key is lower priority than a live one.
- GitHub’s native secret scanning (with push protection) blocks known secret patterns from ever being pushed in the first place — the earliest possible point in the pipeline.
Best practice is to run secrets scanning before the commit even leaves the developer’s machine (pre-commit hook), again on every pull request, and periodically as a full-history scan, since new detection patterns are added over time and may catch secrets that predate the rule.
Container image scanning
Container image scanning inspects a built image’s OS packages, language-level dependencies, and configuration for known vulnerabilities and misconfiguration (running as root, missing HEALTHCHECK, unnecessary capabilities, exposed secrets baked into layers). It runs after the image is built, before it’s pushed to a registry or deployed.
- Trivy — fast, all-in-one (OS packages, app dependencies, IaC, secrets, Kubernetes manifests, SBOM generation) — a strong default choice for a single tool covering multiple scan categories.
- Grype — Anchore’s scanner, pairs naturally with Syft for SBOM generation.
- Clair — used by several container registries (Quay, Harbor) for scan-on-push.
Full container and Kubernetes hardening guidance (base image selection, non-root users, read-only filesystems, admission control, runtime security) is covered in Container and Kubernetes Security; this note focuses on where image scanning sits in the pipeline and how it gates deployment.
Tool category comparison
| Category | Question it answers | What it catches | Runs when | Blind spot |
|---|---|---|---|---|
| SAST | ”Is my code written safely?” | Injection patterns, weak crypto, unsafe deserialization, hardcoded secrets in logic | On every commit/PR, fastest feedback | No runtime context; false positives on safe-in-context code |
| DAST | ”Does my running app behave safely under attack?” | Broken auth, injection reachable over HTTP, misconfigurations, OWASP Top 10 | Against a deployed instance (staging/review app), slower | Needs a running target; limited code coverage vs. exercised paths only |
| SCA | ”Are my dependencies safe?” | Known CVEs in direct/transitive dependencies, license violations | On every build, and continuously as new CVEs are published | Doesn’t catch vulnerabilities not yet in a CVE database (0-days), or misuse of a safe library |
| IaC scanning | ”Is my infrastructure defined safely?” | Open security groups, public buckets, over-privileged IAM, missing encryption | On every commit/PR touching IaC, fast | Doesn’t validate actual deployed drift from the IaC definition |
| Secrets scanning | ”Did I commit a credential?” | API keys, tokens, private keys, passwords in code or history | Pre-commit and on every push, very fast | Can miss secrets in unusual formats/entropy; doesn’t catch secrets injected only at runtime |
| Container image scanning | ”Is my built image safe to run?” | Vulnerable OS/app packages in the image, root user, bad Dockerfile practices | After build, before push/deploy | Doesn’t catch application-logic vulnerabilities (that’s SAST/DAST’s job) |
No single category replaces another — a mature pipeline runs all of them, each acting as a different layer of defense-in-depth applied specifically to the build/release process.
Key Concepts
A real GitHub Actions pipeline: build → scan → gate → deploy
The workflow below stitches SAST, SCA, secrets scanning, container scanning, SBOM generation, and artifact signing into a single pipeline, gating the deploy job on all scans passing. It uses OIDC to assume a cloud role instead of static credentials, and pins actions to keep the example realistic (shortened SHAs shown for readability — pin to full 40-character SHAs in practice).
name: build-scan-deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read # least privilege by default; elevate per-job as needed
id-token: write # required for OIDC federation to cloud provider
security-events: write # required to upload SARIF to the Security tab
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}/app
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4 # pin to full commit SHA in production
- name: Set up build tooling
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies (locked, no scripts)
run: npm ci --ignore-scripts
- name: Build image
id: build
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
outputs: type=docker,dest=/tmp/app-image.tar
- name: Upload image artifact for downstream jobs
uses: actions/upload-artifact@v4
with:
name: app-image
path: /tmp/app-image.tar
retention-days: 1
sast:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Run CodeQL analysis
uses: github/codeql-action/analyze@v3 # uploads SARIF automatically
- name: Semgrep scan
uses: semgrep/semgrep-action@v1
with:
config: p/ci # curated default ruleset
secrets-scan:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so gitleaks can scan past commits too
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true
# fails the job (non-zero exit) if any secret is detected
sca:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Dependency vulnerability scan (Trivy fs mode)
uses: aquasecurity/trivy-action@0.24.0
with:
scan-type: fs
scan-ref: .
severity: CRITICAL,HIGH
exit-code: "1" # fail the build on critical/high findings
format: sarif
output: trivy-fs.sarif
- name: Upload SCA results to Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-fs.sarif
container-scan:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/download-artifact@v4
with:
name: app-image
path: /tmp
- name: Load image for scanning
run: docker load --input /tmp/app-image.tar
- name: Scan built image (Trivy)
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: "1"
format: sarif
output: trivy-image.sarif
input: /tmp/app-image.tar
- name: Upload image scan results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-image.sarif
- name: Generate SBOM (SPDX, via Syft)
uses: anchore/sbom-action@v0
with:
image: ${{ env.IMAGE_NAME }}:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
- name: Upload SBOM artifact
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.spdx.json
# deploy only runs once every scanning gate above has passed
deploy:
needs: [sast, secrets-scan, sca, container-scan]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # environment protection rule = manual approval gate
permissions:
contents: read
id-token: write # OIDC token for cloud auth, no static keys
packages: write
steps:
- uses: actions/download-artifact@v4
with:
name: app-image
path: /tmp
- name: Load and push image
run: |
docker load --input /tmp/app-image.tar
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign image (keyless, via Sigstore/Fulcio + Rekor)
run: cosign sign --yes ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Attach SBOM attestation to image
uses: actions/download-artifact@v4
with:
name: sbom
path: .
- run: cosign attest --yes --predicate sbom.spdx.json --type spdxjson ${{ env.IMAGE_NAME }}:${{ github.sha }}
- name: Assume cloud deploy role via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gh-actions-deploy
aws-region: us-east-1
- name: Deploy
run: ./scripts/deploy.sh ${{ env.IMAGE_NAME }}:${{ github.sha }}
Key design choices worth calling out:
permissionsare scoped per job, not globally maximal. The top-level block sets a restrictive default; each job only elevates what it specifically needs (e.g., onlydeploygetsid-token: writefor a different purpose thansast’s use of the same permission for SARIF upload — GitHub Actions permission names are shared but each job’s grant is independent).- SARIF format is used to upload SAST/SCA/image-scan results to GitHub’s native “Security” tab, giving one unified place (a poor-man’s single pane of glass) to triage findings from multiple tools instead of scattering them across separate dashboards.
exit-code: "1"on Trivy steps is what actually fails the build on critical/high findings — without it, Trivy would only report, not gate.- The
deployjob depends on every scanning job, and additionally uses a GitHubenvironmentwith required reviewers configured (in repo settings), giving a human approval gate on top of the automated ones for production specifically — automated gates for objective findings, human gates for subjective/business risk calls. - OIDC (
id-token: write+configure-aws-credentials) means no long-lived AWS access key ever needs to exist as a repository secret. - Signing and SBOM attestation happen only after all scans pass, and are attached to the specific digest that was scanned — signing an image doesn’t mean “this is safe,” it means “this exact byte-for-byte artifact is the one that passed our pipeline,” which is the actual guarantee supply chain security is trying to provide.
Supply chain security: SBOMs
A Software Bill of Materials (SBOM) is a structured, machine-readable inventory of everything that makes up a piece of software — direct and transitive dependencies, their versions, licenses, and origins. It answers the question “what is actually inside this artifact?”, which matters enormously when a new CVE is disclosed (e.g., Log4Shell): with an SBOM, you can grep/query for “who has log4j-core version X” across your entire fleet in minutes instead of manually auditing every service.
| Format | Maintained by | Notable strengths |
|---|---|---|
| SPDX (Software Package Data Exchange) | Linux Foundation, ISO/IEC 5962:2021 standard | Strong license-compliance metadata, mature tooling, ISO-standardized |
| CycloneDX | OWASP | Security-focused design, native support for vulnerability (VEX) and provenance data, lighter-weight |
Both are widely supported by tools like Syft, Trivy, and language-native generators (e.g., cyclonedx-npm, cyclonedx-gomod). Generating an SBOM at build time and attaching it to the release (as a build artifact and/or a signed attestation, as in the pipeline above) lets downstream consumers — including your own future incident responders — answer “are we affected by CVE-X” without re-scanning from scratch.
Supply chain security: provenance, attestation, and SLSA
Provenance is verifiable metadata about how an artifact was built: from which source commit, by which build system, using which build steps, on which platform — as opposed to trusting an artifact just because it appeared in the right registry with the right name. An attestation is a signed statement asserting a fact about an artifact (e.g., “this SBOM belongs to this image digest,” or “this image was built by this specific CI workflow run from this specific commit”), typically encoded using the in-toto attestation format.
SLSA (Supply-chain Levels for Software Artifacts), pronounced “salsa,” is a framework (originally from Google, now under OpenSSF) that defines graduated levels of supply chain integrity:
| SLSA level | Requirement | What it defends against |
|---|---|---|
| Build L0 | No guarantees | — |
| Build L1 | Build process is scripted/automated and generates provenance | Accidental, undocumented, manual build steps |
| Build L2 | Provenance is generated by a hosted build service and is signed | Tampering with provenance after the fact |
| Build L3 | Build runs in a hardened, isolated environment; provenance is non-forgeable even by the build service’s own users | An attacker with access to the build definition tampering with the build process itself |
GitHub Actions can generate SLSA-compliant provenance using the slsa-framework/slsa-github-generator reusable workflow, which runs the actual build in an isolated, GitHub-controlled generator workflow that the calling repository cannot directly tamper with — satisfying the “non-forgeable” requirement of higher SLSA levels. Consumers can then verify provenance with slsa-verifier before trusting a downloaded artifact, checking that it really was built from the expected source repository and workflow, not just that an artifact with the right filename exists somewhere.
Supply chain security: signed artifacts with Sigstore
Sigstore is an open-source project (now part of OpenSSF) that makes artifact signing accessible without the traditional pain of long-lived private key management, via three components:
- Cosign — CLI tool to sign and verify container images, SBOMs, and other artifacts (used in the pipeline example above).
- Fulcio — a certificate authority that issues short-lived (10-minute) signing certificates tied to an OIDC identity (e.g., “this GitHub Actions workflow run, for this repository, on this commit”) instead of a long-lived keypair the developer must protect forever — this is called keyless signing.
- Rekor — a public, append-only transparency log (similar in spirit to Certificate Transparency) that timestamps every signature, so a signature’s existence (and when it was created) can be publicly and independently verified, and a compromised signer can’t retroactively forge history without it being detectable.
At deploy time, an admission controller (e.g., Kyverno or sigstore/policy-controller in Kubernetes) can enforce “only run images signed by our CI pipeline’s OIDC identity,” rejecting any image — even one with the right name and tag — that lacks a valid, verifiable signature from the expected source. This closes the gap between “we scanned it in CI” and “we can prove at deploy time that what’s running is what we scanned,” which is the actual security property supply chain tooling is trying to deliver — a scan result is worthless if nothing prevents deploying a different, unscanned image with the same tag.
Dependency pinning
Pinning means locking a dependency (library, base image, or CI action) to an exact, immutable version rather than a mutable reference that could resolve to something different tomorrow.
| What to pin | Mutable (avoid) | Immutable (prefer) |
|---|---|---|
| Application dependencies | Loose version ranges without a lockfile (^1.2.0 with no package-lock.json) | Committed lockfile (package-lock.json, poetry.lock, go.sum) resolving to exact versions/hashes |
| Container base images | FROM node:20 (tag can be repointed) | FROM node:20@sha256:<digest> (content-addressed, immutable) |
| CI/CD reusable actions | uses: actions/checkout@v4 | uses: actions/checkout@<full-commit-sha> |
Pinning trades a small amount of convenience (you must deliberately bump the pin) for a large amount of predictability and auditability: a build today and the “same” build next month use byte-for-byte identical inputs, and a compromised upstream tag/maintainer account can’t silently change what you consume. This is deliberately in tension with automated patching (below) — the resolution is to pin, but update the pin frequently and automatically via bot-opened PRs rather than leaving ranges unpinned.
Automated patching
Pinning everything would freeze a codebase in time if nothing then kept those pins current — so pinning and automated patch cadence are two halves of the same practice, not opposites.
- Dependabot / Renovate automatically open pull requests bumping pinned dependencies (and Docker base images, and even GitHub Actions SHAs) when new versions are released, optionally auto-merging ones that pass CI and match a policy (e.g., patch/minor-only auto-merge, major versions require human review).
- Base image update cadence: rebuild and redeploy container images on a schedule (e.g., weekly) even without an application code change, so OS-level security patches in the base image reach production regularly rather than only when a developer happens to touch that service.
- Balancing stability vs. currency: pure “always latest” risks breaking changes and untested regressions reaching production; pure “never touch a working dependency” accumulates unpatched CVEs and makes eventual upgrades larger and riskier. A workable middle ground:
- Auto-merge patch/minor bumps that pass full CI automatically (low risk, high volume).
- Batch and manually review major version bumps (higher risk of breaking changes, lower volume).
- Fast-track (same-day) any dependency bump that fixes a critical/actively-exploited CVE, bypassing the normal cadence entirely.
- Track a maximum staleness SLA (e.g., “no dependency more than 90 days behind latest patch release”) as an explicit, monitored policy rather than an implicit hope.
Gating strategy: fail vs. warn, and avoiding scanner fatigue
Not every finding should block a build — a pipeline that fails on every low-severity linting-style finding trains developers to ignore or bypass it entirely (“scanner fatigue”), which defeats the purpose more thoroughly than not scanning at all. A workable gating strategy is risk-based and environment-aware:
| Severity | On a feature branch/PR | On merge to main | Before production deploy |
|---|---|---|---|
| Critical / actively exploited | Fail the build | Fail the build | Fail the deploy, no override |
| High | Fail the build | Fail the build | Fail the deploy; override requires a documented, time-boxed exception |
| Medium | Warn / annotate PR, don’t block | Warn, track in backlog with an SLA | Warn, but allow deploy |
| Low / informational | Visible in the Security tab only | Visible only | Not a gate |
Practical measures to keep this sustainable:
- Suppression with justification and expiry — a documented exception (e.g.,
.trivyignore,.semgrepignoreentries) must include why it’s suppressed and a review/expiry date, not a silent permanent bypass; stale suppressions should themselves surface as a finding. - Baseline existing findings, gate on new ones. When first introducing a scanner into a mature codebase, gating on all existing findings usually produces an unmanageable backlog on day one. Baseline current findings as “accepted debt” with a remediation plan, and gate strictly only on new findings from that point forward — this is the single most effective way to introduce scanning without a mutiny.
- Tune rulesets before enforcing. Run a scanner in warn-only/report-only mode first, tune out rules that don’t fit your context (a false-positive-heavy rule is worse than no rule), and only then flip it to blocking.
- Consolidate findings into one place. Feed SARIF from every scanner into GitHub’s Security tab (as in the pipeline example) or a dedicated vulnerability management platform (e.g., DefectDojo) so developers have one queue to triage instead of five disconnected tool UIs.
- Set remediation SLAs by severity (e.g., critical: 24-48h, high: 7 days, medium: 30 days) and report on SLA compliance, so “gating” isn’t the only lever — there’s also organizational accountability for actually fixing what’s found.
Best Practices
- Treat pipeline configuration changes like production changes. Require review on workflow files themselves (
.github/workflows/*), not just application code — a malicious or careless change to CI config is often a more direct path to compromise than a change to the app. - Never let a fork’s pull request run with the same secrets as trusted branches. Use
pull_request_targetonly when you fully understand its risk, and prefer separate, minimally-privileged workflows for untrusted contributions. - Pin everything that isn’t your own code — Actions, base images, and prefer lockfiles for dependencies — and update pins via automated, reviewed bot PRs rather than leaving them floating.
- Generate an SBOM on every build, not just at release time, and store it alongside the artifact so you can answer “are we affected” the moment a new CVE drops, for any historical build.
- Sign what you ship and verify what you run. Signing without enforcement at deploy time (admission control) provides no real protection — an attacker who can deploy an unsigned image bypasses the entire signing investment.
- Prefer OIDC/workload identity federation over static cloud credentials in CI, eliminating an entire class of long-lived secrets that can leak.
- Gate by risk, not by volume. Fail builds on critical/high findings; don’t fail on every informational finding, or developers will route around the gate entirely.
- Baseline before you block. When adding a new scanner to an existing codebase, gate on new findings first and pay down the existing backlog on a tracked schedule — a big-bang “fail everything now” rollout gets the scanner disabled by week two.
- Keep patch cadence automated and boring. Auto-merge low-risk dependency bumps that pass CI; reserve human attention for major versions and anything touching authentication, cryptography, or data handling.
- Assume the pipeline will eventually be a target — design so a single compromised step can’t reach production unnoticed (least privilege, signing + verification, human approval gate on production deploys).
References
- roadmap.sh — DevSecOps Roadmap
- OWASP — DevSecOps Guideline
- SLSA — Supply-chain Levels for Software Artifacts
- Sigstore — cosign, Fulcio, Rekor documentation
- SPDX — Software Package Data Exchange specification
- CycloneDX — OWASP Bill of Materials standard
- OpenSSF — Open Source Security Foundation scorecard and best practices
- NIST SP 800-218 — Secure Software Development Framework (SSDF)