mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-09-13 14:49:58 -04:00
340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""
|
|
Repo-wide convention checks for `server/plugins/*/config.json`.
|
|
|
|
These enforce the "Conventions Checklist" in docs/PLUGINS_DEV.md so a plugin
|
|
PR fails CI instead of relying on a reviewer noticing by hand.
|
|
|
|
pytest test/plugins/test_plugin_conventions.py -v
|
|
"""
|
|
|
|
import ast
|
|
import glob
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
import pytest
|
|
|
|
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
_PLUGINS_DIR = os.path.join(_ROOT, 'server', 'plugins')
|
|
|
|
# Core/maintenance plugins that intentionally run on a schedule out of the box
|
|
# (see docs/PLUGINS_DEV.md#conventions-checklist) instead of defaulting to
|
|
# "disabled" like every optional/import-style plugin.
|
|
_ALLOWED_NON_DISABLED_RUN_DEFAULTS = {
|
|
"csv_backup": "schedule",
|
|
"db_cleanup": "schedule",
|
|
"maintenance": "schedule",
|
|
"vendor_update": "schedule",
|
|
"sync": "unused",
|
|
}
|
|
|
|
# Settings UI real-estate: descriptions render directly in Settings, not a
|
|
# README. Cap chosen well above every current plugin's length (longest is
|
|
# ~135 chars) so it only catches a genuine outlier, not routine phrasing.
|
|
_MAX_DESCRIPTION_LENGTH = 200
|
|
|
|
|
|
def _discover_plugin_dirs():
|
|
"""Every plugin folder with a config.json, skipping __-prefixed folders
|
|
and any folder carrying an `ignore_plugin` marker (same rules the app's
|
|
own loader uses - see server/utils/plugin_utils.py:get_plugins_configs)."""
|
|
names = []
|
|
for entry in sorted(os.listdir(_PLUGINS_DIR)):
|
|
plugin_dir = os.path.join(_PLUGINS_DIR, entry)
|
|
if not os.path.isdir(plugin_dir) or entry.startswith('__'):
|
|
continue
|
|
if os.path.isfile(os.path.join(plugin_dir, 'ignore_plugin')):
|
|
continue
|
|
if os.path.isfile(os.path.join(plugin_dir, 'config.json')):
|
|
names.append(entry)
|
|
return names
|
|
|
|
|
|
_PLUGIN_NAMES = _discover_plugin_dirs()
|
|
|
|
|
|
def _load_config(plugin_name):
|
|
path = os.path.join(_PLUGINS_DIR, plugin_name, 'config.json')
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _plugin_py_files(plugin_name):
|
|
return glob.glob(os.path.join(_PLUGINS_DIR, plugin_name, '*.py'))
|
|
|
|
|
|
@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES)
|
|
def test_config_json_is_valid_json(plugin_name):
|
|
path = os.path.join(_PLUGINS_DIR, plugin_name, 'config.json')
|
|
with open(path) as f:
|
|
content = f.read()
|
|
try:
|
|
json.loads(content)
|
|
except json.JSONDecodeError as e:
|
|
pytest.fail(f'{plugin_name}/config.json is not valid JSON: {e}')
|
|
|
|
|
|
@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES)
|
|
def test_run_defaults_to_disabled(plugin_name):
|
|
config = _load_config(plugin_name)
|
|
run_setting = next(
|
|
(s for s in config.get('settings', []) if s.get('function') == 'RUN'),
|
|
None,
|
|
)
|
|
if run_setting is None:
|
|
return # no RUN setting (e.g. a config-only plugin) - nothing to check
|
|
|
|
default = run_setting.get('default_value')
|
|
allowed = _ALLOWED_NON_DISABLED_RUN_DEFAULTS.get(plugin_name)
|
|
if allowed is not None:
|
|
assert default == allowed, (
|
|
f'{plugin_name}: expected the allow-listed RUN default {allowed!r}, got {default!r}. '
|
|
'If this plugin no longer needs an exception, remove it from '
|
|
'_ALLOWED_NON_DISABLED_RUN_DEFAULTS.'
|
|
)
|
|
else:
|
|
assert default == 'disabled', (
|
|
f'{plugin_name}: RUN defaults to {default!r}, expected "disabled". '
|
|
'Non-core plugins must load disabled until the user configures them - '
|
|
'see docs/PLUGINS_DEV.md#conventions-checklist. If this is a core/maintenance '
|
|
'plugin that legitimately needs to run out of the box, add it to '
|
|
'_ALLOWED_NON_DISABLED_RUN_DEFAULTS in this test.'
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES)
|
|
def test_description_is_concise(plugin_name):
|
|
config = _load_config(plugin_name)
|
|
for desc in config.get('description', []):
|
|
if desc.get('language_code') != 'en_us':
|
|
continue
|
|
text = desc.get('string', '')
|
|
assert len(text) <= _MAX_DESCRIPTION_LENGTH, (
|
|
f'{plugin_name}: description is {len(text)} chars (max {_MAX_DESCRIPTION_LENGTH}). '
|
|
'This renders directly in the Settings UI - keep it short and move '
|
|
'implementation rationale to the README instead. '
|
|
'See docs/PLUGINS_DEV.md#conventions-checklist.'
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hardcoded fallback vs. config.json default_value drift
|
|
#
|
|
# Deliberately narrow: only matches the exact `get_setting_value("X") or
|
|
# <literal>` shape every real drift found in this repo actually used. A
|
|
# fallback expressed any other way (a named constant, a function call, ...)
|
|
# is silently skipped rather than flagged - false negatives are fine here,
|
|
# false positives aren't.
|
|
# ---------------------------------------------------------------------------
|
|
_HARDCODED_DEFAULT_RE = re.compile(
|
|
r'''get_setting_value\(\s*["']([A-Za-z0-9_]+)["']\s*\)\s*or\s+
|
|
(?P<literal>
|
|
"[^"\\]*"
|
|
| '[^'\\]*'
|
|
| -?\d+(?:\.\d+)?
|
|
| True|False
|
|
| \[\]
|
|
)''',
|
|
re.VERBOSE,
|
|
)
|
|
|
|
|
|
def _normalize_default_value(value):
|
|
if isinstance(value, bool):
|
|
return str(value).lower()
|
|
if isinstance(value, (int, float)):
|
|
return str(value)
|
|
if isinstance(value, list):
|
|
return '[]' if not value else repr(value)
|
|
if isinstance(value, str):
|
|
stripped = value.strip()
|
|
return stripped.lower() if stripped.lower() in ('true', 'false') else stripped
|
|
return str(value)
|
|
|
|
|
|
def _normalize_code_literal(text):
|
|
text = text.strip()
|
|
if text in ('True', 'False'):
|
|
return text.lower()
|
|
if text == '[]':
|
|
return '[]'
|
|
if len(text) >= 2 and text[0] == text[-1] and text[0] in ('"', "'"):
|
|
return text[1:-1]
|
|
return text # bare number, left as-is
|
|
|
|
|
|
def _setting_defaults(config):
|
|
prefix = config.get('unique_prefix', '')
|
|
return {
|
|
f"{prefix}_{s['function']}": s.get('default_value')
|
|
for s in config.get('settings', [])
|
|
if s.get('function')
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES)
|
|
def test_hardcoded_default_matches_config(plugin_name):
|
|
defaults = _setting_defaults(_load_config(plugin_name))
|
|
if not defaults:
|
|
return
|
|
|
|
mismatches = []
|
|
for py_file in _plugin_py_files(plugin_name):
|
|
with open(py_file) as f:
|
|
source = f.read()
|
|
for match in _HARDCODED_DEFAULT_RE.finditer(source):
|
|
setting_key = match.group(1)
|
|
if setting_key not in defaults:
|
|
continue # not one of this plugin's own settings (e.g. a core setting)
|
|
literal_text = match.group('literal')
|
|
code_value = _normalize_code_literal(literal_text)
|
|
config_value = _normalize_default_value(defaults[setting_key])
|
|
if code_value != config_value:
|
|
mismatches.append(
|
|
f"{os.path.basename(py_file)}: get_setting_value('{setting_key}') or {literal_text} "
|
|
f"(-> {code_value!r}) does not match config.json's default_value {config_value!r}"
|
|
)
|
|
|
|
assert not mismatches, (
|
|
f"{plugin_name}: hardcoded fallback(s) drifted from config.json's declared default - "
|
|
"a missing/empty setting should fall back to the documented default, not a stale one:\n"
|
|
+ "\n".join(mismatches)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RUN_TIMEOUT reused as a per-call timeout inside a loop
|
|
#
|
|
# RUN_TIMEOUT is enforced by the core plugin runner (server/plugin.py) as
|
|
# the whole script's kill-timeout, not a safe per-call budget - see
|
|
# docs/PLUGINS_DEV.md#conventions-checklist. Correct patterns, either of
|
|
# which exempts a plugin from this check:
|
|
# - config.json's "timeoutMultiplier": true on a params[] entry, for a
|
|
# config-declared, known-length loop (see arp_scan).
|
|
# - plugin_helper.per_item_timeout(), for a runtime-variable-length loop
|
|
# (see the publisher plugins).
|
|
# ---------------------------------------------------------------------------
|
|
def _has_timeout_multiplier(config):
|
|
return any(p.get('timeoutMultiplier') for p in config.get('params', []))
|
|
|
|
|
|
def _resolve_int_literal(node, tree):
|
|
"""Best-effort: resolve `node` to a literal int, either directly or via
|
|
a same-module variable assigned a literal int elsewhere. Returns None if
|
|
it can't be resolved (treated as "could be anything" - i.e. risky)."""
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, int):
|
|
return node.value
|
|
if isinstance(node, ast.Name):
|
|
for n in ast.walk(tree):
|
|
if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == node.id for t in n.targets):
|
|
if isinstance(n.value, ast.Constant) and isinstance(n.value.value, int):
|
|
return n.value.value
|
|
return None
|
|
|
|
|
|
def _loop_always_runs_at_most_once(loop_node, tree):
|
|
"""`for _ in range(1):` (or a variable statically known to be 1) can
|
|
never exceed a single-call budget, unlike `for x in <a real collection>:`
|
|
- not the bug shape this check targets."""
|
|
if not isinstance(loop_node, ast.For):
|
|
return False
|
|
it = loop_node.iter
|
|
if isinstance(it, ast.Call) and isinstance(it.func, ast.Name) and it.func.id == 'range' and len(it.args) == 1:
|
|
count = _resolve_int_literal(it.args[0], tree)
|
|
return count is not None and count <= 1
|
|
return False
|
|
|
|
|
|
def _collect_run_timeout_vars(tree, source):
|
|
run_timeout_vars = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Assign):
|
|
value_src = ast.get_source_segment(source, node.value) or ''
|
|
if 'get_setting_value' in value_src and 'RUN_TIMEOUT' in value_src:
|
|
for target in node.targets:
|
|
if isinstance(target, ast.Name):
|
|
run_timeout_vars.add(target.id)
|
|
return run_timeout_vars
|
|
|
|
|
|
def _call_reuses_run_timeout(call_node, source, run_timeout_vars):
|
|
for kw in call_node.keywords:
|
|
if kw.arg != 'timeout':
|
|
continue
|
|
kw_src = ast.get_source_segment(source, kw.value) or ''
|
|
references_var = any(
|
|
isinstance(n, ast.Name) and n.id in run_timeout_vars
|
|
for n in ast.walk(kw.value)
|
|
)
|
|
if references_var or 'RUN_TIMEOUT' in kw_src:
|
|
return kw_src
|
|
return None
|
|
|
|
|
|
def _run_timeout_loop_issues(py_file):
|
|
with open(py_file) as f:
|
|
source = f.read()
|
|
|
|
if 'per_item_timeout(' in source:
|
|
return []
|
|
|
|
try:
|
|
tree = ast.parse(source, filename=py_file)
|
|
except SyntaxError:
|
|
return [] # a real syntax error is caught elsewhere (py_compile in CI)
|
|
|
|
run_timeout_vars = _collect_run_timeout_vars(tree, source)
|
|
|
|
# Functions whose OWN body (anywhere inside it) makes a risky timeout=
|
|
# call - a loop calling one of these by name is just as exposed as a
|
|
# loop making the risky call directly (this is the actual shape of the
|
|
# nmap_dev_scan bug: the loop calls a per-interface helper, and the
|
|
# helper - not the loop itself - is the one passing timeout=).
|
|
risky_functions = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.FunctionDef):
|
|
for call_node in ast.walk(node):
|
|
if isinstance(call_node, ast.Call) and _call_reuses_run_timeout(call_node, source, run_timeout_vars):
|
|
risky_functions.add(node.name)
|
|
break
|
|
|
|
issues = []
|
|
for loop_node in ast.walk(tree):
|
|
if not isinstance(loop_node, (ast.For, ast.While)):
|
|
continue
|
|
if _loop_always_runs_at_most_once(loop_node, tree):
|
|
continue
|
|
for call_node in ast.walk(loop_node):
|
|
if not isinstance(call_node, ast.Call):
|
|
continue
|
|
kw_src = _call_reuses_run_timeout(call_node, source, run_timeout_vars)
|
|
if kw_src:
|
|
issues.append(f"{os.path.basename(py_file)}:{call_node.lineno}: timeout={kw_src}")
|
|
elif isinstance(call_node.func, ast.Name) and call_node.func.id in risky_functions:
|
|
issues.append(
|
|
f"{os.path.basename(py_file)}:{call_node.lineno}: "
|
|
f"calls {call_node.func.id}(), which reuses RUN_TIMEOUT as a per-call timeout"
|
|
)
|
|
|
|
return issues
|
|
|
|
|
|
@pytest.mark.parametrize('plugin_name', _PLUGIN_NAMES)
|
|
def test_run_timeout_not_reused_in_loop(plugin_name):
|
|
config = _load_config(plugin_name)
|
|
if _has_timeout_multiplier(config):
|
|
return
|
|
|
|
issues = []
|
|
for py_file in _plugin_py_files(plugin_name):
|
|
issues.extend(_run_timeout_loop_issues(py_file))
|
|
|
|
assert not issues, (
|
|
f"{plugin_name}: RUN_TIMEOUT appears reused as a per-call timeout inside a loop. "
|
|
"RUN_TIMEOUT is the whole script's kill-timeout, not a safe per-call budget - use "
|
|
'config.json\'s "timeoutMultiplier" for a config-declared, known-length loop, or '
|
|
'plugin_helper.per_item_timeout() for a runtime-variable-length one. '
|
|
"See docs/PLUGINS_DEV.md#conventions-checklist:\n" + "\n".join(issues)
|
|
)
|