Profile
Back to NewsBack
GitHub Trending 36 min
Reader Mode
alpha-omega-security/scrutineer: Security through scrutiny

alpha-omega-security/scrutineer: Security through scrutiny

18 hours ago

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, opencode, or GitHub Copilot CLI 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.27+:

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 -buildvcs=true ./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.

The -buildvcs=true flag includes the source commit in go run builds. Settings > About shows that commit, with a -dirty suffix for local changes, and its commit date. Ordinary go build commands include this metadata automatically in a Git checkout.

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, opencode, or copilot instead, see the Codex backend, Opencode backend, and Copilot 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/copilot 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. To take only some skills out of the container (say verify for a project whose build toolchain only exists on the host, while triage and the deep-dive skills keep their profile containers), list them under host_skills in the config file: those run claude on the host with no egress proxy and reach the skill API on the loopback address, and the option is refused under --hardened and with non-claude backends, like --no-container.

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/ 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 scan one package inside a monorepo, append # to the URL -- e.g. https://github.com/rails/rails#activesupport (or paste a .../tree// URL). Scrutineer scopes the whole pipeline to that sub-folder and gives the sub-package its own page, findings, packages, advisories, disclosure channel, and export, instead of rolling every package up under the repository. By default a sub-package scan is confined to its own sub-folder (siblings pruned, .git kept), widening to the whole repository automatically if the sub-package cannot resolve its dependencies in isolation; set subproject_scope: soft to always scan against the whole tree, or monorepo_attribution: false to keep registry data repo-wide (see scrutineer.sample.yaml). The subprojects skill also auto-discovers a monorepo's sub-packages, so you can scan them from the repo page without composing URLs by hand. Findings, packages, advisories, the disclosure channel, and export are attributed per sub-package; dependents, maintainers, posture, and health stay repo-wide. There is no per-sub-package schedule -- to re-scan sub-packages on a cadence, drive the local API from cron or a systemd timer (see scripts/rescan-subprojects.sh).

To onboard a whole GitHub org at once, open Add multiple → Import 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, and for the skills listed in host_skills, the kernel dereferences them normally, so only point scrutineer at trees you trust.

