From 9983f231b33b1821571016737d6ef492df48f595 Mon Sep 17 00:00:00 2001 From: Valera Satsura Date: Sat, 11 Jul 2026 14:57:50 +0300 Subject: [PATCH] Add Checkov and Hadolint heads; DNS option for self-hosted runners --- Dockerfile | 16 +++++++++++----- README.md | 19 ++++++++++++++++--- action.yml | 15 ++++++++++++++- cerberus.example.yml | 6 ++++++ src/cerberus.test.ts | 24 ++++++++++++++++++++---- src/config.ts | 4 ++++ src/scanners.ts | 28 ++++++++++++++++++++++++++++ src/types.ts | 4 ++++ 8 files changed, 103 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5cff069..0076b83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,24 +13,30 @@ ARG TARGETARCH ARG SEMGREP_VERSION=1.169.0 ARG GITLEAKS_VERSION=8.30.1 ARG TRIVY_VERSION=0.72.0 +ARG CHECKOV_VERSION=3.3.8 +ARG HADOLINT_VERSION=2.14.0 RUN apt-get update \ && apt-get install -y --no-install-recommends git curl ca-certificates python3 python3-pip \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install --no-cache-dir --break-system-packages semgrep==${SEMGREP_VERSION} +RUN pip3 install --no-cache-dir --break-system-packages \ + semgrep==${SEMGREP_VERSION} checkov==${CHECKOV_VERSION} -# The two Go scanners name their release assets differently per architecture. +# The Go/Haskell scanners each name their release assets differently per arch. RUN set -eux; \ case "${TARGETARCH}" in \ - amd64) gl_arch=x64; tv_arch=64bit ;; \ - arm64) gl_arch=arm64; tv_arch=ARM64 ;; \ + amd64) gl_arch=x64; tv_arch=64bit; hl_arch=x86_64 ;; \ + arm64) gl_arch=arm64; tv_arch=ARM64; hl_arch=arm64 ;; \ *) echo "unsupported arch: ${TARGETARCH}" >&2; exit 1 ;; \ esac; \ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz" \ | tar -xz -C /usr/local/bin gitleaks; \ curl -sSfL "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-${tv_arch}.tar.gz" \ - | tar -xz -C /usr/local/bin trivy + | tar -xz -C /usr/local/bin trivy; \ + curl -sSfL -o /usr/local/bin/hadolint \ + "https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-linux-${hl_arch}"; \ + chmod +x /usr/local/bin/hadolint WORKDIR /app COPY --from=build /app/dist ./dist diff --git a/README.md b/README.md index 0cefae6..fc19695 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Cerberus -**Cerberus** is a three-headed guard dog for your CI: SAST (Semgrep), secrets (Gitleaks) and dependencies (Trivy) in one container. It merges everything into a single SARIF report, ships it to your tracker, and fails the pipeline only when *new* findings appear — your old backlog never blocks a merge. +**Cerberus** is a guard dog for your CI: SAST (Semgrep), secrets (Gitleaks), dependencies (Trivy), IaC misconfig (Checkov) and Dockerfile lint (Hadolint) in one container. It merges everything into a single SARIF report, ships it to your tracker, and fails the pipeline only when *new* findings appear — your old backlog never blocks a merge. > **Status: v0.** The CLI works end-to-end (scan → merge → upload → gate); the Docker image and CI wrappers are not published yet. @@ -13,8 +13,11 @@ Running scanners in CI is easy. Living with the results is not: the first scan d ``` CI job ──▶ cerberus scan ├─ Semgrep (SAST) - ├─ Gitleaks (secrets) - └─ Trivy (deps / images / IaC) + ├─ Gitleaks (secrets, whole history) + ├─ Trivy (dependencies, secrets, misconfig) + ├─ Checkov (IaC misconfig) + ├─ Hadolint (Dockerfiles) + └─ custom (anything that writes SARIF) │ merge → one SARIF ▼ POST to backend ──▶ dedup, lifecycle, tasks @@ -35,10 +38,14 @@ include: **GitHub** — add the action: ```yaml +- uses: actions/checkout@v4 + with: + fetch-depth: 0 # Gitleaks scans the whole history - uses: startmatter/cerberus@main with: url: ${{ secrets.K_SARIF_URL }} secret: ${{ secrets.K_SARIF_SECRET }} + # dns: 1.1.1.1,8.8.8.8 # self-hosted runners whose resolver containers can't reach ``` CI context (repo, branch, commit, author, changed files) is detected from GitLab CI / GitHub Actions @@ -50,6 +57,12 @@ one job line covers pushes and merge requests. Add a nightly scheduled pipeline for dependency scanning: new CVEs land in code that never changed, so pushes alone will not surface them. +### Self-hosted runners + +If the runner sits behind a VPN resolver its containers cannot reach, Semgrep cannot fetch its rules +and Trivy cannot fetch its vulnerability database — and both quietly produce an empty report. Pass +`dns: 1.1.1.1,8.8.8.8` (GitHub) or set `--dns` on the runner's docker config (GitLab). + ### Pulling a private image Container packages are private by default, and some organizations disallow public ones. Then the diff --git a/action.yml b/action.yml index 0ef9fb6..6be4b50 100644 --- a/action.yml +++ b/action.yml @@ -25,6 +25,14 @@ inputs: description: Cerberus image to run. required: false default: ghcr.io/startmatter/cerberus:latest + dns: + description: > + DNS servers for the scan container, comma-separated. Needed on self-hosted + runners whose resolver the container cannot reach — without it Semgrep + cannot fetch its rules and Trivy cannot fetch its vulnerability database, + and both quietly produce nothing. Empty uses the host's resolver. + required: false + default: "" registry-token: description: > Token used to pull the image when it is private (the default GITHUB_TOKEN @@ -63,7 +71,12 @@ runs: K_SARIF_URL: ${{ inputs.url }} K_SARIF_SECRET: ${{ inputs.secret }} run: | - docker run --rm \ + dns_args="" + if [ -n "${{ inputs.dns }}" ]; then + IFS=',' read -ra servers <<< "${{ inputs.dns }}" + for s in "${servers[@]}"; do dns_args="$dns_args --dns $(echo "$s" | tr -d ' ')"; done + fi + docker run --rm $dns_args \ -v "${{ github.workspace }}:/src" \ -w "/src/${{ inputs.path }}" \ -e K_SARIF_URL -e K_SARIF_SECRET \ diff --git a/cerberus.example.yml b/cerberus.example.yml index f50f812..fb9c7c3 100644 --- a/cerberus.example.yml +++ b/cerberus.example.yml @@ -13,6 +13,12 @@ scanners: enabled: true scanners: [vuln, secret, misconfig] args: [] + checkov: # IaC misconfig: Terraform, CloudFormation, k8s, Dockerfile + enabled: true + args: [] + hadolint: # every Dockerfile in the tree + enabled: true + args: [] # Your own checks — anything that writes SARIF: # custom: # - name: compose-audit diff --git a/src/cerberus.test.ts b/src/cerberus.test.ts index 07337f9..28eef6b 100644 --- a/src/cerberus.test.ts +++ b/src/cerberus.test.ts @@ -13,6 +13,8 @@ describe("parseConfig", () => { const c = parseConfig({}); expect(c.scanners.semgrep).toEqual({ enabled: true, config: "p/security-audit", args: [] }); expect(c.scanners.trivy.scanners).toEqual(["vuln", "secret", "misconfig"]); + expect(c.scanners.checkov.enabled).toBe(true); + expect(c.scanners.hadolint.enabled).toBe(true); expect(c.gate.failOn).toBe("new-critical"); expect(c.upload).toEqual({ mode: "auto", partial: false }); }); @@ -139,16 +141,30 @@ describe("upload helpers", () => { }); describe("buildInvocations", () => { - it("builds argv for the three heads and shell for custom", () => { + it("builds argv for the built-in heads and shell for custom", () => { const config = parseConfig({ scanners: { custom: [{ name: "own", command: "node check.js -o {output}" }] } }); const inv = buildInvocations(config, "/tmp/out"); - expect(inv.map((i) => i.name)).toEqual(["semgrep", "gitleaks", "trivy", "own"]); + expect(inv.map((i) => i.name)).toEqual(["semgrep", "gitleaks", "trivy", "checkov", "hadolint", "own"]); expect(inv[0]!.command).toContain("--sarif"); - expect(inv[3]!.command).toBe("node check.js -o /tmp/out/custom-0.sarif"); + expect(inv[5]!.command).toBe("node check.js -o /tmp/out/custom-0.sarif"); + }); + + it("reads checkov's report from the name checkov itself picks", () => { + const inv = buildInvocations(parseConfig({}), "/tmp/out").find((i) => i.name === "checkov")!; + expect(inv.output).toBe("/tmp/out/results_sarif.sarif"); + expect(inv.command).toContain("--output-file-path"); + }); + + it("hadolint writes an empty report when the repo has no Dockerfile", () => { + const inv = buildInvocations(parseConfig({}), "/tmp/out").find((i) => i.name === "hadolint")!; + expect(inv.command).toContain('"version":"2.1.0"'); // the no-Dockerfile fallback + expect(inv.command).toContain("hadolint -f sarif"); }); it("skips disabled scanners", () => { - const config = parseConfig({ scanners: { semgrep: { enabled: false }, gitleaks: { enabled: false } } }); + const config = parseConfig({ + scanners: { semgrep: { enabled: false }, gitleaks: { enabled: false }, checkov: { enabled: false }, hadolint: { enabled: false } }, + }); expect(buildInvocations(config, "/tmp/out").map((i) => i.name)).toEqual(["trivy"]); }); }); diff --git a/src/config.ts b/src/config.ts index efef2af..a4bde0e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,6 +33,8 @@ export function parseConfig(raw: unknown): CerberusConfig { const semgrep = scanner("semgrep"); const gitleaks = scanner("gitleaks"); const trivy = scanner("trivy"); + const checkov = scanner("checkov"); + const hadolint = scanner("hadolint"); const custom: CustomScanner[] = []; for (const entry of Array.isArray(scanners.custom) ? scanners.custom : []) { @@ -57,6 +59,8 @@ export function parseConfig(raw: unknown): CerberusConfig { scanners: strArray(trivy.scanners).length ? strArray(trivy.scanners) : ["vuln", "secret", "misconfig"], args: strArray(trivy.args), }, + checkov: { enabled: enabled(checkov), args: strArray(checkov.args) }, + hadolint: { enabled: enabled(hadolint), args: strArray(hadolint.args) }, custom, }, gate: { failOn: oneOf(gate.fail_on, GATE_POLICIES, "new-critical", "gate.fail_on") }, diff --git a/src/scanners.ts b/src/scanners.ts index f276c52..e1308ef 100644 --- a/src/scanners.ts +++ b/src/scanners.ts @@ -53,6 +53,34 @@ export function buildInvocations(config: CerberusConfig, outDir: string): Invoca ], }); } + if (s.checkov.enabled) { + // --output-file-path takes a directory; checkov names the file itself. + // It also exits non-zero when it finds anything, which is not a failure — + // the runner prefers the report over the exit code. + inv.push({ + name: "checkov", + output: join(outDir, "results_sarif.sarif"), + command: [ + "checkov", "-d", ".", "-o", "sarif", "--output-file-path", outDir, + "--compact", "--quiet", ...s.checkov.args, + ], + }); + } + if (s.hadolint.enabled) { + // hadolint takes files, not a directory, and writes to stdout. A repo with + // no Dockerfile must still produce a valid (empty) report, not a failure. + const output = join(outDir, "hadolint.sarif"); + const extra = s.hadolint.args.join(" "); + inv.push({ + name: "hadolint", + output, + command: + `files=$(find . -type f \\( -iname 'Dockerfile' -o -iname 'Dockerfile.*' -o -iname '*.Dockerfile' \\) ` + + `-not -path './node_modules/*' -not -path './.git/*'); ` + + `if [ -z "$files" ]; then printf '{"version":"2.1.0","runs":[]}' > ${output}; ` + + `else hadolint -f sarif ${extra} $files > ${output} || true; fi`, + }); + } for (const [i, custom] of s.custom.entries()) { const output = join(outDir, `custom-${i}.sarif`); inv.push({ name: custom.name, output, command: custom.command.replaceAll("{output}", output) }); diff --git a/src/types.ts b/src/types.ts index a261e79..0854814 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,10 @@ export interface CerberusConfig { semgrep: SemgrepConfig; gitleaks: ScannerConfig; trivy: TrivyConfig; + /** IaC misconfig: Terraform, CloudFormation, k8s, Dockerfile. */ + checkov: ScannerConfig; + /** Dockerfile lint (every Dockerfile in the tree). */ + hadolint: ScannerConfig; custom: CustomScanner[]; }; gate: { failOn: GatePolicy };