Skip to content

Repository files navigation

scrutineer

A local tool for scanning open source repositories for security vulnerabilities and managing the disclosure process. You add a repo by URL, scrutineer runs a pipeline of agent skills against it inside a container, and presents the results in a web UI where you can triage findings, identify maintainers, and track disclosures. The agent CLI is pluggable: claude-code by default, or codex or opencode with -backend.

Quick start

You need one supported container runtime: Docker (the default), rootless Podman, or Apple's container CLI.

Install Scrutineer from Homebrew on macOS or Linux:

brew install scrutineer

Alternatively, download the Linux or macOS archive for your architecture from GitHub Releases, verify it against SHA256SUMS, and put scrutineer on your PATH. The macOS archives are currently unsigned and not notarized, so macOS may present a Gatekeeper warning even after you verify the checksum and GitHub build-provenance attestation.

To build or run from source instead, install Go 1.26+:

git clone https://github.com/alpha-omega-security/scrutineer
cd scrutineer

Authenticate the default claude-code backend with either a Claude Code subscription token (Max, Pro, Team, or Enterprise) generated by the Claude CLI:

claude setup-token
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...

or an Anthropic API key from console.anthropic.com:

export ANTHROPIC_API_KEY=sk-ant-api03-...

Start a downloaded or installed binary with Docker:

scrutineer

Select another installed runtime explicitly:

scrutineer --runtime podman
scrutineer --runtime apple

The existing source-checkout command remains supported:

go run ./cmd/scrutineer -skills ./skills

Then open http://127.0.0.1:8080. The explicit -skills ./skills directory makes the checkout command useful while developing skills because it overrides the copies embedded in the binary. It is optional for ordinary use because Scrutineer ships its built-in skills and per-ecosystem runner profiles inside the executable.

You can also build a checkout-independent executable and run it from another directory:

go build -o scrutineer ./cmd/scrutineer
mkdir -p ~/.local/bin
install -m 0755 scrutineer ~/.local/bin/scrutineer
scrutineer

Precompiled binaries cover Linux and macOS on amd64 and arm64. Run scrutineer --version (or scrutineer version) to see the application version, source commit, build timestamp, and exact runner-image digest paired with that release.

The executable does not bundle Docker, Podman, or Apple's container CLI. Scrutineer remains a host application that asks the selected external runtime to launch an ephemeral runner container for each scan; it materialises the embedded profile Dockerfiles under the data directory when docker/profiles is not available from a source checkout. Content-addressed skill and profile bundles from older versions are retained because existing skill records may still reference their auxiliary files; incomplete extraction directories older than 24 hours are removed automatically. To run under codex or opencode instead, see the Codex backend and Opencode backend sections; only the credential and -backend flag change.

Large batches pause automatically at an account-level rate limit or quota wall. When claude-code is running on a subscription token it emits a rate_limit_event carrying the reset time, and scrutineer re-queues the paused batch after that reset; API-key accounts and the codex/opencode backends report the error without a reset time, so those batches stay paused for manual resume from the /usage page, which also shows the most recent per-window status.

Scrutineer uses Docker by default: each scan runs in an ephemeral container with a read-only source mount and an egress allowlist proxy. The paired runner image (ghcr.io/alpha-omega-security/scrutineer-runner) is pulled on first use, so the first scan is slower while it downloads. A host without the selected runtime fails startup rather than silently weakening isolation; --no-container is the explicit, Claude-only escape hatch for running directly on a trusted host.

