Skip to content

Adding a New Analyzer

A minimal analyzer package looks like:

my_analyzer_package/
├── pyproject.toml
└── my_analyzer/
├── __init__.py exports MyAnalyzer
├── analyzer.py MyAnalyzer(BaseAnalyzer)
├── parser.py parsing logic
├── manifest.py VERSION, FILE_GLOBS
└── rules/
├── generic-rules.md
└── aws-rules.md

BaseAnalyzer lives in OpenTremor Core (opentremor_core.controllers.analyzers.base) — your package depends on opentremor-core, not the other way around.

my_analyzer/manifest.py
VERSION = "1.0.0"
# Glob patterns of file paths this analyzer claims — used by auto-routing
# (POST /{namespace}/ingest|analyze/auto and the GitHub webhook) to decide
# which analyzer handles which changed file in a diff. Leave empty if the
# input isn't a single file (like a full `terraform plan` text stream) —
# implement sniff() below instead for that case.
FILE_GLOBS: list[str] = ["*.tf"]
my_analyzer/analyzer.py
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from opentremor_core.controllers.analyzers.base import BaseAnalyzer
from .manifest import FILE_GLOBS, VERSION
from .parser import MyParser
_RULES_DIR = Path(__file__).parent / "rules"
class MyAnalyzer(BaseAnalyzer):
name = "my-analyzer" # URL slug — must be URL-safe
description = "Analyzes XYZ for security issues."
version = VERSION
file_globs = FILE_GLOBS
# Optional: only needed if raw_input can arrive with no file path to glob
# against FILE_GLOBS (e.g. a single blob pasted into the dashboard's
# Analyze form, not a diff). Default is 0.0 (opt out of content-sniffing
# entirely) — override only if your format is recognisable from content
# alone. content_router.route_auto asks every registered analyzer for a
# score and picks the highest one above its confidence threshold.
def sniff(self, raw_input: str) -> float:
return 1.0 if "some distinctive marker text" in raw_input else 0.0
def ingest(self, raw_input: str) -> list[dict[str, Any]]:
return MyParser(raw_input).parse()
def get_rules(self, rule_type: Optional[str] = None) -> str:
filename = f"{rule_type}-rules.md" if rule_type else "generic-rules.md"
rules_file = _RULES_DIR / filename
if not rules_file.exists():
raise FileNotFoundError(f"No rules file: {rules_file}")
return rules_file.read_text()
my_analyzer/__init__.py
from .analyzer import MyAnalyzer
__all__ = ["MyAnalyzer"]

Each unit your parser returns must be an AnalysisUnit.model_dump()-shaped dict — this model is analyzer-agnostic (it’s the “thing an analyzer produced and an LLM analyses one of”: a Terraform resource block, an Ansible task, a Packer builder block, …):

{
"hash": generate_resource_hash(body), # from opentremor_core.libs.hash
"type": item["type"],
"name": item["name"],
"action": item["action"],
"body": body,
"metadata": {}, # UnitMetadata — generated_at_utc, source_path, group_path, analyzer
}

Declare the entry point in your package’s own pyproject.toml — no code in OpenTremor Core changes:

pyproject.toml
[tool.poetry.dependencies]
opentremor-core = "^1.0" # or a path/git dependency during development
[tool.poetry.plugins."opentremor_core.analyzers"]
my-analyzer = "my_analyzer:MyAnalyzer"

At startup, core discovers every installed package declaring this entry-point group and registers each one automatically (controllers/app.py::_discover_analyzers()). Installing your package is the entire integration step.

Every analyzer is enabled for every org by default once registered — an org-admin can disable one via POST /orgs/{org_id}/analyzers/{name} with {"enabled": false}.


Terminal window
pip install -e ./my_analyzer_package # or poetry add, if depending on it from core's own pyproject
curl http://localhost:8000/analyzers
# → {"analyzers": [..., {"name": "my-analyzer", "description": "...", "version": "1.0.0", "file_globs": [...]}]}
curl http://localhost:8000/my-analyzer/rules
# → Markdown ruleset (with any enabled org custom rules for this analyzer, grouped by category, appended)
curl -X POST http://localhost:8000/my-analyzer/my-namespace/ingest \
-H "Content-Type: text/plain" \
--data-binary @my_input.txt
# → [{"hash": "…", "type": "…", …}]

All of the above require an authenticated principal (API key or session) unless auth is disabled — see API overview for the auth model.

Once FILE_GLOBS/sniff() are set, your analyzer is also reachable without naming it — POST /{namespace}/ingest/auto and POST /{namespace}/analyze/auto (see API endpoints) resolve which analyzer(s) apply from the input itself, the same mechanism the GitHub webhook uses for PR diffs:

Terminal window
curl -X POST http://localhost:8000/my-namespace/ingest/auto \
-H "Content-Type: text/plain" \
--data-binary @my_input.txt
# → {"units": [...], "analyzers_used": ["my-analyzer"], "detections": [...]}

  • name is URL-safe (lowercase, hyphens only)
  • package is self-contained — no files shared with other analyzer packages
  • ingest() always returns a list (empty list is valid)
  • every unit dict contains a hash key
  • hash is computed with generate_resource_hash() on normalised content
  • rule files are in the package’s own rules/ following the {type}-rules.md convention
  • manifest.py sets VERSION and FILE_GLOBS
  • FILE_GLOBS set (diff-shaped input) or sniff() overridden (non-diff single-blob input) so auto-routing can find this analyzer — at least one, unless the analyzer is genuinely only ever invoked by explicit name
  • the opentremor_core.analyzers entry point is declared in pyproject.toml
  • unit tests added
  • a LICENSE is present and the license field in pyproject.toml matches it

OpenTremor is split across two licences. The server — OpenTremor-core, OpenTremor-platform, OpenTremor-dashboard and OpenTremor-task-scheduler — is AGPL-3.0-or-later. Every integration surface, including all four first-party analyzers, is Apache-2.0.

Analyzers can be permissive despite depending on an AGPL core because core carries the OpenTremor Analyzer Plugin Exception (LICENSE-EXCEPTION in the core repo), an additional permission granted under section 7 of the AGPL. It says that a work which uses core only through the analyzer plugin API may be combined with core and distributed under any terms you choose.

The exception names that API surface explicitly, and it is exactly what this page has been describing:

CoveredWhere
BaseAnalyzer, AnalyzerRegistryopentremor_core.controllers.analyzers.base
AnalysisUnit, UnitMetadataopentremor_core.models.resource
generate_resource_hashopentremor_core.libs.hash
the opentremor_core.analyzers entry-point groupyour pyproject.toml

If you contribute an analyzer back for inclusion alongside the first-party ones, match their licence: license = "Apache-2.0" in pyproject.toml, with a matching LICENSE and NOTICE copied from any existing analyzer.


Why build one at all? (the blackhole fallback)

Section titled “Why build one at all? (the blackhole fallback)”