Adding a New Analyzer
1. Create the package
Section titled “1. Create the package”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.mdBaseAnalyzer lives in OpenTremor Core (opentremor_core.controllers.analyzers.base) — your package depends on opentremor-core, not the other way around.
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"]from __future__ import annotationsfrom pathlib import Pathfrom typing import Any, Optional
from opentremor_core.controllers.analyzers.base import BaseAnalyzerfrom .manifest import FILE_GLOBS, VERSIONfrom .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()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}2. Register the analyzer via entry points
Section titled “2. Register the analyzer via entry points”Declare the entry point in your package’s own pyproject.toml — no code in OpenTremor Core changes:
[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}.
3. Verify
Section titled “3. Verify”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:
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": [...]}Checklist
Section titled “Checklist”-
nameis 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
hashkey -
hashis computed withgenerate_resource_hash()on normalised content - rule files are in the package’s own
rules/following the{type}-rules.mdconvention -
manifest.pysetsVERSIONandFILE_GLOBS -
FILE_GLOBSset (diff-shaped input) orsniff()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.analyzersentry point is declared inpyproject.toml - unit tests added