Documentation
Guides
Custom Check Plugins

Custom Check Plugins

NAT's built-in checks cover the OWASP API Security Top 10, but every API has unique security requirements. The plugin system lets you write your own checks in Python and register them alongside the built-in set β€” no forking, no monkey-patching.

How plugins are loaded

NAT supports four mechanisms for supplying custom checks:

MechanismBest for
--plugins-dir <path>Ad-hoc checks during local development
--plugin <module:Class>Loading a specific check from a Python module
Entry points (nat.checks)Distributing checks as reusable Python packages
Python API extra_checks=Programmatic use from your own scripts

All four mechanisms are additive β€” NAT runs built-in checks plus any custom ones you provide.

Writing a custom check

Every custom check is a Python class that subclasses SecurityCheck and implements a single run() method.

Required attributes

AttributeTypeDescription
owasp_idstrOWASP API Top 10 ID this check relates to (e.g. "API4:2023")
titlestrShort human-readable name shown in reports
descriptionstrOne-sentence description of what the check tests

severity_default is optional but recommended ("critical", "high", "medium", "low", or "info").

The run() method

async def run(
    self, endpoint: EndpointInfo, client: httpx.AsyncClient
) -> list[SecurityFinding]:
    ...
  • endpoint β€” metadata about the endpoint under test (URL, method, parameters, spec data)
  • client β€” a pre-configured httpx.AsyncClient with auth headers and rate-limiting already applied
  • Return β€” a list of SecurityFinding objects (empty list = no issue found)

Example: rate-limit checker

# my_checks/rate_limit_check.py
import httpx
from mannf.product.security.checks.base import SecurityCheck, SecurityFinding, EndpointInfo
 
 
class StrictRateLimitCheck(SecurityCheck):
    owasp_id = "API4:2023"
    title = "Strict Rate Limit Enforcement"
    description = (
        "Verifies that endpoints reject requests after a low threshold "
        "to prevent resource exhaustion attacks."
    )
    severity_default = "medium"
 
    # How many requests to fire before expecting a 429
    BURST_SIZE = 10
 
    async def run(
        self, endpoint: EndpointInfo, client: httpx.AsyncClient
    ) -> list[SecurityFinding]:
        responses = []
        for _ in range(self.BURST_SIZE):
            resp = await client.request(
                endpoint.method,
                endpoint.url,
                timeout=5.0,
            )
            responses.append(resp.status_code)
 
        if 429 not in responses:
            return [
                self._make_finding(
                    endpoint=endpoint,
                    title=self.title,
                    detail=(
                        f"Endpoint did not return HTTP 429 after {self.BURST_SIZE} "
                        "consecutive requests. Verify that rate limiting is enforced."
                    ),
                    severity=self.severity_default,
                )
            ]
        return []

Use self._make_finding(...) rather than constructing SecurityFinding directly β€” it populates required metadata fields (scan ID, check ID, OWASP ID) automatically.

_make_finding() parameters

ParameterRequiredDescription
endpointβœ…The EndpointInfo passed to run()
titleβœ…Finding title (usually self.title)
detailβœ…Explanation of the specific issue found
severityβœ…"critical", "high", "medium", "low", or "info"
evidenceβ€”Raw request/response evidence (dict)
remediationβ€”Suggested fix text
referencesβ€”List of reference URLs

CLI usage

Load all checks from a directory

nat scan --url https://api.example.com \
  --spec openapi.yaml \
  --plugins-dir ./my_checks/

NAT recursively scans the directory for Python files and imports any class that subclasses SecurityCheck.

Load a specific check by module path

nat scan --url https://api.example.com \
  --spec openapi.yaml \
  --plugin my_checks.rate_limit_check:StrictRateLimitCheck

The module:Class syntax follows the same convention as Python entry points. The module must be importable from the current working directory or your active virtual environment.

Combine with built-in flags

Custom plugins work alongside all other scan flags:

nat scan --url https://api.example.com \
  --spec openapi.yaml \
  --plugins-dir ./my_checks/ \
  --profile owasp-api-top10 \
  --disable-check rate-limit-missing \
  --output report.html