Click Add repository in the sidebar, paste a git HTTPS URL, and scrutineer enqueues the triage skill. To scan a maintained branch instead of the default, fill the Branch field (it suggests the remote's branches as you type and also accepts a tag or commit), or append a /tree/<branch> suffix to the URL; the suffix also works one-per-line when bulk-importing. Triage then enqueues the rest of the pipeline in parallel. Metadata and package lookups finish in seconds; the security deep-dive takes a few minutes depending on repo size. Open the repo page and switch to the Scans tab to watch progress, or wait for the Findings tab to fill in.

To onboard a whole GitHub org at once, open Add multipleImport a whole org and enter the org (or user) login. Scrutineer fetches every repository and queues each one with the default scan set, skipping forks, archived repos (unless you opt in), and any URL already in the database. Set GITHUB_TOKEN to raise GitHub's unauthenticated rate limit when importing large orgs.

You can also scan a directory on disk, useful before pushing, or for code not hosted on a git forge. Paste an absolute path (/path/to/project) in the same Add repository field. Scrutineer copies the directory into a per-scan workspace and runs the default skill set; skills that need a forge URL or ecosyste.ms enrichment (advisories, exposure, fork, maintainers, metadata, packages, public-issue, report-upstream) are skipped automatically. Symlinks are recreated as-is rather than dereferenced during the copy; in container mode their targets then resolve inside the container, so host files reached only through such a link are not visible to skills. Under --no-container the kernel dereferences them normally, so only point scrutineer at trees you trust.

The optional analysis tools (semgrep, zizmor, git-pkgs, brief) are bundled in the runner image, so you don't need them installed locally when the container runner is in use.

Git authentication

Scrutineer shells out to git clone with no explicit token passing, so it uses whatever credentials are already configured on the host: SSH keys, credential helpers, gh auth login, a .netrc file, or the macOS keychain.

To scan private repos, check that git clone https://github.com/org/repo works in the same environment you launch scrutineer from before adding the URL.

Common setups:

# GitHub CLI (easiest)
gh auth login

# Git credential helper
git config --global credential.helper store   # or osxkeychain / manager-core

# SSH-based clone URLs are not supported -- scrutineer only accepts https:// URLs.
# Use a credential helper to authenticate HTTPS clones instead.

When running inside Docker (docker run ...), the container has no access to host credentials. Mount a credential store or set GIT_ASKPASS to provide access to private repos from inside the container.

When the containerised runner is active (the default when a container runtime is available), each scan runs in a separate container but the clone happens on the host before the source is mounted in. Host credentials are used for the clone and never enter the container.

Features

Scanning and analysis

  • Skill-based scan pipeline -- every scan is a skill on disk (SKILL.md + schema + optional scripts) staged where the active backend expects it. The default pipeline for a new repo is itself a skill (triage) that enqueues the others; edit its SKILL.md to change what runs
  • Structured findings -- vulnerability reports parsed into a database with severity, CWE, location (linked to source), affected versions, and a six-step analysis trace
  • Threat model view -- the project's security contract (components, entry-point trust table, properties provided and disclaimed, known non-findings) rendered from the threat-model scan, falling back to the deep-dive's boundaries and sink inventory on older repositories
  • Dependency exploration -- dependency and dependent tables with one-click import to scan any package's source repository
  • Package registry data -- downloads, dependents, versions, and registry links for every published package
  • Known advisories -- existing CVEs and security advisories pulled automatically
  • Maintainer identification -- model-backed skill combining commit history, issue/PR activity, and registry ownership to identify who to contact for disclosure
  • CWE catalogue -- embedded MITRE CWE data with tooltips on finding tables and full descriptions on finding pages
  • Reachability analysis -- trace sinks found in dependencies through application code to see which are actually reachable
  • Rescan dedup -- findings carry a content fingerprint so re-running a scan updates existing rows instead of creating duplicates; same-fingerprint hits within one scan collapse to a single finding with a +N expandable location list, and findings that stop appearing are marked "not seen" with a miss count

Triage and disclosure workflow

  • Finding workflow -- guided triage flow from new through verification, disclosure, and publication with human gates at each step
  • Cheap-classifier pre-sort -- the revalidate skill auto-enqueues for High/Critical deep-dive findings and every imported finding, emitting true_positive / false_positive / already_fixed / uncertain plus an optional severity adjustment; a true_positive on a High/Critical finding chains into verify automatically
  • Audit queue -- random sample of recent low and false-positive verdicts at /audit so the operator can spot-check the classifier; each review records an agreement-or-overturn verdict on the finding
  • Exploited-in-the-wild flag -- analyst-only yes/no field on findings with free-text evidence, surfaced on the finding page, in the OSV database_specific block, in CSAF audit notes, and in markdown report exports
  • Breaking-change classifier -- the breaking-change skill runs over a suggested-fix diff plus the top dependents, recording breaking / non_breaking / unknown with a rationale and the list of affected dependents
  • Mitigation guidance -- the mitigate skill drafts short-term workarounds and an optional semgrep rule per finding, separate from the code fix
  • CVSS v3.1 and v4.0 -- both vectors stored side by side with derived scores; analyst form, OSV/CSAF exports, and the disclose skill all carry both forward, with the v4 metric set kept distinct from v3
  • Release watch -- the release-watch skill closes the gap between fix-landed and fix-shipped: once a finding reaches fixed, the skill polls upstream releases and records the release tag, URL, and timestamp when it appears
  • CNA matching -- identify the CVE Numbering Authority whose scope covers a repo so disclosures go to the right contact
  • Upstream reporting -- file a finding on the upstream repository through GitHub's private vulnerability reporting with the proposed patch attached, and push the fix to the temporary private fork when GitHub grants access. A PVR report is hard to unsend; before pointing this at an external repository, run it once end-to-end against a repository you control with PVR enabled to confirm the body shape and patch attachment land the way you expect. When upstream has no PVR available, follow the runbook in docs/disclosure-fallback.md

Imports and exports

  • SBOM import -- upload a CycloneDX or SPDX document, resolve each component to a source repository, and queue scans automatically
  • Finding import -- POST SARIF, CSV, markdown, or minimal-JSON findings from external scanners and pentest reports into the same workflow as native scans, with fingerprint dedup against re-imports
  • Free-form ingest -- when the format sniffer in /api/v1/import cannot place a payload, the ingest skill normalises it against the source checkout (resolving locations) before it enters the findings table
  • CSAF export -- download any finding as a schema-validated CSAF 2.0 advisory document
  • OSV export -- download any finding as a schema-validated OSV record, aligned with the OSS-SIRT advisory template (credits, CWE IDs, withdrawn, SEMVER ranges, CVSS v3 + v4 severity entries)
  • JSONL export -- stream all findings or scans as line-delimited JSON for ingestion elsewhere
  • Markdown report export -- download a single consolidated report.md per repository or organisation
  • Disclosure bundle -- download bundle.tar.gz per finding: OSV, CSAF, markdown report, patch.diff, a runnable poc/ directory extracted from the finding's Validation step, and a manifest naming the contents; ready to hand to a coordinator or attach to a private email when filing outside GitHub PVR
  • Encrypted sharing / archival bundle -- export a repository's findings as a self-contained JSON bundle that round-trips through /api/v1/import, optionally age-encrypted to your team's keys. The default bundle is share-safe (finding substance only); include=all produces a lossless archival superset -- enrichment, disclosure fields, notes, communications, and references -- for backing up or moving a repo's findings between your own instances. See docs/encrypted-sharing.md

Operational

  • Containerised runner -- optional per-scan container isolation with read-only source mounts, dropped capabilities, and an authenticated egress allowlist proxy
  • Skill HTTP API -- running skills can call back into scrutineer to list prior scans and enqueue further skills; surface documented in openapi.yaml
  • Live updates -- SSE streaming of scan logs and status changes, no polling
  • Organisation rollup -- repos, findings, and maintainers grouped by owning org, with per-org markdown exports
  • Usage tracking -- per-scan token and cost figures plus a /usage page totalling spend per skill; on a Claude subscription token, a rate-limit wall auto-pauses the batch and resumes it after the reported reset, with per-window status shown on /usage. Optionally (downgrade_on_overage), once the account crosses into overage the model tier falls back from max/high to the mid tier for new scans until overage clears (typically when the window resets) -- announced in the log, on the jobs page, and on /usage
  • Themes -- six colour themes plus a light/dark/system toggle, set on the Settings page

The default pipeline

Adding a repo enqueues the triage skill, whose SKILL.md lists the further skills to run from the bundled set in skills/:

Skill What it does
triage Orchestrates the default scan set via the scrutineer API
metadata Fetches repo metadata from repos.ecosyste.ms
packages Looks up published packages from packages.ecosyste.ms
advisories Fetches known security advisories
dependencies Runs git-pkgs list to index every manifest
sbom Runs git-pkgs sbom for a CycloneDX SBOM
maintainers Model-backed analysis identifying real maintainers and contact routes
repo-overview Runs brief --json for a structured project summary
subprojects Enumerates monorepo packages/workspaces so deep-dives can be scoped to a sub-path
recon Maps distinct externally reachable input-processing subsystems into focus areas; after threat-model completes, those areas fan out into parallel deep-dive audits
threat-model Derives the project's security contract (components, entry-point trust table, claimed and disclaimed properties) for the deep-dive to load
semgrep Static analysis mapped into findings shape
vuln-scan High-recall model-backed static candidate scan adapted from Anthropic's defending-code reference harness
zizmor GitHub Actions workflow audit mapped into findings shape
ingest Normalizes external reports in arbitrary formats into findings when /v1/import cannot recognise the payload
security-deep-dive The model-backed audit producing structured findings
advisory-deep-dive Re-audits every past advisory against its fix commit for a fix bypass, an incomplete fix, or the same bug class in sibling code the patch never touched; the deep-dive scoped to the advisory space
finding-dedup Compares open findings and marks overlapping reports as duplicates
verify Re-checks one finding against current HEAD; records reproduces / fixed / can't-reproduce
revalidate Cheap read-only classifier (prose + git log, no PoC execution) that emits true / false positive / already-fixed / uncertain; auto-enqueued for High/Critical from security-deep-dive and for every imported finding. A true_positive on a High/Critical finding chains automatically to verify
breaking-change Static breaking-change check on the suggested-fix diff; records breaking/non_breaking/unknown with rationale and the affected dependents
release-watch After a finding reaches fixed, watches the upstream for a release containing the fix commit; records release tag, URL, and timestamp on the finding
disclose Drafts a GHSA-shaped advisory (title, description, CVSS, CWEs, references) for one finding
patch Proposes a unified diff fixing one finding; a diff that passes the applicability gate is stored on the finding as its suggested fix
report-upstream Files one finding on the upstream repository via GitHub PVR with the proposed patch attached; the action that moves a finding to reported
public-issue Files a low-severity finding as an ordinary public GitHub issue after analyst confirmation
reachability Traces dependency sinks through application code to determine which are reachable from trust boundaries
cna-match Matches a repository to its CVE Numbering Authority so disclosures route to the right contact
posture Records the repo's security posture (reporting policy, response history, hardening) on the Repository row
forensics Read-only compromise timeline and evidence bundle from local Git history and public forge/archive records
variants Starting from one confirmed finding, searches the current repository for distinct, high-confidence sibling instances of the same root cause

Edit skills/triage/SKILL.md to change what gets run by default. Drop new skill directories in skills/ to add scan types; no code changes needed. See docs/skills.md for the frontmatter reference, the scrutineer.* metadata keys, the context.json shape, output kinds, schema validation, and the skill-facing HTTP API.

Before each scan, lockfiles, minified bundles, and generated trees are stripped from the workspace so the skill doesn't waste turns on them. The builtin skip list covers node_modules, dist, generated, __generated__, *.min.js/*.min.css, and the common lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, go.sum, Gemfile.lock, poetry.lock, composer.lock). Skills can override this with scrutineer.paths (allow-list) and layer scrutineer.ignore_paths on top; see docs/skills.md.

Importing findings from other tools

Scrutineer can ingest findings produced elsewhere so they enter the same triage and disclosure workflow:

curl --data-binary @report.sarif http://127.0.0.1:8080/api/v1/import

SARIF 2.1.0, CSV, markdown, and a minimal JSON shape are all accepted; the format is sniffed from the body. See docs/import.md for the full request and response shape, the per-format field mapping, and how to add support for a new format.

Navigating the UI

Every index page has a search box plus filter and sort dropdowns; the specifics vary by page. The sidebar sections:

  • Repositories -- your scanned repos with language, last-scan status, and finding counts. Click into one for tabs covering Summary, Findings, Threat Model, Packages, Dependencies, Dependents, Advisories, Maintainers, Data, and Scans, plus an "Export report" button for a markdown rollup.
  • Organizations -- repos, findings, and maintainers grouped by owning org, with per-org markdown exports.
  • Findings -- every vulnerability across all repos. A finding page shows the six-step analysis (trace, boundary, validation, prior art, reach, rating), scoring fields, notes, communications log, references, labels, and a change history.
  • Packages -- registry entries discovered across all repos.
  • Advisories -- known CVEs and security advisories pulled for any scanned package.
  • Maintainers -- people identified as maintainers, with their linked repos and findings.
  • SBOMs -- uploaded CycloneDX/SPDX documents. Each component is resolved to a source repository and can be imported for scanning.
  • Audit -- random sample of recent low and false-positive verdicts for spot-checking the cheap-classifier output. Each row records the analyst's agreement-or-overturn verdict, and a small dashboard shows the running overturn rate.
  • Scans -- every scan that has run. Queued scans can be paused/resumed, running or queued scans can be cancelled and failed ones retried.
  • Skills -- installed skills from disk and from the UI; view, edit, or run any of them.
  • Usage -- token and cost totals across all scans, broken down by skill.
  • Settings -- theme, colour scheme, model tiers, runner concurrency (restarts the runner to apply, cancelling in-flight scans) and default turn cap (applied to the next scan), plus system stats (record counts, DB size, paths).

Finding workflow

Each finding from the security-deep-dive skill starts at new and moves through a guided workflow:

  1. new -- just identified. High/Critical from security-deep-dive and every imported finding auto-enqueue a revalidate pass first, which records true_positive / false_positive / already_fixed / uncertain on the finding and (when true_positive on High/Critical) chains into verify. Outside that path: click "Verify" to trigger independent confirmation, "Skip to triage" if you trust the audit, or "Reject"
  2. enriched -- verification ran. Review and click "Triage"
  3. triaged -- confirmed real. Click "Prepare disclosure"
  4. ready -- draft prepared. Run the report-upstream skill to file it via GitHub PVR (github.com only, requires gh auth), run public-issue for reviewed low-severity hardening findings that are safe to file publicly, or click "Mark as reported" after sending it yourself. When upstream has no PVR, follow the runbook in docs/disclosure-fallback.md: route to a CNA when cna-match names one, otherwise contact the channel maintainers returned
  5. reported -- sent to maintainer. Click "Acknowledged" when they respond
  6. acknowledged -- maintainer working on fix. Click "Mark fixed" when it ships
  7. fixed -- patch available. Click "Mark published" to issue the advisory
  8. published -- done

Each finding page has a notes section for recording triage reasoning and communication history.

A patch run whose diff survives the applicability gate (the diff parses, targets files that exist, touches the flagged file, and passes git apply --check) is stored on the finding as suggested_fix with its base commit, downloadable from the finding page as a .patch file and included in markdown report exports. To revise a fix, push your edits to a branch, scan that branch (the Branch field, or a /tree/<branch> URL suffix), and run patch against the new scan: the diff is proposed against that ref's tree, so each round of edit, push, and rescan gets a fresh proposal on top of your work.

Exploring dependencies

The Dependencies tab on a repo groups packages by name and shows all manifest files where each appears. It shows runtime dependencies by default, with a toggle for test/build/dev rows. The import button (arrow icon) next to a dependency resolves it to a repository URL via packages.ecosyste.ms and queues the full pipeline for it. Dependencies you've already imported show a link icon instead.

The same applies to the Dependents tab -- you can import any dependent's repository with one click.

Docker

docker build -t scrutineer .
docker run -p 127.0.0.1:8080:8080 -v scrutineer-data:/data \
  -e ANTHROPIC_API_KEY=sk-ant-api03-... \
  -e ANTHROPIC_BASE_URL=https://... \
  scrutineer

Or with a Claude Code OAuth token instead of an API key:

docker run -p 127.0.0.1:8080:8080 -v scrutineer-data:/data \
  -e CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... \
  scrutineer

For codex or opencode, pass -e CODEX_API_KEY=... / -e OPENAI_API_KEY=... (or ANTHROPIC_API_KEY for opencode with an Anthropic model) and add -backend codex / -backend opencode to the command.

Always bind to 127.0.0.1: the UI has no authentication, so binding to 0.0.0.0 exposes your findings database to anyone on the network.

If a container runtime (docker, rootless podman, or Apple's container) is available on the host, scrutineer runs each scan in an ephemeral container for isolation. The runner image is published to GHCR as a multi-arch manifest (linux/amd64 and linux/arm64) and pulled automatically on first use:

go run ./cmd/scrutineer -skills ./skills

Use --runtime podman to run scans under podman instead of docker (see Podman (rootless) below), --runtime apple to run scans under Apple's container runtime on macOS (see Apple container (experimental) below), --no-container to disable containerised execution entirely, or --runner-image to specify a different image. To build the runner locally instead of pulling from GHCR (use podman build or container build instead if you run scans under those runtimes):

docker build -t scrutineer-runner -f Dockerfile.runner .
go run ./cmd/scrutineer -skills ./skills --runner-image scrutineer-runner

The runner image is not auto-updated, so the analysis toolchain stays on whatever digest you pulled until you pull again. To keep that drift visible, scrutineer checks the registry once at startup (in the background, and failing silently if the registry is unreachable) and flags the runner image when it is more than seven days behind the published :latest -- both in the boot log and as a banner on the Settings page. Update with:

docker pull ghcr.io/alpha-omega-security/scrutineer-runner:latest

If you would rather update automatically, run watchtower against the runner image or pass --pull=always to the runtime; scrutineer deliberately does not pull on its own so a scan's toolchain only changes when you choose to update it.

When the container runner is active, scrutineer starts an authenticated egress proxy on the host and points HTTPS_PROXY/HTTP_PROXY inside the container at it. The proxy only tunnels to an allowlist of hosts: the active model API, *.ecosyste.ms, the major forges (GitHub, GitLab, Codeberg, Bitbucket), common package registries (npm, PyPI, RubyGems, crates.io, Go module proxy, Packagist, Hex, NuGet), advisory sources (semgrep.dev, OSV, NVD, cwe.mitre.org), and the runtime's host endpoint (host.docker.internal for docker/podman, the default gateway IP for Apple's container) for the local skill API. Requests to anything else get a 403 and are logged. Extend the list with egress_allow in the config file. When -model-base-url is set (or, for Claude, falls back to the ANTHROPIC_BASE_URL env var), its hostname is automatically added to the allowlist. The proxy uses a per-process random token so it isn't an open relay; tools that ignore the proxy env are not blocked at the network layer (see threatmodel.md).

For deployments that treat skill prompts as untrusted, pass --hardened (or hardened: true in the config). The flag forces the container runner (--no-container is rejected), trims the egress allowlist to the active backend's model API hosts plus the host skill API (so egress_allow is ignored, drop the flag if you need to widen it), mounts the container rootfs read-only with no-new-privileges, attaches each scan to its own ephemeral network created with --internal (removed when the scan ends) so a process that ignores HTTPS_PROXY has no route out and concurrent scans cannot reach each other, and refuses scans whose workspace footprint exceeds 2 GiB once the clone completes. The 2 GiB check is post-clone: it bounds what hardened mode accepts, not what can land on disk during the clone itself; use OS-level disk quotas if you need a clone-time guarantee. Bundled skills that hit ecosyste.ms or a package registry directly will fail under hardened mode unless they route through the host skill API. Per-ecosystem runner profiles still apply, but profile images that need writable paths beyond /work and /tmp are incompatible. Under rootless podman the proxy runs as a per-scan sidecar container on the --internal network (the host proxy is unreachable there; see docs/podman.md). Under podman, each hardened scan first verifies its --internal network actually blocks external egress while still reaching the egress proxy, and refuses the scan if that cannot be confirmed, so the sandbox never silently weakens.

Runner profiles

When the container runner is active, scrutineer auto-detects a per-ecosystem profile for each scan: it runs brief against the clone and matches its structured package-manager, language, and tool detections. If brief fails or no selector matches, the scan uses the default runner image. The matched profile selects a runner image (built on demand from docker/profiles/<name>/Dockerfile, cached content-addressed by the Dockerfile's hash) and injects that profile's PROFILE.md as the agent's orientation. The most specific profile wins, so a native-extension repo resolves to its *-ext profile ahead of the plain language profile. Force one with ?profile=<name> on the scan API (validated against the skill's requires_profile); default forces the base runner image.

Profile Selected when Adds
php / php-ext package_manager:Composer / tools.native_extension:phpize PHP; php-ext builds PHP debug + ASan/UBSan for C extensions
python / python-ext package_manager:pip / Pipenv / Poetry / uv / PDM / setuptools; python-ext when tools.native_extension:setuptools Extension is present CPython; python-ext builds CPython debug + ASan/UBSan
ruby Bundler Ruby 3.4 + Bundler; metaprogramming / dynamic-dispatch guidance, plus a tripwire that flags an un-instrumented native extension
ruby-ext tools.native_extension:mkmf A superset of ruby: adds an ASan/UBSan Ruby (the default interpreter), valgrind on the stock interpreter, Rust nightly for rb-sys gems, and Brakeman
ruby-rails tools.build:Rails A superset of ruby plus Brakeman, Rails-specific SAST
node npm/pnpm/Yarn/Bun Node.js
go Go Modules Go toolchain
java Maven/Gradle JDK
dotnet NuGet/dotnet CLI .NET SDK
beam Mix/rebar3 Erlang/Elixir
rust Cargo Rust stable + nightly, Miri, sanitizers
perl cpanm or Perl language detection Perl toolchain
c-cpp tools.build:CMake / Make / Autotools / Meson, or C/C++ language detection, after language ecosystems have had a chance to match C/C++ build toolchain

The three Ruby profiles are mutually exclusive at selection time, but ruby-ext and ruby-rails are deliberate supersets of ruby (the same Ruby-level audit, plus their extra coverage), so a detection that errs toward ruby-ext costs only build time, never coverage. A native gem that slips through to the plain ruby profile is caught by that profile's tripwire, which records that memory-safety scanning needs ruby-ext. Add a profile by registering it in internal/worker/profile.go and adding docker/profiles/<name>/{Dockerfile,PROFILE.md}.

Refreshing profile images. Because profile images cache on the Dockerfile's hash, a moved :latest base runner -- or an edited PROFILE.md -- won't always force a rebuild (without skopeo the cache keys on the image ref alone; see Podman). Two helpers give you a clean slate: scripts/ruby-image-reset.sh removes the Ruby profile images (ruby, ruby-ext, ruby-rails), scripts/all-images-reset.sh removes every ecosystem's, and both then pull the latest scrutineer-runner so the next scan rebuilds from a fresh base. Each prompts before removing anything (--force skips it) and uses docker if present, otherwise podman; both run under bash 3.2+, so macOS's stock /bin/bash works.

Podman (rootless)

Pass --runtime podman (or runtime: podman in the config) to run scans under podman instead of docker. Rootless podman is the recommended posture: because the runtime is not root-equivalent, a hostile repository that escapes the scan container lands as an unprivileged subordinate user rather than near-root on the host (see threatmodel.md, T12). There is no auto-detection -- a podman-only host must set --runtime podman explicitly; the default stays docker.

Requirements:

  • podman ≥ 4.7 -- scrutineer reaches the host egress proxy via --add-host host.docker.internal:host-gateway, which older podman does not support; without it, scans fail with network errors. Startup logs a warning if the detected version looks too old or if the host-gateway address can't be resolved.
  • podman ≥ 5.0 (recommended for --hardened) -- the rootless egress proxy sidecar must reach the loopback-bound host skill API, which needs the network backend to forward host-gateway to the host loopback. podman ≥ 5.0 defaults to pasta (--map-host-loopback); older podman can work with a slirp4netns backend that has host-loopback enabled. Where unavailable, hardened scans are refused (fail closed). Startup warns on podman < 5.0 under --hardened. See docs/egress-sidecar.md.
  • /etc/subuid + /etc/subgid -- rootless podman maps the container user back to your host user with --userns=keep-id so scan output and the resumable session store stay host-owned. Your user needs a sub-id range (the usual useradd default provides one; run podman system migrate after changing it). Scrutineer runs a one-off keep-id smoke test at startup and fails fast with a hint if this is misconfigured.
  • skopeo (optional) -- used in place of docker buildx to detect when a moved :latest runner tag should rebuild per-ecosystem profile images. Without it, profiles still build but key their cache on the image ref alone.
  • SELinux -- on an SELinux-enabled host (the Fedora/RHEL/Rocky/Alma default) the runner relabels its bind mounts with :z so the container can read the clone and write its output; without it every scan fails with permission errors. This is handled automatically: --selinux auto (the default) detects the host, and a startup smoke test verifies a real relabeled mount works. Use --selinux off if you pre-label paths yourself, or --selinux on to force it. See docs/podman.md for the :z-vs-:Z rationale.

Under rootless podman with --hardened (verified fail-closed per scan), the scan can't route to a host proxy across the network-namespace boundary, so the egress proxy runs as a per-scan sidecar container on the --internal network to keep enforced egress working. It requires a network backend that forwards host-gateway to the host loopback (modern pasta, default in podman ≥ 5.0, or slirp4netns with host-loopback); where that isn't available the sidecar can't reach the host skill API and the scan is refused (fail closed). Fall back to --hardened-runtime-only for the non-network half (read-only rootfs + no-new-privileges + the 2 GiB post-clone workspace cap), or use rootful podman/docker for --hardened without the host-loopback-forwarding requirement -- the always-on --cap-drop ALL / non-root user / /tmp tmpfs / SELinux :z baseline applies in every mode regardless. See docs/podman.md for the full security model and docs/egress-sidecar.md for the operator validation checklist.

The docker build / docker run commands shown in this repo -- for the runner image and the per-ecosystem profile images under docker/profiles/ -- are CLI-compatible with podman; substitute podman for docker.

Apple container (experimental)

Pass --runtime apple (or runtime: apple in the config) to run scans under Apple's container runtime instead of docker. This path is explicit opt-in; the default stays docker and scrutineer does not auto-detect a docker-less Mac. It is labelled experimental because it is new and Apple's networking has known rough edges, not because of a capability gap: both ordinary and --hardened scans are supported.

Requirements and notes:

  • macOS 26 (Tahoe) on Apple silicon: Apple supports container only on macOS 26 and will not address issues that cannot be reproduced there, so older macOS is out of scope.
  • Apple container with container system start running: startup checks container system status and refuses the runtime if the service is unavailable.
  • Host gateway: scrutineer starts its egress proxy on the host and points scan containers at the runtime's gateway IP, discovered from /proc/net/route inside the runner image. The proxy rewrites local skill-API requests back to 127.0.0.1.
  • Hardened mode: --hardened is supported. Each container runs in its own lightweight VM, so the VM boundary is the isolation; container network create --internal is a vmnet host-only network (egress blocked, host proxy reachable) and each hardened scan proves that fail-closed before running. The one --hardened flag Apple's CLI cannot set is --security-opt no-new-privileges, which the per-container VM boundary substitutes for (Apple's own untrusted-code sandbox hardens the same way). --hardened-runtime-only is a rootless-podman concept and is refused; use --hardened.

