Recipes / Action mode

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.

Use case
Dependency / vulnerability scan, on PR and weekly
Recommended mode
Action
Files
ci.yml, security-scan.run.ts

Source

This recipe ships both triggers — a typed Effect-TS run (*.run.ts) and a GitHub Actions workflow (ci.yml). Use whichever fits; Action mode is the recommended default.

recipes/security-scan/ci.yml
# 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.
recipes/security-scan/security-scan.run.ts
// 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 };
    }),
});