One-command, OSS AppSec static scan for any code repository. Runs SAST, SCA, secret scanning and IaC checks via proven open-source engines, then collects the results into native reports plus a human-readable summary and a CycloneDX SBOM — ready to read yourself or upload to an ASPM/ASOC (DefectDojo, DependencyTrack, …).
It is a thin, stateless orchestrator: no database, no web UI, no workers. Each scanner runs as a one-shot Docker Compose service; a small Python collector aggregates the reports and produces a CI-meaningful exit code.
What's planned (multi-project profiles, scheduling, a minimal UI, ASPM push) is tracked in ROADMAP.md.
| Category | Tool | Default | Output |
|---|---|---|---|
| SAST | Semgrep | on | semgrep.sarif |
| SCA | Trivy | on | trivy-fs.sarif |
| SCA (alt) | Grype | off | grype.sarif |
| SCA (alt) | OSV-Scanner | off | osv.sarif (needs network) |
| IaC | Trivy config | on | trivy-config.sarif |
| IaC | Checkov | on | checkov.sarif |
| IaC (alt) | Hadolint | off | hadolint.sarif (Dockerfiles) |
| Secrets | Gitleaks | on | gitleaks.sarif |
| Secrets (alt) | TruffleHog | off | trufflehog.json + trufflehog.sarif* |
| SBOM | Trivy | on | sbom.trivy.cdx.json |
| SBOM (alt) | Syft | off | sbom.syft.cdx.json |
Tools covering the same category are complementary — enable one or several; every enabled tool emits its own native report and all are aggregated.
* TruffleHog has no native SARIF, so the collector keeps its raw JSON and
generates a SARIF from it, so SARIF-only ASPM tools can ingest it too. Our
findings.json normalization is internal (for the summary); the per-tool native
reports are what you upload to an ASPM.
./run.sh /path/to/your/repo
# default fails (exit 1) on high+ findings; override per run:
./run.sh /path/to/your/repo --fail-on critical
# scan a container image instead of a repo (Trivy / Grype / Syft):
./run.sh --image nginx:1.27
# a private image (or set REGISTRY_USER / REGISTRY_PASS in the env):
./run.sh --image ghcr.io/me/app:1.0 --registry-user me --registry-pass "$TOKEN"
# build from a Dockerfile and scan the result:
./run.sh --build ./path/to/context [--dockerfile Dockerfile.prod]Everything lives in scan-config.yml: which checks run, the
pinned tool versions (bump here to upgrade), the policy gate and the offline
switch. run.sh renders it into .env (which Compose loads) and runs only the
enabled scanners.
scanners:
semgrep: { enabled: true, version: "1.97.0" } # SAST
trivy: { enabled: true, version: "0.58.0" } # SCA + IaC + SBOM
gitleaks: { enabled: true, version: "v8.21.2" } # secrets
checkov: { enabled: true, version: "3.2.334" } # IaC
sbom: true # SBOM via Trivy
sbom_formats: [cyclonedx] # any of: cyclonedx, spdx
fail_on: high # critical|high|medium|low|none (none = report-only)
unknown_severity: medium
offline: false # use ./preload.sh cache, no network during scanDisable a check by setting enabled: false — that scanner won't run and the
collector won't expect its report. Upgrade a tool by changing its version.
Set secrets_history: true to have Gitleaks scan the full git history
(needs a .git in the repo), not just the working tree.
Run once with network to snapshot DBs and rulesets into ./cache/:
./preload.sh # Semgrep ruleset + Trivy vulnerability DBThen set offline: true in scan-config.yml. Scans now run with no network
access (Semgrep uses the cached ruleset; Trivy uses the cached DB with
--skip-db-update --offline-scan). Gitleaks and Checkov bundle their rules in
the image, so they need no preload.
JVM note: Trivy's Java DB downloads on demand. To scan JVM projects offline, run one JVM scan online first to populate
cache/trivy.
reports/
├── native/ # raw per-tool reports (SARIF) — feed these to ASPM
│ ├── semgrep.sarif
│ ├── trivy-fs.sarif
│ ├── trivy-config.sarif
│ ├── gitleaks.sarif
│ ├── checkov.sarif
│ ├── grype.sarif # + grype.json (carries CVE/GHSA aliases for dedup)
│ └── trufflehog.json # + generated trufflehog.sarif
├── findings.json # consolidated, de-duplicated findings + counts
├── sbom.trivy.cdx.json # CycloneDX SBOM (Trivy; .spdx.json if enabled)
├── sbom.syft.cdx.json # Syft SBOM (CycloneDX + SPDX, if enabled)
├── summary.md # human summary
└── summary.html # human summary (styled)
summary.html shows severity cards/bars, per-tool breakdown and a findings
table with category filters, expandable details (description + advisory
link per finding), and — in baseline mode — a NEW flag on findings added
since the accepted snapshot. It is fully self-contained (no external assets).
The collector decides pass/fail: exit 1 if any finding is at or above
fail_on, else 0. Scanners themselves always exit 0, so a noisy finding
never breaks the scan phase — only the policy does. run.sh propagates the
collector's exit code, which makes this safe to drop into a CI gate.
Ready-to-use templates are included:
- GitHub Actions —
.github/workflows/appsec-scan.ymlruns the scan, uploads every SARIF to code scanning, keepsreports/as an artifact, caches the vuln DBs, and fails the job on a policy breach. - GitLab CI —
.gitlab-ci.ymlruns the scan, exposes the CycloneDX SBOM to GitLab Dependency Scanning, and caches DBs. (Needs a runner with the host Docker socket — see the file header.) - Publish the collector image —
.github/workflows/publish-collector.ymlpushes the collector to GHCR on release, so CI can skip the local build.
When several tools cover the same category they report the same issues. With
dedup: true (default) the collector merges duplicates in findings.json and
the summary (the raw native reports are left untouched for ASPM import):
- secrets — same
(file, line)is one secret, regardless of tool. - SCA — same
package@version+ file, clustered by overlapping vuln IDs. Trivy'sCVE-2018-1000656and Grype'sGHSA-562c-5r94-xh97merge because Grype'srelatedVulnerabilitieslists that CVE — so CVE/GHSA aliases collapse. - SAST/IaC — same rule at the same
(file, line).
A merged finding keeps the highest severity, records every tool that
reported it, and lists the equivalent IDs as aliases. findings.json reports
raw_findings, unique_findings and duplicates_removed. Per-tool counts
still reflect each tool's true raw yield.
Drop known/accepted findings from the gate and counts via an ignore: list in
scan-config.yml. An entry matches when every field present matches; rule is
a glob over the finding's ID and its aliases (so a single CVE entry also
catches the equivalent GHSA):
ignore:
- rule: CVE-2018-1000656
reason: "no fix available, mitigated at the proxy"
- file: "tests/**"
reason: "test fixtures"
- rule: "generic.*"
category: secrets
reason: "false positives in sample data"Suppressed findings are excluded from fail_on but recorded in findings.json
under suppressed (with the reason) for the audit trail.
Accept the current findings and fail only on new ones thereafter:
# 1. set `baseline: true` in scan-config.yml, then snapshot the accepted state:
./run.sh /path/to/repo --update-baseline # writes appsec-baseline.json
# 2. commit appsec-baseline.json; subsequent runs gate on new findings only:
./run.sh /path/to/repoEach finding gets a stable fingerprint (category, id, file, package, line).
Runs report new / known / fixed counts (in the console, summary and
findings.json), and fail_on applies to new findings only. Re-run
--update-baseline to re-accept the current state.
./run.sh --image <ref> scans a container image instead of a repo. Only the
image-capable tools run — Trivy and Grype (OS + language package
vulnerabilities) and Syft (SBOM); source-only scanners (Semgrep, Checkov,
Gitleaks, Hadolint) are skipped. Dedup, suppressions, baseline, SBOM formats and
the policy gate all work the same way.
- Private registries — pass
--registry-user/--registry-pass(or setREGISTRY_USER/REGISTRY_PASS). The scanners pull the image themselves using a generated docker auth config; no Docker socket is mounted. - Build & scan —
./run.sh --build <context> [--dockerfile <path>]builds the image with the host Docker, exports it to a tar (docker save) and scans that archive — so locally-built images are scanned without a registry or a Docker socket in the scanner containers.
By default scanners run from their pinned tags (versions in
scan-config.yml). For tamper-evident, fully reproducible runs, pin them to
immutable digests:
./pin.sh # resolves each repo:tag -> repo@sha256:... into image-digests.lockCommit image-digests.lock; render-env.py then runs every scanner by digest.
Re-run ./pin.sh after bumping a version (a bumped version with no matching
lock entry safely falls back to its tag). Delete the lock to go back to tags.
To pin a single tool by hand, put a digest in its version field instead of
a tag — version: "sha256:abc..." resolves to repo@sha256:abc....
run.shrendersscan-config.yml→.envand bind-mounts the target repo read-only at/code.- The enabled scanner services run once (in parallel), each writing a native
SARIF report into the shared
reports/volume and exiting0. collectornormalizes every SARIF into one model, writesfindings.json+ summaries, and exits with the policy code.
No docker.sock, no docker-in-docker.
docker-compose.yml # scanners + collector (versions/flags from .env)
run.sh # entrypoint: render config, run scanners + collector
preload.sh # offline cache populator
scan-config.yml # the single file you edit
scripts/render-env.py # scan-config.yml -> .env
orchestrator/ # Python collector (config, normalize, policy, summary)