Analyzer SDK¶
An analyzer is a small Python package that inspects a repository and returns structured signals: routes, outbound calls, databases and hints, each with a file and line. AttackMap discovers analyzers through a Python entry point, so your plugin is a package that advertises itself. Core doesn't need to change.
Canonical contract
The authoritative contract is the attackmap.sdk package in the
engine repo (src/attackmap/sdk/).
Anything you can import from attackmap.sdk is stable across minor
releases. Anything else in attackmap is internal. If this page and the
repo disagree, the repo is correct.
Version note
Run order by priority, opt-in enabled_by_default=False, failure
isolation (analyzer_errors, --strict-analyzers) and the
attackmap.sdk.fs helpers are on core main and ship in the first release
after v0.4.31. On v0.4.31 and earlier, built-ins always run first,
priority and enabled_by_default are ignored, an analyzer that raises
aborts the whole scan, and attackmap.sdk.fs doesn't exist.
A complete example¶
This analyzer finds Flask @app.route(...) decorators. It loads and runs
against core as written; the docs repo has a test that extracts this block and
runs it.
import re
from pathlib import Path
from attackmap.sdk import (
AnalyzerMetadata,
AnalyzerResult,
FrameworkHint,
Route,
iter_repo_files,
line_of,
line_snippet,
read_source,
rel,
)
_ROUTE = re.compile(
r"""@\w+\.route\(\s*["']([^"']+)["']"""
r"""(?:[^)]*methods\s*=\s*\[([^\]]*)\])?"""
)
_MANIFESTS = {"requirements.txt", "pyproject.toml", "setup.cfg", "Pipfile"}
class FlaskLiteAnalyzer:
metadata = AnalyzerMetadata(
name="flask-lite", # slug; also the entry-point key and the -m value
display_name="Flask Lite",
version="0.1.0", # your plugin's version, not attackmap's
description="Detects Flask @app.route handlers.",
scope="Small Flask services.",
languages=["python"],
targets=["flask"],
priority=100, # framework analyzers use 50-149; lower runs first
experimental=True,
enabled_by_default=False, # opt-in: runs only with -m flask-lite
)
@property
def name(self) -> str:
# Required: core rejects an analyzer without a non-empty `name`.
return self.metadata.name
def detect(self, root: str | Path) -> bool:
# Cheap check: does a manifest mention flask?
for path in iter_repo_files(root, names=_MANIFESTS):
text = read_source(path, root=root)
if text and "flask" in text.lower():
return True
return False
def analyze(self, root: str | Path) -> AnalyzerResult:
root = Path(root)
result = AnalyzerResult(root=str(root), languages=["python"])
for path in iter_repo_files(root, suffixes={".py"}, include_tests=False):
text = read_source(path, root=root)
if text is None: # unreadable or binary: skip, never raise
continue
result.files_scanned += 1
file = rel(path, root)
for match in _ROUTE.finditer(text):
line = line_of(text, match.start())
methods = [
m.strip(" '\"").upper()
for m in (match.group(2) or "").split(",")
if m.strip(" '\"")
] or ["GET"]
for method in methods:
result.routes.append(
Route(path=match.group(1), method=method, file=file, line=line)
)
result.framework_hints.append(
FrameworkHint(
hint="flask",
file=file,
line=line,
evidence_text=line_snippet(text, line),
)
)
return result
An analyzer needs four things (AnalyzerProtocol):
| Member | Purpose |
|---|---|
metadata: AnalyzerMetadata |
Static description; see the field table. |
name (property or attribute) |
Non-empty string, normally metadata.name. Without it the plugin is skipped with loaded object is not a valid analyzer. |
detect(root) -> bool |
Should this analyzer run on this repo? Keep it cheap. If it raises, the analyzer is treated as not matching and a warning is logged. |
analyze(root) -> AnalyzerResult |
Extract signals and return them. |
The entry point may name the class, a zero-argument factory or an instance. Core instantiates a class with no arguments.
Register the entry point¶
Advertise the analyzer under the attackmap.analyzers group in your
package's pyproject.toml:
Install the package into the same environment as AttackMap. It then appears in
attackmap modules and can be selected with -m flask-lite. If it doesn't
appear, look for a warning from the attackmap.analyzers logger: failing to
load, invalid metadata and a missing name each log why the plugin was skipped
(Failed to ... or Skipping entry point ...).
AnalyzerMetadata fields¶
From the pydantic Field descriptions in attackmap/analyzer_contracts.py.
Adding fields is a minor-release change; removing or renaming one is a
major-version break.
| Field | Type | Default | Description |
|---|---|---|---|
name |
str |
required | Slug identifier. Must match [a-z0-9][a-z0-9-]*. The entry-point key AttackMap uses to select the analyzer (e.g. from --module) and to attribute signals via provenance. |
display_name |
str |
name |
Human-friendly name shown in attackmap modules output and reports. |
version |
str |
"0.1.0" |
Semver of the analyzer plugin itself, not attackmap. Should match your package's pyproject.toml version. Must not be empty. |
description |
str |
"" |
One-sentence summary of what the analyzer detects. Shown in module listings. |
scope |
str |
"" |
One sentence on the kinds of repositories this analyzer targets (e.g. "Node/TypeScript backend service repos"). |
targets |
list[str] |
[] |
Frameworks, protocols or platform tokens the analyzer specializes in (e.g. ["react-native", "expo"]). Combined with languages to compute ecosystems. |
languages |
list[str] |
[] |
Programming languages the analyzer parses (e.g. ["javascript", "typescript"]). Combined with targets to compute ecosystems. |
priority |
int (≥ 0) |
100 |
Run order across built-ins and plugins: lower runs first, ties broken by name. Merge is first-seen-wins in that order, so a lower value also wins a duplicate signal. Convention: 0–49 broad language analyzers, 50–149 framework analyzers, 150+ app-specific analyzers. |
experimental |
bool |
True |
Whether the analyzer is still stabilizing. Doesn't affect execution, only how core presents it in listings. Experimental analyzers should normally leave enabled_by_default False. |
enabled_by_default |
bool |
False |
Whether to run without an explicit --module. False makes it opt-in; see Opt-in analyzers. Broad language analyzers set it True; specialty framework and app analyzers usually leave it False. |
metadata.ecosystems is a read-only, de-duplicated, lowercased tuple of
languages + targets. The legacy ecosystems= constructor argument is still
accepted and used as languages when neither languages nor targets is set.
What analyze returns¶
AnalyzerResult is ScanResult: the SDK exports it as an alias, so
AnalyzerResult is ScanResult is true and either name works. root is the
only required field. Fill in the signal lists below and files_scanned, and
optionally languages and limitations (strings describing what you couldn't
analyze).
Returning a plain dict also works: core validates it as a ScanResult.
Returning anything else (including None) counts as a failure; see
Failure isolation.
Signal models¶
All are exported from attackmap.sdk. Every model also has an optional
source_analyzer field, which core fills with your analyzer's name if you
leave it empty. line is 1-indexed and optional everywhere. evidence_text
is the source snippet that justifies the signal.
ScanResult field |
Model | Fields (required in bold) | Dedup key |
|---|---|---|---|
routes |
Route |
path, method (default "ANY"), file, line |
(path, method, file) |
external_calls |
ExternalCall |
target, file, line, method, evidence_text |
(target, method, file) |
databases |
DatabaseHint |
kind, file, line, evidence_text |
(kind, file) |
auth_hints |
AuthHint |
hint, file, line, evidence_text, confidence (0.7) |
(hint, file) |
service_hints |
ServiceHint |
same as AuthHint |
(hint, file) |
edge_hints |
EdgeHint |
same as AuthHint |
(hint, file) |
entrypoint_hints |
EntrypointHint |
same as AuthHint |
(hint, file) |
protocol_hints |
ProtocolHint |
same as AuthHint |
(hint, file) |
framework_hints |
FrameworkHint |
same as AuthHint |
(hint, file) |
secret_hints |
SecretHint |
name, file, line, evidence_text, confidence (0.85), kind ("env_reference") |
(name, file, line) |
dependencies |
DependencyHint |
name, version, ecosystem (pypi, npm, go, cargo, composer or swiftpm), file, line, dev, resolved, direct, via, evidence_text |
(ecosystem, name, version, file, dev) |
ScanResult has other list fields too: taint_chains, vulnerabilities,
authz_candidates, crypto_weaknesses, web_hardening_issues,
code_weaknesses, workflow_issues, anomalies and analyzer_errors. Core
fills these itself from the merged signals, once per scan. They aren't part
of the analyzer contract, so leave them empty.
What analyzers don't do¶
Analyzers emit signals, not conclusions. They don't produce findings, severities, taint chains, attack paths or reports. Core owns all of that and derives it from the merged signals. If you want a finding, emit a richer signal and let core do the reasoning.
How results are merged¶
Core runs every selected analyzer, stamps provenance, and merges the results
with attackmap.analyzers.merge_analyzer_results. The dedup keys come from
attackmap.merge.MERGE_SCHEMA.
- Order. Analyzers run in
(metadata.priority, name)order, built-ins and plugins alike. - First seen wins. Within each list field, a signal whose dedup key (the
table above) was already seen is dropped. Because of the run order, the
analyzer with the lower
priorityvalue wins a duplicate. To make your richer route replace a built-in's, give your analyzer a lower priority than that built-in (for example, the built-inpython-webanalyzer is 20). - Order is preserved. Signals appear in the order they were first seen.
languagesis the sorted union,limitationsthe de-duplicated union, andfiles_scannedthe sum.
Two analyzers that both emit GET /items in app.py produce one route in the
report, the one from whichever ran first.
Opt-in analyzers¶
With no -m/--module, core runs every installed analyzer whose detect()
returns True, except those with enabled_by_default=False. Those are
opt-in: they run only when named with -m, and detect() still gates them.
When an opt-in analyzer matches a repo but isn't selected, the CLI says so:
Passing any -m switches to explicit selection: only the named analyzers are
considered (built-ins included only if named), opt-in or not.
Failure isolation¶
If analyze() raises, or returns something other than a ScanResult or a
valid dict, core logs it, skips that analyzer and carries on with the rest.
The console prints Analyzer '<name>' failed and was skipped: …, and
attackmap-report.json lists it under scan.analyzer_errors with the
analyzer name, exception type and a redacted message.
Pass --strict-analyzers to fail fast instead. Use it while developing a
plugin and in CI, so a broken analyzer fails the run rather than silently
reducing coverage.
Optional keyword arguments¶
Core calls analyze(root). It passes two extra keyword arguments only if your
analyze signature declares them, so a plugin that doesn't know about them
keeps working:
progressis passed when the CLI is showing progress. It's the scan's progress reporter;progress.stage("label")shows what your analyzer is doing. Don't callprogress.done(), because core ends the scan. The type isn't part of the stable SDK, so treat it as optional and duck-typed.recallis passed asTrueonly when the user ran--recall. Use it to surface lower-confidence signals you'd normally hold back.
def analyze(self, root, progress=None, recall=False):
if progress is not None:
progress.stage("Flask routes")
...
Walking and reading the repo¶
Use attackmap.sdk.fs (also re-exported from attackmap.sdk) rather than
Path.rglob and read_text:
| Helper | What it does |
|---|---|
iter_repo_files(root, *, suffixes=None, names=None, skip_dirs=DEFAULT_SKIP_DIRS, max_bytes=None, include_tests=True, on_skip=None) |
Yields regular files, sorted and depth-first. Filters by suffix (case-insensitive) or exact file name. Prunes skip_dirs by repo-relative directory name, never follows symlinks out of the repo, skips AttackMap's own output directories and files over the size cap. include_tests=False drops test, spec and fixture files. on_skip(path, reason) reports skipped symlinks and oversized files. |
read_source(path, *, root=None) |
Reads text as UTF-8 (with or without BOM), then cp1252, then latin-1. Returns None for unreadable or binary files and, with root, for paths outside it. Never raises. |
rel(path, root) |
path relative to root, POSIX-style. Use it for file fields. |
line_of(content, offset) |
1-indexed line of a character offset, such as match.start(). |
line_snippet(content, line, max_len=200) |
The stripped text of a line, truncated, for evidence_text. |
DEFAULT_SKIP_DIRS |
The directories no analyzer should enter (node_modules, .git, vendor, build output and so on). Extend it with a set union for your ecosystem, e.g. adding bin and obj for .NET. |
The lower-level repo-confined helpers walk_repo, is_contained,
contained_file and read_repo_text are exported too.
Testing your plugin¶
- Install the plugin and core into one environment and check it's listed by
attackmap modules. - Run
attackmap analyze <fixture-repo> -m <name> --strict-analyzersso any exception fails the run. - In unit tests,
attackmap.analyzers.analyze_repository(root, analyzers=[MyAnalyzer()], strict=True)runs your analyzer through the real merge and provenance path.
attackmap.analyzers is internal and may change between minor releases, so
keep such tests close to your pinned core version. The official plugins
(e.g. attackmap-analyzer-python, attackmap-analyzer-go) are working
templates.
Conventions¶
- Precision over recall. Validate every heuristic against real
repositories before release. False positives erode trust fastest. Hold
speculative signals for
recall. - Exclude noise. Skip tests and vendored or minified code:
include_tests=FalseandDEFAULT_SKIP_DIRScover the common cases. - Cite evidence. Emit
file(repo-relative, viarel) andlineon every signal so findings can be diffed and grounded. - Never raise for one bad file.
read_sourcereturnsNoneinstead; skip the file and, if it matters, add alimitationsentry.