The docker build commands shown for the runner image and profiles can be run as container build when you use this runtime. See docs/apple.md for the full parity matrix, the VM-isolation security model, and how hardened mode works.

Flags

Flag Default Description
-config ./scrutineer.yaml if present Path to YAML config file
-addr 127.0.0.1:8080 Listen address
-data ./data Data directory for the database and workspaces
-effort high Claude effort level (claude backend only)
-skills - Additional local directory to load SKILL.md files from; same-named skills override the bundled copies (repeatable)
-skills-repo - owner/repo[@ref] or git HTTPS URL https://host/path[@ref] to clone skills from on startup; @ref pins a branch, tag or commit and the resolved SHA is recorded on every scan
-backend claude Agent CLI the container runner execs: claude, codex, or opencode. Non-claude backends require the containerised runner
--runtime docker Container runtime: docker, podman (rootless podman supported), or apple (Apple, experimental)
--selinux auto Bind-mount SELinux relabeling: auto (relabel when SELinux is detected), on, or off
--no-container false Disable the containerised runner; run claude directly on the host (no isolation). Deprecated alias: --no-docker
--hardened false Strict sandbox: container runtime required, egress restricted to the backend's model API hosts + host skill API, read-only rootfs, internal network
--hardened-runtime-only false The non-network half of --hardened (read-only rootfs + no-new-privileges + 2 GiB workspace cap) without the per-scan --internal network; the rootless fallback for hosts where the --hardened egress sidecar can't run (implied by --hardened). Deprecated alias: --hardened-rootless-runtime
--runner-image release-matched digest (ghcr.io/alpha-omega-security/scrutineer-runner:latest in development builds) Container image for per-scan containers
-concurrency 4 Number of scans to run in parallel
-clone shallow Clone depth: shallow (--depth 1) or full
-scan-timeout 1h Wall-clock limit per scan; exceeded scans fail
-max-turns 0 Per-scan turn cap (0 = unlimited); claude backend only, codex and opencode have no turn cap
-schema-strict false Fail a scan when its report.json does not validate against the skill's schema.json (default: warn in the scan log and parse anyway)
-model-base-url - Custom model API base URL for the active backend (env fallback: ANTHROPIC_BASE_URL for claude). -anthropic-base-url is a deprecated alias.

