Security scan
Run a dependency and vulnerability scan with the `security-scan` run — gated on PRs and on a weekly schedule, no GHA minutes burned on the scan itself.
Fires on Every PR · weekly — wire it any of these ways:
- Action recommended A ci.yml step dispatches the run — when it must interleave with other CI jobs or gate the PR.
- Schedule pattern A Cloudflare Cron Trigger fires it on a wall-clock cadence — no GitHub event, no workflow file. (weekly full-repo scan on a cron)
- Use case
- Dependency / vulnerability scan, on PR and weekly
- FlareDispatch shape
- 1 typed run + dispatch
- Plain GHA shape
- 1 workflow · 4 jobs (per-scanner + aggregate)
Source
The recommended FlareDispatch shape is shown first. Toggle to Without FlareDispatch to see the full GitHub Actions workflow a team would maintain to do the same job without it.
Action mode — triggered by a GitHub Actions workflow that calls the shipped run.
# Recipe: security / dependency scan on Cloudflare
#
# Use case: dependency and vulnerability scanning that you want on every PR
# *and* on a weekly schedule (to catch newly-disclosed CVEs in code that
# hasn't changed). Offload to the shipped `security-scan` run, which runs
# each selected scanner in its own container in parallel.
#
# Mode: Action mode, fire-and-forget. See specs/04-gha-integration.md.
# Run: security-scan — see specs/02-runs.md#5-security-scan.
name: security-scan
on:
pull_request:
schedule:
- cron: "0 6 * * 1" # every Monday 06:00 UTC
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: security-scan
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"scanners": ["pnpm-audit", "trivy-fs", "gitleaks"],
"failOn": "high"
}
# The scheduled run uses github.sha of the default branch — the same run
# slug, no extra wiring. Findings land in the check-run summary. // Recipe: security / dependency scan — the `security-scan` Run
//
// The typed Run that ./ci.yml dispatches. Runs each selected scanner in its
// own container, in parallel, and fails the check-run if any scanner exits
// non-zero (each scanner is configured to exit non-zero at/above `failOn`).
//
// The scanners are a heterogeneous list, so the fan-out stays plain
// `Effect.forEach` rather than the count-based `sharded` primitive — but each
// scanner's container + checkout is the `workspace` primitive. See
// specs/03-dsl.md § Primitives.
//
// This is the shipped `security-scan` run, reproduced here so the recipe is
// self-contained. Spec: specs/02-runs.md § 5. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, artifact } from "@flare-dispatch/core";
import { workspace } from "@flare-dispatch/core/primitives";
const Scanner = Schema.Literal(
"npm-audit", "pnpm-audit", "cargo-audit", "uv-audit",
"trivy-fs", "grype-fs", "gitleaks",
);
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
scanners: Schema.Array(Scanner),
failOn: Schema.optional(Schema.Literal("any", "high", "critical")), // default "high"
});
const Output = Schema.Struct({
failed: Schema.Boolean,
scannerResults: Schema.Array(
Schema.Struct({
scanner: Schema.String,
exitCode: Schema.Number,
reportUri: Schema.String,
}),
),
});
export const securityScan = defineRun({
name: "security-scan",
version: "1.0.0",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1200, maxConcurrency: 4 },
run: (input) =>
Effect.gen(function* () {
const failOn = input.failOn ?? "high";
const scannerResults = yield* step("scan", () =>
Effect.forEach(
input.scanners,
(scanner) =>
Effect.gen(function* () {
const { container, dir } = yield* workspace({
repo: input.repo,
sha: input.sha,
});
const exec = yield* sandbox.exec({
cwd: dir,
container,
env: { FAIL_ON: failOn },
// `scan` is a thin wrapper in the base image that normalizes
// each scanner's flags and exit codes against FAIL_ON.
command: ["scan", scanner],
});
const reportUri = yield* artifact.upload({
name: `${scanner}-report.json`,
path: exec.logPath,
container,
});
return { scanner, exitCode: exec.exitCode, reportUri };
}),
{ concurrency: 4 },
),
);
const failed = scannerResults.some((r) => r.exitCode !== 0);
return { failed, scannerResults };
}),
});
A faithful, runnable GHA workflow — what a team actually
maintains to do this job without FlareDispatch.
# Recipe: security / dependency scan — BASELINE (without FlareDispatch)
#
# This is what you'd maintain on plain GitHub Actions to run multiple
# heterogeneous scanners (pnpm-audit, Trivy, gitleaks) in parallel on every
# PR AND on a weekly cron, with a single aggregated PR check. Shown here
# ONLY as a comparison — see ./security-scan.run.ts for the FlareDispatch
# version (~45 lines, one primitive: `workspace`).
#
# Notable costs of the GHA-only path:
# - Every scanner has its own install + flags. Trivy and gitleaks are
# pre-built binaries you have to fetch; pnpm-audit is `pnpm` you already
# have. There's no thin wrapper like FlareDispatch's `scan` CLI that
# normalizes flags and exit codes against a shared FAIL_ON threshold.
# - The matrix can't easily express different setup steps per scanner —
# so each scanner is its own JOB, repeating checkout + setup-node.
# - The "fail on high" semantics differ per tool. Trivy has `--exit-code`
# + `--severity`, pnpm has `--audit-level`, gitleaks has none — you have
# to grep its output. The wrapper that homogenises this is something you
# either build yourself or paste into every workflow.
# - Scheduled (weekly) run uses the same workflow but runs against the
# default branch — and there's no first-class PR context, so any
# comment-back logic you want must branch on `github.event_name`.
# - The aggregate job has to download every scanner's report, decide
# overall pass/fail, and re-upload as a merged artifact. Manual.
name: security-scan-baseline
on:
pull_request:
schedule:
- cron: "0 6 * * 1" # every Monday 06:00 UTC
permissions:
contents: read
security-events: write # if you want SARIF uploads
jobs:
# --------------------------------------------------------------------------
# 1. Node/pnpm audit. Lives in its own job because the install step is
# pnpm-specific; sharing with Trivy/gitleaks below would just bloat the
# other jobs with unused setup-node.
# --------------------------------------------------------------------------
pnpm-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- name: Install
run: pnpm install --frozen-lockfile
- name: pnpm audit
run: |
pnpm audit --audit-level=high --json > pnpm-audit.json || EXIT=$?
# `pnpm audit` exits non-zero ONLY at the threshold — propagate.
test "${EXIT:-0}" = "0"
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: pnpm-audit-report
path: pnpm-audit.json
# --------------------------------------------------------------------------
# 2. Trivy filesystem scan — own binary fetch + own flag dialect.
# --------------------------------------------------------------------------
trivy-fs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Trivy
run: |
curl -fsSL "https://aquasecurity.github.io/trivy-repo/deb/public.key" \
| sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" \
| sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install -y trivy
- name: Trivy scan (fail on HIGH+)
run: |
trivy fs \
--severity HIGH,CRITICAL \
--exit-code 1 \
--format json \
--output trivy.json \
.
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: trivy-report
path: trivy.json
# --------------------------------------------------------------------------
# 3. Gitleaks — secret scanning. Different flag dialect again; no native
# "fail on high" because severity isn't a gitleaks concept — every find
# is a fail.
# --------------------------------------------------------------------------
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # gitleaks needs the full history
- name: Install gitleaks
run: |
curl -fsSL -o gitleaks.tar.gz \
"https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz"
tar -xzf gitleaks.tar.gz gitleaks
chmod +x gitleaks
- name: Run gitleaks
run: ./gitleaks detect --redact --report-format=json --report-path=gitleaks.json --exit-code 1
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: gitleaks-report
path: gitleaks.json
# --------------------------------------------------------------------------
# 4. Aggregate — branch protection should require THIS, not the individual
# scanners. Pulls every report, summarises, fails if any scanner failed.
# --------------------------------------------------------------------------
security-scan:
if: always()
needs: [pnpm-audit, trivy-fs, gitleaks]
runs-on: ubuntu-latest
steps:
- name: Download reports
uses: actions/download-artifact@v4
with:
path: ./reports
- name: Summarise
run: |
echo "## Security scan summary" >> "$GITHUB_STEP_SUMMARY"
for r in pnpm-audit trivy gitleaks; do
status="${{ needs.pnpm-audit.result }}"
echo "- $r: $status" >> "$GITHUB_STEP_SUMMARY"
done
- name: Propagate scanner failures
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "::error::One or more security scanners flagged findings."
exit 1