Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ jobs:
security:
name: Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload Semgrep SARIF to GitHub code scanning
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
Expand All @@ -329,13 +332,35 @@ jobs:
run: |
pip install --upgrade pip
pip install -e ".[dev]"
pip install bandit pip-audit
pip install bandit pip-audit "semgrep==1.163.0"
- name: Bandit security scan
run: bandit -r fluid_build/ -c pyproject.toml --severity-level medium -q
- name: pip-audit dependency CVE scan
# Fails the build on any known-vulnerable dependency. Keeps the
# litellm / jinja2 / h11 / cryptography CVE floors honest over time.
run: pip-audit --desc
# Semgrep custom rules — forge-cli repo-specific invariants from
# CLAUDE.md (deleted-module imports, SQL-safety helper bypass,
# copilot workspace_root confinement). Generic SAST is CodeQL's job.
- name: Semgrep custom-rule gate
env:
SEMGREP_SEND_METRICS: "off"
run: semgrep scan --config .semgrep/fluid-rules.yml --error fluid_build/
- name: Semgrep SARIF (advisory — code-scanning surface)
if: always()
env:
SEMGREP_SEND_METRICS: "off"
run: |
semgrep scan --config .semgrep/fluid-rules.yml \
--sarif --output semgrep.sarif fluid_build/ || true
- name: Upload Semgrep SARIF
# Skipped on fork PRs — their GITHUB_TOKEN cannot write
# security-events. The gate step above still runs and still blocks.
if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
uses: github/codeql-action/upload-sarif@7fc1baf373eb073c686865bd453d412d506a05a2 # v3.35.1
with:
sarif_file: semgrep.sarif
category: semgrep

license-check:
name: License Headers
Expand All @@ -347,3 +372,22 @@ jobs:
python-version: "3.12"
- name: Check license headers
run: python scripts/check_license_headers.py

# Dependency review — blocks PRs that introduce known-vulnerable
# dependencies. First-party GitHub action; requires the dependency
# graph (Settings -> Code security). A license policy can be added
# later via `deny-licenses` once the project agrees one.
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
# Only meaningful on PRs — the action diffs the dependency graph
# between the base and head refs.
if: github.event_name == 'pull_request'
permissions:
contents: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Dependency review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
61 changes: 60 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,22 @@ jobs:
test -f release-smoke/contract.fluid.yaml || { echo "::error::scaffold failed"; exit 1; }
echo "Release smoke test passed"

- name: Generate CycloneDX SBOM
# SBOM of exactly what the released wheel installs (the isolated
# venv built above). Shipped as a release artifact for supply-chain
# transparency — what JFrog Xray / CRA-minded consumers expect.
run: |
pip install "cyclonedx-bom==7.3.0"
cyclonedx-py environment /tmp/test-install \
--of JSON --output-file sbom.cdx.json

- name: Upload SBOM artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom
path: sbom.cdx.json
retention-days: 30

- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
Expand Down Expand Up @@ -456,14 +472,21 @@ jobs:
echo "$NOTES"
} > release-notes.md

- name: Download SBOM
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: sbom