The optional analysis tools (semgrep, bandit, zizmor, betterleaks, darnit, 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
  • Classifier and release-viability 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; every true positive chains into the critic release-build assessment, while High/Critical true positives also chain into verify
  • 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
  • CERT/CC VINCE submission -- map a reviewed disclosure into VINCE's authenticated vulnerability-report form, review every field and attachment, then send one multipart request and record the returned VRF ID. The action requires a config-file API key; see docs/disclosure-fallback.md
  • 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; see the HTTP API overview for authentication boundaries and openapi.yaml for the full route specification
  • Live updates -- SSE streaming of scan logs and status changes, pushed rather than polled; the jobs list, the repositories list and a repository's Scans tab refresh their own table when a scan starts, finishes, or is cancelled, paused, resumed or queued, keeping the current scroll, filters and sort. Elapsed times ("started 3m ago") count up on their own, recomputed in the page rather than fetched
  • 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, correlating cost with repository workload proxies and linking runs that cost at least ten times their skill median; see docs/usage.md. 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. Set pause_on_overage: true to pause model scans instead; this takes precedence over downgrading and preserves resumable work.
  • 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 | | reflect | Records tool failures and working entrypoints from a finished scan set into the threat model | | 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 | | embedded-native | Runs brief --json at the repository root and each initialized shallow submodule to map native languages, extension bridges, build tools, manifests, and dependencies | | 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 | | history | Mines Git history for security fixes that never received an advisory, with ancestry-checked incremental caching and explicit partial-history reporting | | 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 | | bandit | Python-only static analysis mapped into findings shape, carrying bandit's own confidence level; runs alongside semgrep on repositories with Python | | betterleaks | Secret scanning across the available Git history, with raw secret values removed before findings are written | | vuln-scan | High-recall model-backed static candidate scan adapted from Anthropic's defending-code reference harness | | zizmor | GitHub Actions workflow audit enriched with bundled trust-boundary, credential, and supply-chain guidance | | 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 an evidenced attack tree and typed attacker prerequisites, runs three isolated attempts, scores five fixed evidence criteria, gates the verdict on host-matched design controls, and applies deterministic severity caps from reconciled controls and verified prerequisites | | critic | Assesses whether a true-positive finding can affect a real release build, stores an append-only attack-path record, and marks release paths as viable, non-viable, sample/test-only, or conditional | | 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. Every true_positive chains to critic, and High/Critical true positives also chain 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 | | reattack | Applies one immutable gated patch to a fresh checkout and independently exercises three root-cause variants plus a benign control; records a bypass, verified resistance, or incomplete verification without overwriting prior attempts | | 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 | | compliance | Audits the repo against the 62 OpenSSF Baseline controls with darnit, resolves the controls darnit defers to LLM analysis or could not verify without a forge token, and records per-control verdicts and the attained Baseline level on the Compliance tab | | 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 | | audit-injection | Opt-in static audit for command/code execution, unsafe deserialization, and server-side template injection with ecosystem-specific references | | audit-exfil | Opt-in static audit for SSRF, path traversal, XXE, and response or diagnostic leakage with ecosystem-specific references | | audit-authz | Opt-in static audit for IDOR, tenant isolation, fail-open guards, privilege escalation, and authorization decisions based on unverified claims | | audit-pii | Opt-in static audit for real personal or customer-identifying data committed to source or exposed through logs, URLs, telemetry, exports, and responses | | audit-memory | Opt-in static audit for reachable memory corruption in first-party C, C++, unsafe Rust, native extensions, and FFI boundaries | | audit-package-manager | Static audit of package manager clients, registries, and proxies against a bundled threat model; triage enqueues it when source evidence shows the repository implements one | | audit-web | Reviews web sessions, browser origins, uploads and business workflows when triage finds an implemented web application or API | | audit-embedded | Reviews firmware updates, boot chain, provisioning, device credentials and debug interfaces when triage finds device firmware |

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, Compliance, Packages, Dependencies, Dependents, Advisories, Maintainers, Data, Scans, and Chat, 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.
  • Reporting -- corpus-wide activity over a rolling window (24 hours, 7 days, 30 days, or all time) with a minimum-severity floor on findings: repositories scanned, runs started and completed, findings, a per-day breakdown, and the per-scan cost and token averages beside their all-time figures. Downloadable as CSV or JSON; JSON exports pulled from several discrete scanner instances can be combined into one corpus-wide report with go run ./scripts/merge-reports a/report.json b/report.json; see docs/reporting.md.
  • 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). The chat pool is sized at half the concurrency the server started with and is not resized here, so a change only takes effect for chat after a restart.

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. Every true positive chains into the release-build critic; High/Critical true positives also chain into verify. Outside that path: click "Run verification" to start independent confirmation using model tokens, "Mark triaged" if you have already verified it independently, or "Reject"
  2. enriched -- verification ran. Review and click "Mark triaged" to save your decision
  3. triaged -- confirmed real. Review the critic's release-build assessment, then click "Draft disclosure" (a model job) to generate the draft. An exact NON_VIABLE assessment blocks private disclosure, public issues, and upstream reporting; VIABLE, SAMPLE_OR_TEST, CONDITIONAL_VIABLE, and unassessed findings remain analyst decisions
  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. With federation_peers configured, every route out of this state (the button, the report-upstream and public-issue skills, and the VINCE submission) first asks each peer whether it already holds the same finding and, on a match, names their contact so you coordinate before proceeding (see docs/interchange.md)
  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.

The repository page carries a Federation opt-out control: recording one cancels the repository's queued, running and paused scans, then refuses every new scan on it, including scheduled runs and automatic follow-ups. It also withdraws the repository from the interchange feeds, and, when the public feed is configured, publishes the request so other federated instances honour it too; an opt-out arriving on a peer feed sets it here the same way. See docs/interchange.md.

