Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,9 @@ Only run scans against systems you own or are explicitly authorized to assess.
| SQLi Exploiter | `sqli_exploiter` | `exploit` | `exploit` | `sqlmap` | Exploit SQL injection in web apps to extract data. |
| SQL Injection Testing | `sqlmap` | `web` | `exploit` | `sqlmap` | Automatic SQL injection and database takeover tool. |
| SSH Runner | `ssh_runner` | `execution` | `intrusive` | `ssh` | Remote command execution via SSH. |
| Subdomain Finder | `subdomain-finder` | `recon` | `safe` | `subfinder` | Discover subdomains of a domain. |
| Subdomain Scanner | `subdomain_discovery` | `recon` | `safe` | `subfinder` | Enumerate subdomains using passive sources. |
| Subdomain Discovery (Configurable) | `subdomain_discovery` | `recon` | `safe` | `subfinder` | Comprehensive configurable subdomain enumeration via passive sources. Thread count and source coverage tunable via presets. |
| Subdomain Takeover | `subdomain_takeover` | `exploit` | `intrusive` | `subfinder` | Discover dangling DNS entries pointing to external services. |
| Subfinder | `subfinder` | `recon` | `safe` | `subfinder` | Fast passive subdomain enumeration. |
| Subfinder (Quick) | `subfinder` | `recon` | `safe` | `subfinder` | Quick passive subdomain enumeration with minimal configuration — just provide a root domain. |
| theHarvester | `theharvester` | `recon` | `safe` | `theHarvester` | OSINT collection for emails, domains, and hosts. |
| TLS Security Analysis | `tls_inspector` | `security` | `safe` | `openssl` | Examine TLS/SSL certificates and cipher configurations. |
| Uncover | `uncover` | `recon` | `safe` | `uncover` | Discover internet-exposed assets from external search sources. |
Expand Down Expand Up @@ -306,3 +305,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
```
23 changes: 23 additions & 0 deletions backend/secuscan/plugin_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
15 changes: 5 additions & 10 deletions frontend/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
151 changes: 151 additions & 0 deletions frontend/testing/e2e/workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { test, expect } from '@playwright/test';

const BASE = 'http://127.0.0.1:5173';
const API_KEY = '6abeafd82cdb0eebea98dc3817b0bb5f9f8773f60402bccad9eaad7870ae8f58';

const MOCK_WORKFLOWS = [
{
id: 'wf-001',
name: 'Daily DNS Scan',
schedule_seconds: 86400,
enabled: true,
steps: [{ plugin_id: 'dns_recon', inputs: { target: 'example.com' } }],
last_run_at: new Date(Date.now() - 3600000).toISOString(),
queued_task_ids: [],
created_at: new Date().toISOString(),
},
];

const MOCK_CREATED_WORKFLOW = {
id: 'wf-002',
name: 'Nightly Port Scan',
schedule_seconds: 43200,
enabled: true,
steps: [{ plugin_id: 'port_scanner', inputs: { target: '10.0.0.1' } }],
last_run_at: null,
queued_task_ids: [],
created_at: new Date().toISOString(),
};

const MOCK_RUN_RESPONSE = {
queued_task_ids: ['task-001', 'task-002'],
};

async function setupMocks(page: import('@playwright/test').Page) {
await page.route(`${BASE}/api/v1/workflows`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ workflows: MOCK_WORKFLOWS, total: 1 }),
});
} else if (route.request().method() === 'POST') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CREATED_WORKFLOW),
});
} else {
await route.fulfill({ status: 404 });
}
});

await page.route(`${BASE}/api/v1/workflows/*/run`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_RUN_RESPONSE),
});
await page.route(`${BASE}/api/v1/workflows`, async (innerRoute) => {
const updated = MOCK_WORKFLOWS.map(w => ({
...w,
queued_task_ids: ['task-001', 'task-002'],
last_run_at: new Date().toISOString(),
}));
await innerRoute.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ workflows: updated, total: 1 }),
});
});
});

await page.route(`${BASE}/api/v1/workflows/*`, async (route) => {
if (route.request().method() === 'DELETE') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ deleted: true }),
});
await page.route(`${BASE}/api/v1/workflows`, async (innerRoute) => {
await innerRoute.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ workflows: [], total: 0 }),
});
});
} else {
await route.fulfill({ status: 404 });
}
});
}

test.describe('Workflow lifecycle', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate((key) => {
localStorage.setItem('secuscan_api_key', key);
}, API_KEY);
});

test('displays existing workflows', async ({ page }) => {
await setupMocks(page);
await page.goto('/workflows');
await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible();
await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Enabled')).toBeVisible();
});

test('creates a new workflow via the create sheet', async ({ page }) => {
await setupMocks(page);
await page.goto('/workflows');
await page.getByRole('button', { name: /new workflow/i }).click();
await expect(page.getByRole('heading', { name: /new workflow/i })).toBeVisible();
await page.getByPlaceholder('My Workflow').fill('Nightly Port Scan');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Nightly Port Scan')).toBeVisible({ timeout: 10000 });
});

test('runs a workflow and shows queued tasks', async ({ page }) => {
await setupMocks(page);
await page.goto('/workflows');
await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 });
await page.getByTitle('Run now').click();
await expect(page.getByText('task-001')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('task-002')).toBeVisible();
});

test('deletes a workflow and removes it from the grid', async ({ page }) => {
await setupMocks(page);
await page.goto('/workflows');
await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 });
await page.getByTitle('Delete').click();
await expect(page.getByRole('heading', { name: /delete workflow/i })).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('No Workflows')).toBeVisible({ timeout: 10000 });
});

test('full lifecycle: create, run, and delete a workflow', async ({ page }) => {
await setupMocks(page);
await page.goto('/workflows');
await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /new workflow/i }).click();
await page.getByPlaceholder('My Workflow').fill('Nightly Port Scan');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Nightly Port Scan')).toBeVisible({ timeout: 10000 });
await page.getByTitle('Run now').first().click();
await page.getByTitle('Delete').first().click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('No Workflows')).toBeVisible({ timeout: 10000 });
});
});
56 changes: 0 additions & 56 deletions plugins/subdomain-finder/metadata.json

This file was deleted.

45 changes: 0 additions & 45 deletions plugins/subdomain-finder/parser.py

This file was deleted.

6 changes: 3 additions & 3 deletions plugins/subdomain_discovery/metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
"id": "subdomain_discovery",
"name": "Subdomain Scanner",
"version": "1.0.0",
"description": "Enumerate subdomains using passive sources",
"long_description": "Subfinder is a subdomain discovery tool that returns valid subdomains for websites by using passive online sources. It has a simple modular architecture and is optimized for speed.",
"description": "Comprehensive configurable subdomain enumeration via passive sources.",
"long_description": "An advanced subdomain discovery plugin built on ProjectDiscovery's subfinder with additional configuration options. Supports tuning thread count, toggling all passive sources on/off, and selecting between quick and comprehensive presets. Use this plugin when you need fine-grained control over the enumeration depth and source coverage.",
"category": "recon",
"author": {
"name": "SecuScan Contributors",
Expand Down Expand Up @@ -88,5 +88,5 @@
"system_packages": []
},
"docker_image": "projectdiscovery/subfinder:latest",
"checksum": "34c426cb7ea665b795595723b7f6f0b4bd302ebd69971268ee9eebde4fbac5d5"
"checksum": "36a62951d26ba2da37b777b21fc117f81b4125e88144688f43cc1beb0989cc0c"
}
Loading
Loading