diff --git a/docs/plugin_health_dashboard.md b/docs/plugin_health_dashboard.md new file mode 100644 index 00000000..81d2c06c --- /dev/null +++ b/docs/plugin_health_dashboard.md @@ -0,0 +1,43 @@ +# Plugin Health & Coverage Dashboard + +The Plugin Health & Coverage Dashboard is a developer utility for inspecting SecuScan plugins. + +It follows the repository plugin model by treating plugin directories containing `metadata.json` as the source of truth and checking whether each plugin has a corresponding `parser.py`. + +## What it checks + +- Total plugin count +- Parser availability +- Plugin category distribution +- Plugin metadata path +- Plugin directory path + +## How to run + +Print a Markdown report to the terminal: + +```bash +python scripts/plugin_health_dashboard.py +``` + +Print JSON output: + +```bash +python scripts/plugin_health_dashboard.py --format json +``` + +Write Markdown output to a file: + +```bash +python scripts/plugin_health_dashboard.py --output plugin_health_report.md +``` + +Write JSON output to a file: + +```bash +python scripts/plugin_health_dashboard.py --format json --output plugin_health_report.json +``` + +## Notes + +The script does not write generated reports by default. Reports are printed to stdout unless an explicit `--output` path is provided. diff --git a/scripts/plugin_health_dashboard.py b/scripts/plugin_health_dashboard.py new file mode 100644 index 00000000..fc0db74f --- /dev/null +++ b/scripts/plugin_health_dashboard.py @@ -0,0 +1,122 @@ +import argparse +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PLUGIN_ROOT = ROOT / "plugins" + + +def safe_relative_path(path, base): + try: + return str(path.relative_to(base)) + except ValueError: + return str(path) + + +def discover_plugins(plugin_root=PLUGIN_ROOT): + plugins = [] + plugin_root = Path(plugin_root) + + if not plugin_root.exists(): + return plugins + + for metadata_file in plugin_root.rglob("metadata.json"): + plugin_dir = metadata_file.parent + parser_file = plugin_dir / "parser.py" + + try: + metadata = json.loads(metadata_file.read_text(encoding="utf-8")) + except json.JSONDecodeError: + metadata = {} + + plugins.append({ + "name": metadata.get("name", plugin_dir.name), + "path": safe_relative_path(plugin_dir, plugin_root.parent), + "metadata_path": safe_relative_path(metadata_file, plugin_root.parent), + "has_metadata": True, + "has_parser": parser_file.exists(), + "category": metadata.get("category", "uncategorized"), + "description": metadata.get("description", ""), + }) + + return sorted(plugins, key=lambda item: item["name"]) + + +def build_report(plugins): + total = len(plugins) + with_parser = sum(1 for plugin in plugins if plugin["has_parser"]) + + categories = {} + for plugin in plugins: + category = plugin["category"] + categories[category] = categories.get(category, 0) + 1 + + return { + "summary": { + "total_plugins": total, + "plugins_with_parser": with_parser, + "plugins_without_parser": total - with_parser, + }, + "categories": categories, + "plugins": plugins, + } + + +def format_markdown(report): + summary = report["summary"] + + lines = [ + "# Plugin Health & Coverage Report", + "", + "## Summary", + "", + f"- Total plugins: {summary['total_plugins']}", + f"- Plugins with parser.py: {summary['plugins_with_parser']}", + f"- Plugins without parser.py: {summary['plugins_without_parser']}", + "", + "## Categories", + "", + ] + + for category, count in sorted(report["categories"].items()): + lines.append(f"- {category}: {count}") + + lines.extend([ + "", + "## Plugin Details", + "", + "| Plugin | Category | Parser | Path |", + "|---|---|---|---|", + ]) + + for plugin in report["plugins"]: + parser_status = "Yes" if plugin["has_parser"] else "No" + lines.append( + f"| {plugin['name']} | {plugin['category']} | {parser_status} | `{plugin['path']}` |" + ) + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate plugin health report from metadata.json/parser.py plugin directories." + ) + parser.add_argument("--format", choices=["json", "markdown"], default="markdown") + parser.add_argument("--output", type=Path) + + args = parser.parse_args() + + report = build_report(discover_plugins()) + content = json.dumps(report, indent=2) if args.format == "json" else format_markdown(report) + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(content, encoding="utf-8") + else: + print(content) + + +if __name__ == "__main__": + main() diff --git a/testing/test_plugin_health_dashboard.py b/testing/test_plugin_health_dashboard.py new file mode 100644 index 00000000..41560d7d --- /dev/null +++ b/testing/test_plugin_health_dashboard.py @@ -0,0 +1,100 @@ +import json +from pathlib import Path +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.plugin_health_dashboard import discover_plugins, build_report, format_markdown + + +def test_discover_plugins_uses_metadata_and_parser_directories(tmp_path): + plugin_dir = tmp_path / "plugins" / "network" / "demo_plugin" + plugin_dir.mkdir(parents=True) + + metadata = { + "name": "demo_plugin", + "category": "network", + "description": "Demo plugin", + } + + (plugin_dir / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + (plugin_dir / "parser.py").write_text("def parse(): pass", encoding="utf-8") + + plugins = discover_plugins(tmp_path / "plugins") + + assert len(plugins) == 1 + assert plugins[0]["name"] == "demo_plugin" + assert plugins[0]["category"] == "network" + assert plugins[0]["has_parser"] is True + assert plugins[0]["has_metadata"] is True + + +def test_build_report_counts_parser_coverage(): + plugins = [ + {"name": "plugin_one", "category": "network", "has_parser": True}, + {"name": "plugin_two", "category": "web", "has_parser": False}, + ] + + report = build_report(plugins) + + assert report["summary"]["total_plugins"] == 2 + assert report["summary"]["plugins_with_parser"] == 1 + assert report["summary"]["plugins_without_parser"] == 1 + assert report["categories"]["network"] == 1 + assert report["categories"]["web"] == 1 + + +def test_format_markdown_contains_plugin_details(): + report = { + "summary": { + "total_plugins": 1, + "plugins_with_parser": 1, + "plugins_without_parser": 0, + }, + "categories": {"network": 1}, + "plugins": [ + { + "name": "demo_plugin", + "category": "network", + "has_parser": True, + "path": "plugins/network/demo_plugin", + } + ], + } + + markdown = format_markdown(report) + + assert "Plugin Health & Coverage Report" in markdown + assert "demo_plugin" in markdown + assert "network" in markdown + assert "Yes" in markdown + + +def test_cli_output_path_creates_parent_directories_and_writes_file(tmp_path): + output_path = tmp_path / "nested" / "reports" / "plugin_health_report.json" + + result = subprocess.run( + [ + sys.executable, + "scripts/plugin_health_dashboard.py", + "--format", + "json", + "--output", + str(output_path), + ], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + + assert result.stdout == "" + assert output_path.exists() + + report = json.loads(output_path.read_text(encoding="utf-8")) + assert "summary" in report + assert "plugins" in report + assert "categories" in report