Config file

Every flag above can be set in a YAML config file instead, loaded from ./scrutineer.yaml by default (override with -config path/to/file; command-line flags always take precedence). See scrutineer.sample.yaml for the full shape.

The config file can also replace the model pick list and pin the fallback default model used by the high tier:

default_model: claude-sonnet-4-6
models:
  - name: Sonnet 4.6
    id:   claude-sonnet-4-6
  - name: Sonnet 5.0
    id:   claude-sonnet-5
  - name: Opus
    id:   claude-opus-4-7

Skills resolve to a model through a tier: high by default, unless the skill's SKILL.md metadata pins scrutineer.model to another tier or an exact model id (bundled lightweight skills such as metadata use mid, security-deep-dive uses max). The Settings page maps each tier to any configured model.

Codex backend

Scrutineer can drive OpenAI's codex CLI instead of claude-code. The runner image bundles the codex binary, so switching is the flag (or backend: codex in scrutineer.yaml) plus a credential:

export CODEX_API_KEY=sk-...
go run ./cmd/scrutineer -skills ./skills -backend codex

The container, egress proxy, language profiles and skill staging stay the same; only the agent CLI inside the container changes. The egress allowlist picks up api.openai.com automatically, and the model pick list defaults to codex's own catalog with tier tags already set -- override with models: in the config if you want a different set. Use -model-base-url or model_base_url: for a custom OpenAI-compatible endpoint; under codex it is passed as openai_base_url to codex exec. The codex backend requires the containerised runner; --no-container with -backend codex is rejected at startup.

