Guidance for AI coding agents (and new contributors) working on Sobelow.
If you are looking for how to use Sobelow in your own project, read
usage-rules.md instead.
Sobelow is a security-focused static analyser for Elixir and Phoenix. It parses
source to AST with Code.string_to_quoted/2, walks it with Macro.prewalk/3
looking for known-dangerous shapes, and reports findings with a confidence level.
It ships two entry points that share all logic:
mix sobelow— the Mix task (lib/mix/tasks/sobelow.ex)- an escript — same module, via
main/1
lib/
mix/tasks/sobelow.ex CLI: option parsing, .sobelow-conf merge, dispatch
sobelow.ex Public facade, reporting options, module-name registry
sobelow/
scanner.ex Scan phases, worker lifecycle, output and exit status
scan.ex Scan-local cache, settings, diagnostics and statistics
scan/discovery.ex Source discovery, file/module metadata, router selection
skip_file.ex Skip-file loading, sorting and persistence
version_check.ex Optional version notification, cache and HTTP boundary
parse.ex Compatible entry points for shared AST helpers
parse/source.ex Source loading, skip comments and parse diagnostics
parse/metadata.ex Definition/module metadata and pipeline skip association
parse/calls.ex Call matching, captures, pipes and indexed traversal
parse/variables.ex Tainted arguments, function parameters and locations
parse/template.ex EEx/HEEx expressions and render assignments
print.ex Human-readable output and confidence grading
finding.ex %Finding{} struct, fingerprints, `use Sobelow.Finding`
finding_log.ex Findings collector; json/1 and sarif/1 renderers
meta_log.ex Template state
fingerprint.ex Skip-list state
config.ex Config-file category + config parsing helpers
<category>.ex Category module (XSS, SQL, Traversal, RCE, ...)
<category>/<check>.ex Individual check
test/
support/scan_case.ex End-to-end scan harness
fixtures/apps/ Fixture applications scanned end to end
fixtures/<check>/ Single-file fixtures for unit tests
e2e/ End-to-end tests
Checks continue to call Sobelow.Parse; its delegates preserve the existing
helper names, default arities and Macro.prewalk callbacks. Put new parsing
logic in the relevant internal module, and expose it through the facade when
checks need it. Keep original AST nodes and metadata intact during refactors,
since findings and skip fingerprints depend on them.
mix deps.get
mix test # unit + e2e
mix test.all # hex.audit, format, warnings-as-errors, unused deps, credo
mix coveralls # coverage reportCI runs the matrix in .github/workflows/elixir.yml. mix credo --all --strict
runs on every matrix entry and must be clean; formatting and
--warnings-as-errors are enforced on the newest Elixir only.
The minimum supported Elixir is declared in mix.exs (elixir: "~> 1.12"). Do not
use syntax or stdlib functions newer than that floor without also raising it — that
is a breaking change for downstream users and belongs in its own PR.
A check is a module that sets two attributes, calls use Sobelow.Finding, and
implements run/N. lib/sobelow/traversal/send_file.ex is a representative example:
defmodule Sobelow.Traversal.SendFile do
@moduledoc """
# Directory Traversal in `send_file`
Prose shown by `mix sobelow -d Traversal.SendFile`, and used verbatim as the
SARIF rule help text.
Send File checks can be ignored with the following command:
$ mix sobelow -i Traversal.SendFile
"""
@uid 21
@finding_type "Traversal.SendFile: Directory Traversal in `send_file`"
use Sobelow.Finding
def run(fun, meta_file) do
confidence = if !meta_file.controller?, do: :low
Finding.init(@finding_type, meta_file.filename, confidence)
|> Finding.multi_from_def(fun, parse_def(fun))
|> Enum.each(&Print.add_finding(&1))
end
## send_file(conn, status, file, offset \\ 0, length \\ :all)
def parse_def(fun) do
# index 2 == the `file` argument
Parse.get_fun_vars_and_meta(fun, 2, :send_file, :Conn)
end
enduse Sobelow.Finding aliases Finding, Parse, Print, and Utils for you, and
defines details/0, id/0, and rule/0.
The arity of run/N is set by the parent category, not by convention. Each
category module defines get_vulns/4 and decides what to pass along —
Sobelow.Traversal calls run/2, Sobelow.XSS calls run/4, and the
config-file categories call run/1 or run/2 with paths instead of an AST. Match
the sibling checks in the category you are adding to.
The index passed to Parse.get_fun_vars_and_meta/3,4 is the zero-based position of
the tainted argument in the function being flagged. Note the comment above
parse_def/1 recording the signature — keep that habit, it is the only place the
index is explained.
@uid must be unique across the whole project. It becomes the SARIF rule id
(SBLW + zero-padded uid). Check existing uids before picking one:
grep -rh "@uid" lib/ | sort -n -k2@finding_type must be "<Module.Check>: <description>". The :-split is
load-bearing — Sobelow.Finding.rule/0 uses it to build the SARIF rule name, and
FindingLog.format_sarif/1 uses the prefix to look the module back up.
Missing any one of these fails silently:
@submodulesin the parent category module (e.g.lib/sobelow/traversal.ex) — without this the check never runs.Sobelow.get_mod/1inlib/sobelow.ex— without this--ignore,# sobelow_skip, and the SARIFruleIdall silently stop working for it.- The "Supported modules" list in the
@moduledocoflib/mix/tasks/sobelow.ex— this is whatmix help sobelowprints.
Do not call IO.puts from a check. Route findings through Print.add_finding/1,
which dispatches on Sobelow.format(). If a check needs extra fields beyond
type/file/line/variable, follow the pattern in Sobelow.Config.Secrets.add_finding/5:
a case Sobelow.format() with a branch per format, using
Print.print_custom_finding_metadata/2 for "txt".
Every branch must be handled: "json", "txt", "compact", "flycheck", and a
catch-all _ that only logs. "sarif" is rendered from the log, not per-finding —
Sobelow.format/0 maps it to "json".
The "github" format also renders from the collected log, via
FindingLog.github/0. The catch-all branch retains findings for this format.
Rewrite GitHub annotation locations only when rendering; original finding
filenames and fingerprints must stay intact.
Print.get_sev/2,3 grades a finding by whether the tainted variable is one of the
enclosing function's parameters (or conn.params), i.e. plausibly user-controlled.
Finding.multi_from_def/3 calls it with the confidence you passed to
Finding.init/3:
nilorfalse— grade it::highif user-controlled,:mediumotherwise- an explicit atom (
:low) — pin it, skipping the grading
The common idiom is to pin :low outside controllers and let it grade inside them.
Sobelow deliberately over-reports; when unsure, emit a low-confidence finding rather than nothing.
These are the invariants the crash-class bug fixes established. Preserve them.
- A single bad file must never abort a scan.
Parse.ast/1returns{}for unparseable sources, andParse.get_meta_template_funs/1returns empty metadata for unparseable templates — unless--strictis set, which is the only mode allowed toSystem.halt/1on a parse failure. - Never
System.halt/1from a check. Only the CLI layer and--strictmay halt. - Never fail a scan over auxiliary state. A corrupt version-check cache, an
unwritable
~/.sobelow, or a missingdeps/directory must degrade quietly. A security tool that exits 0 without scanning is worse than one that reports nothing at all. - Prefer total functions over pattern matches that assume success. Several past
bugs were
{a, b} = some_call()wheresome_call()had a scalar fallback.
Finding.fingerprint/1 is :erlang.phash2([type, vuln_source, filename, vuln_line_no]).
It is what .sobelow-skips entries match on.
Changing any component changes the fingerprint, which silently invalidates
users' committed skip files and resurfaces findings they had triaged away. If a
change moves reported line numbers, say so explicitly in the CHANGELOG. There is a
legacy_fingerprint (md5) kept for backwards compatibility with older skip files —
follow that precedent rather than breaking people.
- End-to-end:
use Sobelow.ScanCase, thenscan("<fixture app>")returns the decoded JSON report. Helpers:findings/1,finding_modules/1,findings_for/2,scan_io/2for raw stdout/stderr,temp_fixture_file/3for files that must not be committed in a broken state. - These tests must be
async: false— the scan pipeline uses named processes and application env. - Unit: single-file fixtures under
test/fixtures/<check>/exercising the check's parsing helpers directly. Seetest/config/csrf_test.exs.
mix format covers test/**, so a fixture that is meant to be syntactically
invalid must be written at runtime with temp_fixture_file/3, not committed.
Do not name a fixture *_test.exs — ExUnit will load it as a test file.
- Run
mix formatbefore committing; CI enforces it. - Internal modules are
@moduledoc false. A check's@moduledocis user-facing documentation printed by-d— write it for a developer triaging a finding. - Test-only public functions get
@doc false. - Keep the public API additive. Sobelow is depended on widely enough that renaming or removing a check, a CLI flag, or a finding type is a breaking change.