The Chat tab on a repository or a finding opens a read-only conversation with the agent about that code: it works from a copy of the clone plus a snapshot of the findings, and is restricted to reading and searching, so it can explain and cross-check but never modify anything. Each conversation keeps its own working copy on disk, so delete the ones you are done with from the conversation page to reclaim the space.

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) creates an immutable numbered remediation attempt and updates the finding's suggested_fix projection. The current diff is downloadable from the finding page as a .patch file and included in markdown report exports. To iterate on your own edits, push them to a branch, scan that branch, then run patch against the new scan. Re-attack patch checks out an attempt's exact base commit, applies its exact diff, and independently exercises at least three distinct root-cause variants plus a benign control. Only a complete failed_to_bypass result derives verified_secure; a same-class, same-sink bypass is retained as evidence and staged for later patch attempts, while missing or inconsistent evidence remains verification_incomplete. Earlier attempts and bypass evidence remain immutable.

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 --build-arg COMMIT="$(git rev-parse HEAD)" -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. For copilot, pass -e GH_TOKEN=... (a fine-grained PAT or gh auth token value; classic ghp_ PATs are rejected by Copilot CLI) and add -backend copilot.

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 -buildvcs=true ./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 -buildvcs=true ./cmd/scrutineer -skills ./skills --runner-image scrutineer-runner

The runner's bundled Claude Code only moves when a maintainer merges Renovate's update, and Renovate waits until a Claude Code release is seven days old, so a model that needs a newer CLI takes at least a week to become usable. It takes longer on a release binary, whose default runner image only changes with the next scrutineer release. To run a newer Claude Code without falling back to --no-container, build the runner from a checkout of the scrutineer version you run and override the CLAUDE_*_LOCK build arguments with the Claude Code tag and the SHA-256 of claude-linux-x64.tar.gz and claude-linux-arm64.tar.gz from that Claude Code release's SHASUMS256.txt. Per-ecosystem profile images are cached by a locally built runner's tag alone, so give each version its own tag to have them rebuilt on top of it:

docker build -t scrutineer-runner:claude-2.1.284 -f Dockerfile.runner --build-arg CLAUDE_AMD64_LOCK=v2.1.284@sha256: --build-arg CLAUDE_ARM64_LOCK=v2.1.284@sha256: . go run -buildvcs=true ./cmd/scrutineer -skills ./skills --runner-image scrutineer-runner:claude-2.1.284

The staleness check below never flags a locally built runner, so drop --runner-image once the published runner carries the Claude Code release you need.

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

On sidecar-backed hardened runtimes, scrutineer also refuses a runner image whose proxy binary does not support the current host-API tunnel policy. Pull or rebuild custom runner images when updating the host binary.

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 permits 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. That API port accepts inspected HTTP proxy requests but refuses raw CONNECT tunnels; separately configured host-local model ports remain tunnelable. 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 Docker Desktop and rootless podman the proxy runs as a per-scan sidecar container on the --internal network because the host proxy is unreachable there; see docs/podman.md for the rootless podman path. These runtimes verify that each hardened network blocks external egress while still reaching the sidecar, and refuse 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//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= 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 or RubyGems | 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 | | scala | sbt or Scala language detection | A superset of java plus sbt and Scala-specific reproducer guidance | | java | Maven/Gradle | JDK | | dotnet | NuGet/dotnet CLI | .NET SDK | | beam | Mix/rebar3 | Erlang/Elixir | | rust | Cargo | Rust stable + nightly, Miri, sanitizers | | ocaml | opam, tools.build:Dune, or OCaml language detection | opam + a compiled OCaml 5.x switch + dune | | swift | Swift Package Manager or Swift language detection | Swift toolchain + swiftly | | 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//{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 or Docker Engine 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
... (README truncated for length)
Chat with me