security

scnr: Defensive Vulnerability Discovery That Never Exploits

devopsgolangopen-sourcescnrsecurityvulnerability-management
Gerald VillorenteOctober 13, 20269 min read
scnr: Defensive Vulnerability Discovery That Never Exploits

Every security scanner I tried as a solo builder wanted something I did not have: an enterprise budget, a team to feed it, or a staging environment I could afford to break. The active ones worried me most, tools that probe so aggressively they can take down the thing they are supposed to protect. I built scnr to be the opposite: a defensive vulnerability discovery tool that observes and reports, and never exploits.

scnr is a single static binary. Point it at your code, your container images, your infrastructure-as-code, your hosts and networks, your web apps, or your AWS account, and it gives you one plain-English report with a posture score. No agents, no dashboards to babysit, no exploits, no brute force, nothing that changes the target. This post walks through why I built it, how the safety model works, and how to run it yourself.

The problem: security tooling assumes an enterprise

Small teams and solo builders ship to the cloud every day, but security tooling assumes you are an enterprise. The result is that most projects get scanned never, or with a pile of disconnected tools whose outputs nobody reconciles. Meanwhile the attacks are automated and indiscriminate: leaked secrets on GitHub, unpatched npm and PyPI packages, public S3 buckets, SSH open to the world, databases without backups.

  • Priced for enterprises. Per-seat pricing and annual contracts put continuous scanning out of reach for side projects and small teams.

  • Built for teams. Agents, collectors, and dashboards that need feeding by people you do not have.

  • Dangerously active. Aggressive probers that can degrade production systems, so teams only dare run them in staging, if they run them at all.

  • Fragmented. One tool for secrets, another for dependencies, a third for cloud posture, and nobody reconciles the outputs.

scnr exists to close that gap with one command you can run against your own systems without fear.

Meet scnr: one binary that observes and reports

scnr scans everything you own from one command: source trees, lockfiles, container images, Dockerfiles, Terraform and Kubernetes manifests, hosts, networks, web apps, and AWS posture. Targets are auto-detected, so scnr scan just works whether you hand it a directory, a docker save tarball, a CIDR, a URL, or an aws:// account. Reports land in ./scnr-out/<scan-id>/ as HTML and JSON by default, with Markdown, CSV, SARIF for GitHub code scanning, and CycloneDX SBOMs one flag away.

It is written in Go, ships as a single static binary with no runtime dependencies, and it is open source under Apache 2.0 at github.com/iamstoick/scnr. I maintain it solo, and every design decision optimizes for one thing: a builder should be able to scan on a Friday afternoon without fear of breaking Friday night.

Safety by construction, not by policy

The core promise is simple: scnr observes and reports, it never exploits. That promise lives in SAFETY.md as a set of invariants, and the important part is that they are enforced by code and tests, not by documentation alone:

  • No exploit code exists. No payload execution, no credential brute force, no denial of service, no state-changing checks. There is not even a --show-secrets flag: findings carry at most a fragment of a secret plus an HMAC, never the value.

  • Every scan is authorized. You acknowledge authorization once with scnr scope init, or per run with --ack-authorized. On a terminal, scnr asks first rather than assuming.

  • Deny always wins. Scope allow and deny lists are checked against the hostname and every resolved address, then re-checked at dial time with DNS-rebinding protection, while cloud metadata endpoints stay denied by default.

  • Public targets need proof. An allow-list entry is never enough on its own: public addresses need interactive consent or a 30-day ownership verification.

  • Gentle by default. Private scans cap at 500 connections per second, public at 100. Raising limits past twice the default needs an explicit flag and gets written to the audit log.

  • Everything is audited. Every scan, refusal, verification, and override lands in an append-only audit log that database triggers protect against updates and deletes.

  • No telemetry. The only outbound connections scnr itself makes are your in-scope targets, the vulnerability feeds it needs, and a notify URL you explicitly configure.

