reNgine v3 introduces a modular plugin architecture that lets developers extend the platform's capabilities without modifying the core codebase. Plugins can:
- Add new Django models and REST API endpoints
- Register Temporal workflows and activities for scan pipeline integration
- Inject tasks into the scan pipeline at any tier (
tier_1throughtier_7, orstandalone) - Serve a React UI via Vite Module Federation (loaded dynamically by
PluginPageLoader) - Install external security tools at container startup via
tools.yaml
A reNgine plugin is a directory (packaged as a custom signed ZIP [.r3n] for distribution) with the following structure:
my-plugin/
├── manifest.yaml # Required — identity, pipeline hooks, UI config
├── tools.yaml # Optional — binary tool dependencies
├── my_engine.yaml # Optional — Django fixture for engine templates
├── backend/ # Optional — Django app (models, API, Temporal workflows)
│ ├── __init__.py
│ ├── models.py
│ ├── api.py
│ ├── api_urls.py # Registers routes at /api/plugins/{slug}/
│ ├── serializers.py
│ ├── migrations/
│ └── temporal_exports.py # Temporal workflow + activity definitions
└── ui/ # Optional — frontend UI source (Vite lib build)
├── package.json
├── vite.config.ts
├── tsconfig.json
└── src/
├── index.ts # Barrel — named exports of all page components
├── api/ # TanStack Query hooks
├── store/ # Zustand state
├── hooks/ # Custom React hooks (WebSocket, etc.)
├── components/ # Shared UI components
└── pages/ # Full page components
manifest.yaml is the source of truth for your plugin.
name: "My Plugin"
version: "1.0.0"
author: "Your Name"
icon: "icon.png"
description: "What this plugin does."
runtime:
run_after: "VulnerabilityScan" # Core scan step to run after options are run_before, run_after, standalone
temporal:
workflows:
- "backend.temporal_exports.my_workflow"
activities:
- "backend.temporal_exports.my_activity"
- "backend.temporal_exports.another_activity"
ui:
menu_item: "My Plugin" # Label in the "Plugins" nav group
menu_path: "/my-plugin" # Sub-path under /{projectSlug}/
entry_export: "MyPluginExport"SubdomainDiscovery | PortScan | FetchURL | VulnerabilityScan | Reporting
A plugin backend is a standard Django app installed into plugins_data/{slug}/backend/ at install time. The dynamic URL loader in api/urls.py auto-discovers backend/api_urls.py and mounts it at /api/plugins/{slug}/.
# backend/api_urls.py
from django.urls import path
from rest_framework import routers
from .api import MyViewSet
router = routers.DefaultRouter()
router.register(r'items', MyViewSet, basename='items')
urlpatterns = router.urlsDefine activities in backend/temporal_exports.py and list them in manifest.yaml temporal.activities. The Temporal orchestrator discovers and registers them on startup.
# backend/temporal_exports.py
from temporalio import activity
@activity.defn(name="my_plugin_activity")
async def my_activity(params: dict) -> dict:
...
return {"status": "done"}There are two ways to add UI from a plugin:
| Pattern | Use case | Example |
|---|---|---|
| Component override | Replace an existing core component | custom_vuln_badge overrides VulnerabilityBadge |
| New pages | Add entirely new pages with nav link | erl_temporal adds Exploit Readiness Dashboard pages |
Both patterns use the same Vite lib build. The difference is how the host integrates the output.
Use this when you want to replace an existing component in the core UI.
ui:
overrides:
- name: "VulnerabilityTable"
file: "VulnerabilityTable.js"import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: 'src/VulnerabilityTable.tsx',
name: 'VulnerabilityTable',
fileName: 'VulnerabilityTable',
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react-dom', '@mui/material', 'lucide-react'],
},
outDir: 'dist',
},
});The built file (dist/VulnerabilityTable.js) must have a default export — the host's PluginComponentLoader uses module.default.
Use this when your plugin adds entirely new pages or complex applications that need their own routes. This pattern leverages Vite Module Federation to dynamically load your app's mount point safely inside the host shell.
Use @originjs/vite-plugin-federation to expose a single mount module. Avoid sharing dependencies (shared: []) to ensure your plugin remains completely decoupled from host versions and avoids dependency clashes.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'my_plugin',
filename: 'remoteEntry.js',
exposes: {
'./mount': './src/mount.tsx',
},
shared: []
})
],
build: {
target: 'esnext',
minify: false,
cssCodeSplit: false,
outDir: 'dist',
emptyOutDir: true
}
});The host system doesn't render your React tree directly. Instead, it creates an empty div and calls your mount function, passing it the DOM element and any host context (like the active projectSlug).
// src/mount.tsx
import { createRoot, Root } from 'react-dom/client';
import App from './App';
let root: Root | null = null;
export const mount = (el: HTMLElement, props: any) => {
root = createRoot(el);
root.render(<App {...props} />);
};
export const unmount = (_el: HTMLElement) => {
if (root) {
root.unmount();
root = null;
}
};Now you can build your plugin as a completely standard React application!
// src/App.tsx
import React from 'react';
export default function App({ projectSlug }: { projectSlug: string }) {
return (
<div>
<h1>My Plugin Dashboard</h1>
<p>Active project: {projectSlug}</p>
</div>
);
}ui:
menu_item: "My Plugin" # Nav label shown under "Plugins"
menu_path: "/my_plugin" # Mapped to the dynamic route /{projectSlug}/plugins/my_pluginWhen the plugin is enabled, the core router automatically detects it and renders the Module Federation remote loader.
Source: r3ngine-plugins/{slug}/ui/src/
↓
Build: npm run build (or build_plugins.py)
↓
Output: r3ngine-plugins/{slug}/ui/dist/assets/remoteEntry.js
↓
Package: build_plugins.py → dist/{slug}.zip
↓
Install: AtomicInstaller → plugins_data/{slug}/ + MEDIA_ROOT/plugins/{slug}/ui/
↓
Served: /media/plugins/{slug}/ui/assets/remoteEntry.js
# Build and package a single plugin
cd r3ngine-plugins
python build_plugins.py active_directory
# Build all plugins
python build_plugins.pycd r3ngine-plugins/active_directory/ui
npm install
npm run buildThe active_directory plugin is the reference implementation of the new-pages pattern.
Backend: Django app with models (ADAssessment, ADFinding, ADTrust, ADExposure), REST API at /api/plugins/active_directory/, Temporal workflow with 8 activities, Neo4j graph manager.
Frontend pages (exported from ui/src/index.ts):
| Export name | Route | Description |
|---|---|---|
ADAssessmentsPage |
/{slug}/active-directory |
Assessment list with create/start actions |
ADAssessmentDetailPage |
/{slug}/active-directory/assessment/$id |
Findings, trusts, exposures tabs + ingest |
ADGraphExplorerPage |
/{slug}/active-directory/assessment/$id/graph |
Interactive Cytoscape domain graph |
ADTrustAnalyticsPage |
/{slug}/active-directory/assessment/$id/trusts |
Trust relationship table |
ADExposureDashboardPage |
/{slug}/active-directory/assessment/$id/exposures |
Risk-scored exposure surface |
Key dependencies bundled into dist/index.js:
cytoscape+react-cytoscapejs(graph visualization)zustand(UI state)@tanstack/react-query(data fetching)
Peer dependencies provided by host (NOT bundled):
react,react-dom@mui/material,@mui/icons-materiallucide-react
{
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@mui/material": "^6.0.0",
"@mui/icons-material": "^6.0.0",
"lucide-react": "^0.400.0"
},
"dependencies": {
"@tanstack/react-query": "^5.100.9",
"zustand": "^5.0.0",
"cytoscape": "^3.33.3"
},
"devDependencies": {
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.0.0",
"typescript": "^5.0.0"
}
}peerDependencies→ listed inrollupOptions.external→ provided by host at runtime, not bundleddependencies→ bundled intodist/index.js- Do NOT add
reactor@mui/materialtodependencies— the host provides one instance; bundling another causes React hook errors
- Write source locally — all plugin code lives in
r3ngine-plugins/{slug}/on your host machine - Build the UI —
cd r3ngine-plugins/{slug}/ui && npm run build - Sync to container —
docker cp r3ngine-plugins/{slug} r3ngine-web-1:/usr/src/app/plugins_data/ - Sync UI to media —
docker exec r3ngine-web-1 python manage.py sync_plugin_ui - Test in browser — navigate to
/{projectSlug}/my-plugin - Commit — plugin files to
r3ngine-plugins/repo; host route changes to the mainr3nginerepo
Never commit
web/plugins_data/to any repo — it is runtime install state only.
tools:
- name: "my-tool"
binary: "my-tool"
install_type: "pip3"
install_command: "pip3 install my-tool"
validation_command: "my-tool --version"Tools are installed into plugins_data/{slug}/ on the worker container.
- Check
reNgine.opsec_utilsfor proxy rotation and stealth utilities - Use
_send_ws_update(assessment_id, type, payload)in Temporal activities to push real-time progress via WebSocket - WebSocket endpoint for plugins:
ws[s]://{host}/ws/plugins/{slug}/{assessment_id}/ - All data fetching in plugin UI should use
credentials: 'include'to pass session cookies
Advanced authentication testing and password auditing via brutus, netexec, kerbrute, and hashcat.
SMTP open relay, user enumeration, STARTTLS, and SPF/DKIM/DMARC policy checks. Runs automatically after Tier 2 port scanning.
Standalone Metasploit integration providing a 2-way interactive terminal and automated Temporal-driven template scans.



