Argus.Analysis behaviour (Panoptes v0.13.0)

Copy Markdown View Source

Behaviour and API for BEAM program analyses.

Each analysis is a module that implements this behaviour, declaring its name, description, Souffle rules file, required extractors, and output relations. The system discovers these modules at runtime from the :panoptes application's module list.

Defining a custom analysis

Create a module that implements @behaviour Argus.Analysis:

defmodule MyApp.Analyses.Unused do
  @behaviour Argus.Analysis

  @impl true
  def name, do: :unused

  @impl true
  def description, do: "find unused functions"

  @impl true
  def rules_file, do: "unused.dl"

  @impl true
  def extractors, do: []

  @impl true
  def output_relations do
    [
      %{
        name: :unused_function,
        fields: [{:func, :symbol, "function ID"}],
        doc: "Function that is never called."
      }
    ]
  end
end

You can also pass {:custom, "path/to/rules.dl"} to run/3 to run ad-hoc Datalog rules without defining a module.

Built-in analyses

See modules under Argus.Analyses.* for the full list. Use builtin_analyses/0 or builtin_analysis_modules/0 to discover them at runtime.

Summary

Callbacks

Converts one output-relation row into finding attributes.

Functions

Returns the list of built-in analysis names.

Returns all discovered built-in analysis modules.

Derives the stage-0 relations into an existing facts directory.

Extracts facts from the given modules once, for one or more analyses.

Looks up a built-in analysis module by name.

Restricts raw Souffle results to the relations a built-in analysis declares as its outputs.

The relations an analysis actually reads, as Souffle resolves them.

Returns output relations for a named built-in analysis.

Runs an analysis against the given modules.

Runs a single analysis's Datalog rules against an existing facts directory.

The path to the stage-0 rules file.

Types

analysis()

@type analysis() :: atom() | {:custom, Path.t()}

output_relation()

@type output_relation() :: %{
  :name => atom(),
  :fields => [Argus.Schema.field()],
  :doc => String.t(),
  optional(:key) => [atom()]
}

result()

@type result() :: %{required(String.t()) => [[String.t()]]}

Callbacks

description()

@callback description() :: String.t()

extractors()

@callback extractors() :: [module()]

finding(relation, row)

(optional)
@callback finding(relation :: atom(), row :: [String.t()]) :: Argus.Findings.attrs()

Converts one output-relation row into finding attributes.

Receives the relation name (as declared in output_relations/0) and the raw row (a list of strings, one per declared field). Implementations assign a severity, write title/detail prose, and attach the most precise anchor the row allows — see Argus.Findings for the construction helpers. Optional: analyses without it fall back to a generic :info-severity rendering of the relation's declared doc.

Relations that carry witness columns (a call site that evidences the defect) can produce several rows for one logical finding — one per witnessing site. Such a relation declares :key: the field names that identify the finding. Rows agreeing on the key fields are deduplicated before conversion, and finding/2 receives one deterministic representative (the lexicographically least row), so finding counts do not depend on how many sites witness the same defect.

name()

@callback name() :: atom()

output_relations()

@callback output_relations() :: [output_relation()]

rules_file()

@callback rules_file() :: String.t()

Functions

builtin_analyses()

@spec builtin_analyses() :: [atom()]

Returns the list of built-in analysis names.

builtin_analysis_modules()

@spec builtin_analysis_modules() :: [module()]

Returns all discovered built-in analysis modules.

derive_stage0(facts_dir, opts \\ [])

@spec derive_stage0(
  Path.t(),
  keyword()
) :: :ok | {:error, term()}

Derives the stage-0 relations into an existing facts directory.

Stage 0 is the shared call graph (call_edge): every client analysis needs it, and before stratification each one re-derived it inside its own solve from the layer-1 bytecode relations. Deriving it once here removes that redundancy, and — more importantly for incremental consumers — keeps instruction, remote_call and friends out of the input set of analyses that only reason about supervision structure.

extract_facts/3 calls this for you, so batch callers need not think about it. Incremental consumers call it directly, memoize the result, and reuse it across solves: the output is markedly more stable than its inputs, since it moves only when the call structure changes, not when a function body does.

Writes call_edge.facts into facts_dir. Idempotent — re-running overwrites with the same content for the same inputs.

extract_facts(modules, analyses, opts \\ [])

@spec extract_facts(modules :: [atom() | String.t()], [analysis()], keyword()) ::
  {:ok, Path.t()} | {:error, term()}

Extracts facts from the given modules once, for one or more analyses.

Runs the pipeline with the union of the analyses' default extractors (plus any extra :extractors from opts), writing .facts files to a fresh temporary directory. Because the pipeline always materializes every schema relation (empty files included), the resulting directory can feed run_rules/3 for each of the analyses without re-extraction.

Returns {:ok, facts_dir} or {:error, reason}.

fetch_module(name)

@spec fetch_module(atom()) :: {:ok, module()} | :error

Looks up a built-in analysis module by name.

Returns {:ok, module} or :error if not found.

filter_to_outputs(results, name)

@spec filter_to_outputs(result(), analysis()) :: result()

Restricts raw Souffle results to the relations a built-in analysis declares as its outputs.

Intermediate clientlib relations (call_reachable, sync_dep, ...) are dropped. Custom analyses and unknown names pass through unchanged — there is no declaration to filter against.

input_relations(analysis)

@spec input_relations(analysis()) :: {:ok, [String.t()]} | {:error, term()}

The relations an analysis actually reads, as Souffle resolves them.

Derived from the transformed RAM program — the form that actually executes — rather than the source .dl. That distinction matters: the parsed AST lists every declared input including ones later pruned as unused, so reading the source over-approximates, and following .include by hand under-approximates (Souffle resolves includes relative to the including file). The RAM's operation="input" entries are the set Souffle will genuinely open.

Incremental consumers use this to project a per-analysis fact directory, so an analysis only re-solves when a relation it truly reads has moved.

Returns {:ok, [relation_name]} or {:error, reason}.

output_relations(name)

@spec output_relations(atom()) :: {:ok, [output_relation()]} | :error

Returns output relations for a named built-in analysis.

Returns {:ok, relations} or :error if the analysis is not found.

run(modules, analysis, opts \\ [])

@spec run(modules :: [atom() | String.t()], analysis(), keyword()) ::
  {:ok, result()} | {:error, term()}

Runs an analysis against the given modules.

Returns {:ok, results} where results is a map of relation name to list of rows. Each row is a list of strings.

Options

  • :concurrency — number of parallel extraction workers (default: schedulers)
  • :extractors — list of domain extractor modules to run
  • :souffle_bin — path to souffle binary (default: auto-detect)

run_rules(facts_dir, analysis, opts \\ [])

@spec run_rules(Path.t(), analysis(), keyword()) :: {:ok, result()} | {:error, term()}

Runs a single analysis's Datalog rules against an existing facts directory.

The facts directory must contain .facts files for every relation the analysis declares as input — extract_facts/3 guarantees this when the analysis was included in its analyses list.

Returns {:ok, results} or {:error, reason}.

stage0_rules_path()

@spec stage0_rules_path() :: Path.t()

The path to the stage-0 rules file.