There is even a test that parses every source file and fails the build if any code uses raw dialers or default HTTP clients. Safety reviewers can read the invariants and then read the tests that enforce them.

Defensive vulnerability discovery: what one scan covers

Behind that single command sit focused engines, each doing one job:

  • secrets: leaked credentials in source, redacted before anything is stored, printed, or logged.

  • sca: lockfile dependencies matched against osv.dev data, enriched with CISA KEV and EPSS, or against a signed offline bundle.

  • codecheck: static analysis with local rules, plus semgrep with locked-down flags when it is installed.

  • iac: Dockerfiles, Terraform, and Kubernetes manifests checked for misconfigurations.

  • image: container vulnerabilities from docker save tarballs or OCI layouts. Registry pulls are deliberately unsupported: save the image, then scan the file.

  • netscan: TCP port and service discovery at capped rates, driving nmap with pinned addresses when nmap is installed.

  • webscan: one GET plus fixed-path template probes, safe nuclei templates only, redirects disabled.

  • cloud (AWS): about 25 read-only checks across IAM, S3, EC2, RDS, CloudTrail, and KMS, after sts:GetCallerIdentity confirms which account you pointed at.

When scnr can use an external tool on your PATH (nmap, nuclei, semgrep, trivy, grype, prowler), it drives it through argument arrays with forbidden-flag lists, never shell strings. When a tool is missing, the engine reports skipped with the reason instead of failing silently. Partial coverage is always labeled as partial.

From zero to your first report

Grab the prebuilt binary for your platform and verify the checksum:

# Linux (x86_64)
curl -L -o scnr https://github.com/iamstoick/scnr/releases/latest/download/scnr-linux-amd64
# macOS (Apple Silicon)
# curl -L -o scnr https://github.com/iamstoick/scnr/releases/latest/download/scnr-darwin-arm64

chmod +x scnr
curl -L -o checksums.txt https://github.com/iamstoick/scnr/releases/latest/download/checksums.txt
shasum -a 256 -c checksums.txt
./scnr version

Check what scnr sees on your machine. Missing tools degrade to skipped, and doctor tells you how to install them without running anything itself:

scnr doctor
scnr doctor --install-guide

Record your authorization acknowledgment once, then scan. With no target, an interactive wizard walks you through it:

scnr scope init   # records your authorization acknowledgment in scnr.yaml
scnr scan

For a repo, CI-style, with JSON output and no prompts:

scnr scan ./myrepo --format json --out out/ --non-interactive --ack-authorized

Narrow to secrets and dependency CVEs, skip everything else, or go fully offline with a loaded bundle:

scnr scan ./myrepo --engines secrets,sca --ack-authorized --non-interactive
scnr scan ./myrepo --offline --ack-authorized --non-interactive

Open ./scnr-out/<scan-id>/ in a browser for the HTML report: findings in plain English, a posture score, and an honest account of which engines ran and which were skipped.

Proving you own a public target

Scanning yourself on a private network just works after acknowledgment. Public addresses need proof, and the allow list alone is never that proof. Get a token, publish it, confirm it:

# 1. Get a token, publish it as DNS TXT _scnr-verify.example.com
#    or at https://example.com/.well-known/scnr-verify.txt
scnr verify example.com

# 2. Confirm it (recorded for 30 days)
scnr verify example.com --check

# 3. Now non-interactive scans are allowed
scnr scan https://example.com --ack-authorized --non-interactive

Without proof, non-interactive scans exit with code 3 and a plain-English refusal, and scnr scope check <target> explains the gate decision before you scan. That refusal is the feature: it is what makes scnr safe to hand to a teammate or a CI job.

Active checks, fenced and opt-in

