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
16 changes: 11 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
15 changes: 14 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
6 changes: 6 additions & 0 deletions cerberus.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions src/cerberus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
Expand Down Expand Up @@ -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"]);
});
});
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 : []) {
Expand All @@ -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") },
Expand Down
28 changes: 28 additions & 0 deletions src/scanners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading