diff --git a/PLUGINS.md b/PLUGINS.md index 767e1aec2..389fc5be4 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -312,3 +312,29 @@ python scripts/validate_plugin.py --plugin plugins/nmap The validation checks metadata JSON, required fields, checksums, and custom parser imports when applicable. + +### Metadata quality lint rules + +Two additional lint checks help maintain high-quality plugin metadata: + +1. **Missing field help text** — Each field in the `fields` array should include + a `help` string that provides a brief user-facing description of the input. + Fields without `help` text are flagged with a lint warning. + + ```json + // Good — has help text + { "id": "target", "label": "Target", "type": "text", "help": "IP address or hostname to scan" } + + // Bad — missing help text (lint warning) + { "id": "target", "label": "Target", "type": "text" } + ``` + +2. **Ambiguous category** — Each plugin's `category` must be one of the + recognized categories: `recon`, `vulnerability`, `web`, `exploit`, `network`, + `expert`, `code`, `forensics`, `utils`, `execution`, `security`, `robots`. + Unknown or misspelled categories are rejected. + + ```bash + # Run the linter + python scripts/validate_plugins.py + ``` diff --git a/backend/secuscan/plugin_validator.py b/backend/secuscan/plugin_validator.py index 7c3ac455f..52c2a0c21 100644 --- a/backend/secuscan/plugin_validator.py +++ b/backend/secuscan/plugin_validator.py @@ -22,6 +22,11 @@ VALID_SAFETY_LEVELS = {"safe", "intrusive", "exploit"} VALID_FIELD_TYPES = {"string","integer","text", "number", "boolean", "select", "multiselect", "textarea"} VALID_PARSER_TYPES = {"json", "text", "custom", "none"} +VALID_CATEGORIES = { + "recon", "vulnerability", "web", "exploit", "network", + "expert", "code", "forensics", "utils", "execution", + "security", "robots", +} REQUIRED_TOP_LEVEL_FIELDS = [ "id", @@ -109,6 +114,7 @@ def validate(self) -> ValidationResult: result = ValidationResult(plugin_id=plugin_id, plugin_dir=self.plugin_dir) self._check_required_fields(data, result) + self._check_category(data, result) self._check_engine(data, result) self._check_command_template(data, result) self._check_fields(data, result) @@ -126,6 +132,17 @@ def _check_required_fields(self, data: dict, result: ValidationResult) -> None: if key not in data or data[key] in (None, "", [], {}): result.add(key, f"Required field '{key}' is missing or empty") + def _check_category(self, data: dict, result: ValidationResult) -> None: + cat = data.get("category") + if not cat: + return + if cat not in VALID_CATEGORIES: + result.add( + "category", + f"'{cat}' is not a recognized category — " + f"must be one of: {sorted(VALID_CATEGORIES)}", + ) + def _check_engine(self, data: dict, result: ValidationResult) -> None: engine = data.get("engine") if not isinstance(engine, dict): @@ -214,6 +231,12 @@ def _check_fields(self, data: dict, result: ValidationResult) -> None: f"Field '{fid}' is type '{ftype}' and must have a non-empty 'options' list", ) + if not f.get("help"): + result.add( + f"{prefix}.help", + f"Field '{fid}' is missing 'help' text — add a brief user-facing description", + ) + def _check_output(self, data: dict, result: ValidationResult) -> None: output = data.get("output") if not isinstance(output, dict): diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1c73e91bb..09ab29ec9 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -20,18 +20,13 @@ export default function AppShell({ children }: AppShellProps) { return saved !== null ? JSON.parse(saved) : true }) - // Brief hack to sync sidebar state without a full context provider useEffect(() => { - const handleStorage = () => { - const saved = localStorage.getItem('sidebar-expanded') - if (saved !== null) setSidebarExpanded(JSON.parse(saved)) - } - window.addEventListener('storage', handleStorage) - const interval = setInterval(handleStorage, 100) - return () => { - window.removeEventListener('storage', handleStorage) - clearInterval(interval) + const handleSidebarChange = (e: Event) => { + const detail = (e as CustomEvent).detail + if (typeof detail === 'boolean') setSidebarExpanded(detail) } + window.addEventListener('sidebar-state-changed', handleSidebarChange) + return () => window.removeEventListener('sidebar-state-changed', handleSidebarChange) }, []) useEffect(() => { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b8..fb7b00546 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -108,6 +108,7 @@ export default function Sidebar() { useEffect(() => { localStorage.setItem('sidebar-expanded', JSON.stringify(isExpanded)) + window.dispatchEvent(new CustomEvent('sidebar-state-changed', { detail: isExpanded })) }, [isExpanded]) return ( diff --git a/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json b/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json index be121e37f..5264d21a9 100644 --- a/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json +++ b/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json @@ -21,14 +21,16 @@ "label": "Target Host", "type": "text", "required": true, - "placeholder": "192.168.1.1" + "placeholder": "192.168.1.1", + "help": "IP address or hostname to ping" }, { "id": "count", "label": "Packet Count", "type": "number", "required": false, - "default": 4 + "default": 4, + "help": "Number of ICMP packets to send" } ], "output": { diff --git a/testing/backend/unit/test_plugin_validator.py b/testing/backend/unit/test_plugin_validator.py index f344cd76c..af8508169 100644 --- a/testing/backend/unit/test_plugin_validator.py +++ b/testing/backend/unit/test_plugin_validator.py @@ -26,6 +26,7 @@ VALID_SAFETY_LEVELS, VALID_FIELD_TYPES, VALID_PARSER_TYPES, + VALID_CATEGORIES, ) FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "plugins" @@ -48,7 +49,7 @@ def _error_messages(result: ValidationResult) -> list[str]: def _write_metadata(tmp_path: Path, data: dict) -> Path: plugin_dir = tmp_path / "my_plugin" - plugin_dir.mkdir() + plugin_dir.mkdir(exist_ok=True) (plugin_dir / "metadata.json").write_text(json.dumps(data), encoding="utf-8") return plugin_dir @@ -65,8 +66,8 @@ def _minimal_valid() -> dict: "engine": {"type": "cli", "binary": "ping"}, "command_template": ["ping", "-c", "{count}", "{target}"], "fields": [ - {"id": "target", "label": "Target Host", "type": "text"}, - {"id": "count", "label": "Count", "type": "number"}, + {"id": "target", "label": "Target Host", "type": "text", "help": "IP address or hostname"}, + {"id": "count", "label": "Count", "type": "number", "help": "Number of packets"}, ], "output": {"parser": "text"}, "safety": {"level": "safe", "requires_consent": False}, @@ -135,6 +136,11 @@ def test_invalid_fixture_catches_unknown_placeholder(self): placeholder_errors = [e for e in result.errors if "Placeholder" in e.message] assert placeholder_errors, "Expected placeholder-mismatch error" + def test_invalid_fixture_catches_missing_help_text(self): + result = validate_one_plugin(INVALID_FIXTURE) + help_errors = [e for e in result.errors if e.path.endswith(".help")] + assert len(help_errors) >= 2, "Expected help text errors for both fields" + # =========================================================================== # Required fields @@ -536,4 +542,60 @@ def test_error_display_format(self, tmp_path): result = validate_one_plugin(plugin_dir) err = next(e for e in result.errors if e.path == "safety.level") display = err.display() - assert "[" in display and "safety.level" in display and "→" in display \ No newline at end of file + assert "[" in display and "safety.level" in display and "→" in display + + +# =========================================================================== +# Metadata quality lint checks +# =========================================================================== + + +class TestMetadataQualityLint: + def test_missing_field_help_text_reported(self, tmp_path): + data = _minimal_valid() + data["fields"] = [ + {"id": "target", "label": "Target", "type": "text"}, + ] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + help_errors = [e for e in result.errors if e.path == "fields[0].help"] + assert len(help_errors) == 1 + assert "help" in help_errors[0].message + + def test_field_help_text_present_passes(self, tmp_path): + data = _minimal_valid() + data["fields"] = [ + {"id": "target", "label": "Target", "type": "text", "help": "The target IP or hostname"}, + ] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + help_errors = [e for e in result.errors if e.path.startswith("fields[0].help")] + assert help_errors == [] + + def test_invalid_category_reported(self, tmp_path): + data = _minimal_valid() + data["category"] = "unknown_category" + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + assert "category" in _error_paths(result) + cat_errors = [e for e in result.errors if e.path == "category"] + assert len(cat_errors) == 1 + assert "not a recognized category" in cat_errors[0].message + + def test_valid_categories_accepted(self, tmp_path): + for cat in sorted(VALID_CATEGORIES): + data = _minimal_valid() + data["category"] = cat + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + cat_errors = [e for e in result.errors if e.path == "category"] + assert cat_errors == [], f"Category '{cat}' should be valid" + + def test_missing_category_is_not_flagged(self, tmp_path): + data = _minimal_valid() + del data["category"] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + cat_errors = [e for e in result.errors if e.path == "category"] + assert len(cat_errors) == 1 + assert "Required" in cat_errors[0].message \ No newline at end of file