Passive scanning reads your site the way a browser first sees it. When you want more, scnr can crawl same-origin pages and send inert markers to find reflected input, open redirects, and error disclosure. This stays firmly opt-in behind a triple gate:

  • Triple gate. Active checks need --allow-intrusive plus a scope.intrusive_allow entry plus a private target or a verified public one. Interactive consent alone is not enough for active checks on public targets.

  • Inert by construction. One marker per parameter, a random string with quote characters at most, never a statement, tag, or handler. Probes cap at 1000 requests per target.

  • Never state-changing. POST forms are inventoried, action and field names only, and never submitted. Links that look state-changing (logout, delete, publish) are reported and never fetched.

# Private app (needs intrusive_allow in scnr.yaml)
scnr scan https://app.internal/ --allow-intrusive --ack-authorized

# Authenticated scan (auth.yaml is chmod 600, owned by you)
scnr scan https://app.internal/ --allow-intrusive --auth-file ./auth.yaml

When the flag is passed but the gate refuses a target, scnr prints why, runs passively, audits the refusal, and says so in the report. That honesty about what ran and what did not is a feature, not a limitation.

Gating CI with policy as code

Scanning in CI only matters if it can fail the build. Write a policy (max criticals, minimum posture score), then:

scnr scan . --policy policy.yaml --ack-authorized --non-interactive  # exits 1 on violation
scnr policy check latest --policy policy.yaml

# Re-render any old scan as SARIF for GitHub code scanning
scnr report latest --format sarif --out out/

Exit codes are explicit: 0 ok, 1 policy violated, 2 usage error, 3 refused before anything ran, 4 all engines failed, 5 partial coverage. Air-gapped runners load a signed vulnerability bundle built on an online machine, so --offline never phones home. Add --strict to fail the build if any engine was skipped or failed.

Working with results

A scanner you run weekly needs memory. scnr keeps a local store of findings and assets you can query, and every new scan can diff against the previous one:

scnr findings list --help
scnr findings show <fingerprint>
scnr findings diff <scan-a> <scan-b>
scnr assets list

# Compare against the previous scan right after scanning
scnr scan . --diff latest --ack-authorized --non-interactive

Suppress known findings going forward with baselines, or accept individual findings by fingerprint with an audit trail:

scnr baseline create --from <scan-id>
scnr scan . --baseline baseline.yaml --ack-authorized

scnr accept <fingerprint-or-rule-id>
scnr accept list
scnr accept revoke <id>

For continuous coverage without CI, the daemon runs stored schedules under exactly the same safety rules, non-interactively and one at a time:

scnr schedule add --name weekly --cron "0 2 * * 1" -- scan 10.0.0.0/24 --profile standard
scnr schedule add --name web --cron "@daily" -- scan https://app.example.com
scnr schedule list

What scnr refuses to do

A defensive tool is defined by its refusals as much as its features. scnr will not:

  • run exploits, brute force, fuzzing, or denial-of-service checks, and pull requests shaped like them are declined;

  • pull container images from registries, contact endpoints a scanned repo chooses, or follow redirects out of scope;

  • scan public ranges wider than /24 (private /16) without an audited override;

  • show you secret values, phone home telemetry, or silently skip coverage it could not achieve.

Those refusals are why I can recommend running it against production infrastructure you own. The worst case of a scnr scan is an incomplete report that tells you it is incomplete.

Try it on your own projects

If you ship code to the cloud without a security team, scnr is built for you. Run it against a repo tonight and see what it finds. Bug reports and false positives are welcome, especially with the target type, the profile, and redacted output. Good first contributions are new safe web templates, new read-only AWS checks, and docs fixes. Start here: github.com/iamstoick/scnr.

Featured image: scnr project artwork, provided by the author.

Related reading

Hardening a Production Web App: A Practical Security Audit

Hardening a Production Web App: A Practical Security Audit

September 13, 2026
devopsdockernextjsnodejs
AI-Powered PHP-FPM Analysis: Streamlining Troubleshooting with Golang and Gemini AI

AI-Powered PHP-FPM Analysis: Streamlining Troubleshooting with Golang and Gemini AI

July 8, 2025
devopsdevops toolgolangphp-fpm

Comments

Comments disabled — configure NEXT_PUBLIC_GISCUS_* env vars.