- name: Create GitHub Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
name: "FLUID Build ${{ needs.quality-gate.outputs.version }}"
body_path: release-notes.md
prerelease: ${{ needs.quality-gate.outputs.is_prerelease == 'true' }}
files: dist/*
files: |
dist/*
sbom.cdx.json

# ──────────────────────────────────────────────────────────────
# 5. Build and push Docker image to GHCR
Expand Down Expand Up @@ -508,6 +531,42 @@ jobs:
type=raw,value=${{ needs.quality-gate.outputs.profile }}-latest
type=raw,value=latest,enable=${{ needs.quality-gate.outputs.is_prerelease == 'false' }}

# Build the image locally first so it can be vulnerability-scanned
# before it is ever pushed. The gha layer cache makes the push
# build below near-instant.
- name: Build image for scanning (no push)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: Dockerfile
push: false
load: true
tags: forge-cli:scan
build-args: |
PROFILE=${{ needs.quality-gate.outputs.profile }}
cache-from: type=gha
cache-to: type=gha,mode=max

# Grype (Anchore) — scans the image for OS + library CVEs that
# pip-audit cannot see (base-image packages). Pinned by version
# and verified by SHA256 — no third-party GitHub Action.
- name: Install Grype (pinned + checksum-verified)
run: |
GRYPE_VERSION=0.112.0
GRYPE_SHA256=acb14a030010fe9bdb9594b4ae108d9d14ef2f926d936aa0916dc62c89c058ea
curl -sSL -o grype.tar.gz \
"https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/grype_${GRYPE_VERSION}_linux_amd64.tar.gz"
echo "${GRYPE_SHA256} grype.tar.gz" | sha256sum -c -
tar -xzf grype.tar.gz grype
./grype version

- name: Scan image for HIGH/CRITICAL CVEs (gate)
# Fails the release on a HIGH/CRITICAL finding that has a fix
# available. `--only-fixed` excludes unpatched base-image CVEs
# (a Debian slim base always carries some) so the gate stays
# actionable rather than permanently red.
run: ./grype forge-cli:scan --fail-on high --only-fixed

- name: Build and push Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# OpenSSF Scorecard — automated supply-chain posture scoring.
#
# Runs the Scorecard checks weekly + on branch-protection changes,
# publishes the score (badge at scorecard.dev) and uploads SARIF to
# code scanning so posture regressions — action pinning, branch
# protection, build provenance — surface in Security Overview.
name: Scorecard

on:
branch_protection_rule:
schedule:
- cron: "41 5 * * 2" # weekly, Tuesday 05:41 UTC
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload SARIF to code scanning
id-token: write # publish results to the public OpenSSF API
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false

- name: Run Scorecard analysis
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true

- name: Upload Scorecard artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: scorecard-results
path: results.sarif
retention-days: 5

- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@7fc1baf373eb073c686865bd453d412d506a05a2 # v3.35.1
with:
sarif_file: results.sarif
77 changes: 77 additions & 0 deletions .semgrep/fluid-rules.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Custom Semgrep rules — forge-cli repo-specific invariants.
#
# These encode structural conventions documented in CLAUDE.md that
# generic SAST cannot express. Generic vulnerability classes (injection,
# weak crypto, taint flow, etc.) are covered by GitHub CodeQL; this file
# is ONLY for forge-cli's own conventions.
#
# Gating contract: every rule is `severity: ERROR`. CI runs
# semgrep scan --config .semgrep/fluid-rules.yml --error fluid_build/
# so a match fails the build. After editing, run:
# semgrep --validate --config .semgrep/fluid-rules.yml
rules:
# CLAUDE.md (2026-04-23): cli/execute.py and cli/compile.py were deleted.
# A regex (not an AST pattern) keeps this robust across import forms.
- id: fluid-import-deleted-module
languages: [python]
severity: ERROR
message: >-
`fluid_build.cli.execute` and `fluid_build.cli.compile` were removed.
Use `fluid_build.build_runners` (`run_builds_from_args`) instead of
`cli.execute`, and `fluid_build.cli.bundle` instead of `cli.compile`.
metadata:
category: maintainability
references:
- CLAUDE.md
pattern-regex: '(?m)^\s*(?:from|import)\s+fluid_build\.cli\.(?:execute|compile)\b'

# CLAUDE.md (SQL safety): every DDL string literal must route through
# quote_string_literal in providers/_sql_safety.py. Inline quote
# doubling diverges from the central escaper and breaks under
# non-default Snowflake escape settings.
- id: fluid-ddl-raw-quote-doubling
languages: [python]
severity: ERROR
message: >-
Inline single-quote doubling bypasses `quote_string_literal` in
`fluid_build/providers/_sql_safety.py`. Route SQL string literals
through that central helper instead of an inline `.replace()`.
metadata:
category: security
references:
- CLAUDE.md
paths:
include:
- "/fluid_build/providers/**"
exclude:
- "/fluid_build/providers/_sql_safety.py"
patterns:
- pattern: $X.replace("'", "''")

# CLAUDE.md (copilot agent loop): path-accepting @forge_tool tools must
# be confined. A tool whose signature takes `workspace_root` only gets
# it injected when the decorator declares `workspace_root_aware=True`.
- id: fluid-copilot-tool-missing-workspace-root
languages: [python]
severity: ERROR
message: >-
This @forge_tool takes a `workspace_root` parameter but its decorator
is missing `workspace_root_aware=True`. Without that flag the
dispatcher will not inject `workspace_root`, bypassing the
path-confinement boundary for an LLM-driven tool.
metadata:
category: security
references:
- CLAUDE.md
paths:
include:
- "/fluid_build/cli/forge_copilot_tools.py"
patterns:
- pattern: |
@forge_tool(...)
def $FUNC(..., workspace_root, ...):
...
- pattern-not: |
@forge_tool(..., workspace_root_aware=True, ...)
def $FUNC(..., workspace_root, ...):
...
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*

# Upgrade the packaging toolchain — the slim base ships older pip /
# setuptools / wheel that carry fixable advisories the release Grype
# gate would (correctly) flag. Keeping them current keeps the image clean.
RUN pip install --no-cache-dir --upgrade pip setuptools wheel

# ============================================
# Build stage — install from source
# ============================================
Expand Down
23 changes: 12 additions & 11 deletions fluid_build/forge/core/pipeline_systems/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"actions/setup-python@v5": "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065", # v5.6.0
"actions/upload-artifact@v4": "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", # v4.6.2
"actions/download-artifact@v4": "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093", # v4.3.0
"aquasecurity/trivy-action@v0": "aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1", # v0.35.0
"google/osv-scanner-action/osv-scanner-action@v2": "google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2", # v2.3.8
"github/codeql-action/upload-sarif@v3": "github/codeql-action/upload-sarif@7fc1baf373eb073c686865bd453d412d506a05a2", # v3.35.1
"google-github-actions/auth@v2": "google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed", # v2.1.13
"aws-actions/configure-aws-credentials@v4": "aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a", # v4.3.1
Expand Down Expand Up @@ -512,8 +512,8 @@ def _security_audit_block(self, complexity: "PipelineComplexity") -> Dict[str, A
``fluid policy-apply``, ``fluid audit --compliance``), and
* the canonical step name + comment-banner with the keywords
(``security scan``, ``vulnerability``, ``policy``, ``audit``,
``trivy``, ``sast``) so CI assertions and operator search both
succeed.
``osv-scanner``, ``sast``) so CI assertions and operator search
both succeed.

Returns ``{"name", "comment", "body"}`` — each subclass adapts
the trio into its native primitive (Azure DevOps stage / Bitbucket
Expand All @@ -525,32 +525,33 @@ def _security_audit_block(self, complexity: "PipelineComplexity") -> Dict[str, A
return {}
# The shell body. Single POSIX sh body so every CI system can
# paste it as-is. ``|| true`` on the optional scanner so a
# missing trivy binary doesn't fail the pipeline — operators
# missing osv-scanner binary doesn't fail the pipeline — operators
# promote it from "advisory" to "blocking" by removing the
# ``|| true`` once the binary is on the runner.
body = (
"set -eu\n"
"# FLUID security scan + compliance audit — advanced/enterprise tier.\n"
"# Surfaces: SAST signal (via fluid validate), policy check\n"
"# (via fluid policy-apply --mode dry-run), and audit / SBOM /\n"
"# vulnerability scan (via fluid audit + optional trivy fs).\n"
"# vulnerability scan (via fluid audit + optional osv-scanner).\n"
"fluid validate --security-only || true\n"
"if [ -f dist/artifacts/policy/bindings.json ]; then\n"
" fluid policy-apply dist/artifacts/policy/bindings.json "
'--mode dry-run --env "${FLUID_ENV:-dev}" || true\n'
"fi\n"
"fluid audit --compliance --output runtime/compliance-report.json || true\n"
"# Optional: trivy fs scan if the binary is on the runner.\n"
"if command -v trivy >/dev/null 2>&1; then\n"
" trivy fs . --format sarif --output runtime/trivy-results.sarif || true\n"
"# Optional: OSV-Scanner vulnerability scan if the binary is on the runner.\n"
"if command -v osv-scanner >/dev/null 2>&1; then\n"
" osv-scanner scan source -r . --format sarif "
"--output runtime/osv-results.sarif || true\n"
"fi\n"
)
comment_lines = [
"Security + compliance audit (advanced/enterprise tier).",
"Runs SAST-style fluid validate, policy enforcement dry-run,",
"compliance audit + SBOM, and an optional trivy vulnerability",
"scan. Snyk / other scanners can replace trivy without breaking",
"the rest of the stage.",
"compliance audit + SBOM, and an optional OSV-Scanner",
"vulnerability scan. Any SCA scanner can replace it without",
"breaking the rest of the stage.",
]
return {
"name": "Security and Compliance Audit",
Expand Down
2 changes: 1 addition & 1 deletion fluid_build/forge/core/pipeline_systems/bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ def generate(self, config: PipelineConfig) -> Dict[str, str]:
audit_comment,
audit["body"],
],
"artifacts": ["runtime/compliance-report.json", "runtime/trivy-results.sarif"],
"artifacts": ["runtime/compliance-report.json", "runtime/osv-results.sarif"],
}
}
pipeline["pipelines"]["branches"]["main"].append(audit_step)
Expand Down
Loading
Loading