Writing Scan Plugins¶
Scan plugins let you add custom security checks to NetSentinel without modifying core code. They receive the device list from a network scan and return findings.
This document covers scan plugins — Python scripts in the
plugins/directory that analyse the discovered device list. For integrating hardware (routers, modems, access points), see Hardware Integrations instead. NetSentinel has two distinct plugin systems; they are not interchangeable.
How scan plugins work¶
Drop a .py file into the plugins/ directory next to the executable, or into ~/.config/NetSentinel/plugins/. NetSentinel discovers and loads it automatically on the next scan. Plugins appear in the Security Audit section of the nav.
Scan plugins are executed in a sandboxed subprocess and cannot crash the main app, but they do have access to the full scan result.
Required interface¶
Every plugin must expose exactly these two names at module level:
PLUGIN_META = {
"name": "My Plugin", # Display name in the UI
"version": "1.0.0", # Semver string
"description": "What this checks", # One sentence, shown on the plugin card
"author": "Your Name",
"tags": ["routing"], # Optional — used for filtering in the plugin list
}
def run(devices: list, **kwargs) -> PluginResult:
...
run() parameters¶
| Parameter | Type | Description |
|---|---|---|
devices |
list |
DeviceInfo-like objects from the most recent Module 1 scan |
**kwargs |
dict |
Reserved for future use — ignore or forward |
Each device object has these attributes:
| Attribute | Type | Notes |
|---|---|---|
ip |
str |
Current IPv4 address |
mac |
str |
MAC address (lowercase, colon-separated) |
vendor |
str |
OUI vendor name from the MAC registry |
hostname |
str |
Best resolved hostname (mDNS > NetBIOS > rDNS) |
device_type |
str |
Classified type: router, printer, phone, iot, etc. |
os_family |
str |
OS family if fingerprinted: Windows, Linux, macOS, etc. |
risk_level |
str |
Current risk: HIGH, STORM, MEDIUM, WARNING, LOW, CLEAN, UNKNOWN |
verdict |
str |
Human-readable one-line verdict |
remediation |
str |
Suggested remediation action |
Use getattr(d, "attribute", default) rather than direct attribute access — future scan methods may not populate every field.
Return value — PluginResult¶
Import from modules.plugin_system:
| Field | Type | Default | Notes |
|---|---|---|---|
plugin_name |
str |
required | Set to PLUGIN_META["name"] |
findings |
list[str] |
[] |
Each string is one finding shown in the report |
risk_level |
str |
"LOW" |
LOW / MEDIUM / HIGH / CRITICAL |
raw_data |
dict |
{} |
Arbitrary data for programmatic consumption via REST API |
error |
str |
"" |
Set if the plugin failed; suppresses findings in the UI |
risk_level on PluginResult is the overall severity of the plugin's findings — not the risk level of any individual device. Set it to the worst finding in the list.
Minimal working example¶
from modules.plugin_system import PluginResult
PLUGIN_META = {
"name": "Default Gateway Check",
"version": "1.0.0",
"description": "Flags any device that shares the gateway IP with an unexpected vendor.",
"author": "You",
"tags": ["routing"],
}
TRUSTED_GATEWAY_VENDORS = {"TP-Link", "ASUS", "Netgear", "Ubiquiti", "MikroTik"}
def run(devices: list, **kwargs) -> PluginResult:
findings = []
for d in devices:
ip = getattr(d, "ip", "")
vendor = getattr(d, "vendor", "")
if ip.endswith(".1") and vendor and vendor not in TRUSTED_GATEWAY_VENDORS:
findings.append(
f"{ip} ({vendor}) — gateway IP held by an unexpected vendor"
)
return PluginResult(
plugin_name=PLUGIN_META["name"],
findings=findings,
risk_level="LOW" if not findings else "MEDIUM",
)
Error handling¶
Always return a PluginResult — never raise an unhandled exception:
def run(devices: list, **kwargs) -> PluginResult:
try:
findings = _do_checks(devices)
return PluginResult(plugin_name=PLUGIN_META["name"], findings=findings)
except Exception as exc:
return PluginResult(
plugin_name=PLUGIN_META["name"],
error=f"Plugin failed: {exc}",
)
Debugging locally¶
# Run the validator (no code executed)
python -m modules.plugin_tools validate my_plugin.py
# Test against a saved scan result (JSON)
python -c "
import json, importlib.util, pathlib
spec = importlib.util.spec_from_file_location('p', 'my_plugin.py')
p = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p)
# Load a scan result exported from the app (File → Export → JSON)
devices = json.loads(pathlib.Path('scan_result.json').read_text())
result = p.run(devices)
print(result)
"
Constraints¶
- No outbound network connections to addresses outside the local subnet without explicit user action. Plugins that call external APIs will fail the validator.
- No disk writes except under a path the user has configured. Use
raw_datainPluginResultto pass data back to the caller. - No new dependencies beyond stdlib and
requirements.txt. If your plugin needs a new package, declare it in your PR and add it torequirements.txt. run()must return aPluginResultin all code paths — including exception handlers.- Do not import
PyQt6or any UI library. Plugins run outside the main process.
Submitting a scan plugin¶
Open a pull request with your .py file in the plugins/ directory. The PR description should include:
- What security check the plugin performs and why it matters
- Which device types or conditions trigger a finding
- Example output from a real scan (anonymise IPs if needed)
- Any new pip dependencies and why they are necessary
See the PR checklist for the full merge requirements.