Packaging plugins as a Python package

For team-wide or organisation-wide distribution, publish your checks as a normal Python package with an entry point in the nat.checks group. NAT discovers these automatically at startup β€” no CLI flags needed.

pyproject.toml example

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
 
[project]
name = "acme-nat-checks"
version = "0.1.0"
dependencies = ["nat-engine>=1.3.0"]
 
[project.entry-points."nat.checks"]
strict-rate-limit = "acme_nat_checks.rate_limit:StrictRateLimitCheck"
jwt-alg-none = "acme_nat_checks.jwt:JwtAlgNoneCheck"

After installing the package (pip install acme-nat-checks) the registered checks appear automatically in nat checks list and run as part of every scan.

Python API

When invoking NAT programmatically you can pass check classes directly:

import asyncio
from mannf.product.security.scanner import NATScanner
from my_checks.rate_limit_check import StrictRateLimitCheck
 
scanner = NATScanner(
    url="https://api.example.com",
    spec="openapi.yaml",
    extra_checks=[StrictRateLimitCheck],
)
 
results = asyncio.run(scanner.scan())

Plugin Loader API reference

These functions are exposed in mannf.product.security.plugin_loader for advanced use.

load_plugins_from_directory(path)

from mannf.product.security.plugin_loader import load_plugins_from_directory
 
checks = load_plugins_from_directory("./my_checks/")

Recursively imports all Python files in path and returns a list of SecurityCheck subclasses found. Raises PluginLoadError if a module cannot be imported.

load_plugins_from_entry_points()

from mannf.product.security.plugin_loader import load_plugins_from_entry_points
 
checks = load_plugins_from_entry_points()

Discovers and returns all checks registered under the nat.checks entry point group in the current Python environment.

load_all_plugins(plugins_dir=None, extra_modules=None)

from mannf.product.security.plugin_loader import load_all_plugins
 
checks = load_all_plugins(
    plugins_dir="./my_checks/",
    extra_modules=["acme_nat_checks.jwt:JwtAlgNoneCheck"],
)

Convenience wrapper that calls both loaders and deduplicates results. This is what the CLI uses internally.

Testing custom plugins

Test your check class directly with pytest β€” no running NAT instance required.

# tests/test_rate_limit_check.py
import pytest
import respx
import httpx
from my_checks.rate_limit_check import StrictRateLimitCheck
from mannf.product.security.checks.base import EndpointInfo
 
 
@pytest.fixture
def endpoint():
    return EndpointInfo(
        url="https://api.example.com/items",
        method="GET",
        parameters=[],
    )
 
 
@respx.mock
@pytest.mark.asyncio
async def test_detects_missing_rate_limit(endpoint):
    # All responses are 200 β€” no rate limiting
    respx.get("https://api.example.com/items").mock(return_value=httpx.Response(200))
 
    check = StrictRateLimitCheck()
    async with httpx.AsyncClient() as client:
        findings = await check.run(endpoint, client)
 
    assert len(findings) == 1
    assert findings[0].severity == "medium"
 
 
@respx.mock
@pytest.mark.asyncio
async def test_passes_when_rate_limited(endpoint):
    # First 9 return 200, 10th returns 429
    respx.get("https://api.example.com/items").side_effect = [
        httpx.Response(200)] * 9 + [httpx.Response(429)]
 
    check = StrictRateLimitCheck()
    async with httpx.AsyncClient() as client:
        findings = await check.run(endpoint, client)
 
    assert findings == []

Use respx (opens in a new tab) to mock HTTP responses in async tests β€” it integrates cleanly with httpx.AsyncClient.

Listing plugins

After loading your plugins, confirm they are registered:

nat checks list

Custom checks appear in the list with a [plugin] tag next to their ID.

Licensing

NAT is released under AGPL-3.0. Plugins that are loaded at runtime and distributed publicly are subject to the same licence terms. If you need to keep your custom checks proprietary, a commercial licence is available β€” contact us for details.

Next steps

Was this helpful?