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

Why build one at all? (the blackhole fallback)

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