See docs/codex.md for what differs from claude (argv, skill staging, credentials, egress), which model ids the pinned codex version accepts, and why codex's own sandbox is disabled inside scrutineer's container.

Opencode backend

opencode is provider-agnostic, so -backend opencode runs whichever model you configure (Anthropic, OpenAI, or anything opencode supports). The runner image bundles the opencode binary; set the credential for your provider and the model id in provider/model form:

backend: opencode
default_model: anthropic/claude-sonnet-4-6

The egress allowlist covers models.dev plus the Anthropic and OpenAI API hosts; other providers go in egress_allow:. Like codex, the opencode backend requires the containerised runner. See docs/opencode.md for provider-credential handling and what differs from claude.

Sandboxed Claude Code configs

In --no-container mode the claude subprocess inherits your ~/.claude/settings.json, so sandbox settings that restrict network or filesystem access there will fail skills that need them. Point claude at a separate config directory just for scrutineer runs:

CLAUDE_CONFIG_DIR=~/.claude-scrutineer go run ./cmd/scrutineer -skills ./skills

Copy your settings.json into that directory and drop the sandbox keys; your normal Claude Code config is untouched. Container mode is not affected: claude runs inside the container with its own environment regardless of the host config.

Security

See SECURITY.md for the reporting policy and threatmodel.md for the full threat model. The short version: scanning a repository is equivalent to running code from it. The containerised runner (when available) isolates each scan, but the default bare-metal mode runs everything as your user. Only scan repositories you'd be willing to clone and build locally.

Further documentation

  • docs/skills.md -- bundled skills, writing your own, frontmatter and output-kind reference
  • docs/import.md -- importing findings from other tools (SARIF, CSV, markdown, minimal JSON) and adding new formats
  • openapi.yaml -- the skill-facing HTTP API
  • docs/database.md -- full database schema reference
  • docs/backup.md -- backing up and restoring the database (built-in scrutineer backup/restore, sqlite3, Litestream)
  • docs/development.md -- project layout, regenerating embedded data, running tests
  • docs/encrypted-sharing.md -- encrypted findings sharing between contributors (age + SSH keys, team keyring management)
  • docs/codex.md -- the codex backend: what differs from claude, sandbox interaction, adding another harness
  • docs/opencode.md -- the opencode backend: provider-agnostic credentials and egress
  • docs/podman.md -- security model and known gaps for the podman / rootless runtime (sandbox isolation, hardened-mode verification)
  • docs/egress-sidecar.md -- operator validation checklist for the rootless --hardened egress proxy sidecar

License

MIT. See LICENSE. Copyright (c) 2026 Alpha-Omega.