@adsim/wordpress-mcp-server 4.6.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
// src/tools/security.js — security tools (6)
|
|
2
|
+
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
|
+
|
|
4
|
+
import { json } from '../shared/utils.js';
|
|
5
|
+
import { rt } from '../shared/context.js';
|
|
6
|
+
|
|
7
|
+
export const definitions = [
|
|
8
|
+
{ name: 'wp_audit_user_security', _category: 'security', description: 'Use to audit administrator accounts for security risks: default usernames, inactive accounts, generic emails, missing 2FA, enumerable display names. Read-only.',
|
|
9
|
+
inputSchema: { type: 'object', properties: { days: { type: 'number', description: 'default 90' }, include_generic_emails: { type: 'boolean', description: 'default true' }, check_2fa: { type: 'boolean', description: 'default true' } }}},
|
|
10
|
+
{ name: 'wp_check_file_permissions', _category: 'security', description: 'Use to check critical WordPress file/directory permissions via companion mu-plugin. Reports permission issues with fix commands. Read-only.',
|
|
11
|
+
inputSchema: { type: 'object', properties: {} }},
|
|
12
|
+
{ name: 'wp_list_recently_modified_files', _category: 'security', description: 'Use to list recently modified files in wp-content with suspicious file detection (random names, PHP in uploads, out-of-update-window changes). Read-only.',
|
|
13
|
+
inputSchema: { type: 'object', properties: { days: { type: 'number', description: 'default 7' }, paths: { type: 'array', items: { type: 'string' }, description: 'Paths relative to wp-content/ (default ["plugins","themes"])' }, extensions: { type: 'array', items: { type: 'string' }, description: 'File extensions to scan (default [".php",".js"])' }, exclude_legitimate: { type: 'boolean', description: 'default true' } }}},
|
|
14
|
+
{ name: 'wp_audit_plugin_vulnerabilities', _category: 'security', description: 'Use to scan active plugins against WPScan vulnerability database. Returns CVEs with CVSS scores when WPSCAN_API_KEY is set; plugin list with versions otherwise. Read-only.',
|
|
15
|
+
inputSchema: { type: 'object', properties: { severity_filter: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'all'], description: 'default all' }, include_inactive: { type: 'boolean', description: 'default false' } }}},
|
|
16
|
+
{ name: 'wp_check_ssl_certificate', _category: 'security', description: 'Use to check SSL/TLS certificate validity, expiration, security headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP), and compute a security grade (A+ to F). Read-only.',
|
|
17
|
+
inputSchema: { type: 'object', properties: { domain: { type: 'string', description: 'Domain to check (default: site hostname)' }, warn_days: { type: 'number', description: 'default 30' } }}},
|
|
18
|
+
{ name: 'wp_audit_login_security', _category: 'security', description: 'Use to audit WordPress login security: XML-RPC exposure, user enumeration, login URL, 2FA plugins, brute force protection, admin username. Returns score /100 and grade. Read-only.',
|
|
19
|
+
inputSchema: { type: 'object', properties: {} }}
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const handlers = {};
|
|
23
|
+
|
|
24
|
+
handlers['wp_audit_user_security'] = async (args) => {
|
|
25
|
+
const t0 = Date.now();
|
|
26
|
+
let result;
|
|
27
|
+
const { wpApiCall, auditLog, sanitizeParams, name } = rt;
|
|
28
|
+
const secDays = args.days || 90;
|
|
29
|
+
const secGenericEmails = args.include_generic_emails !== false;
|
|
30
|
+
const secCheck2fa = args.check_2fa !== false;
|
|
31
|
+
const genericDomains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'aol.com', 'mail.com'];
|
|
32
|
+
|
|
33
|
+
// Fetch admin users
|
|
34
|
+
let admins;
|
|
35
|
+
try {
|
|
36
|
+
admins = await wpApiCall('/users?roles=administrator&per_page=100');
|
|
37
|
+
} catch (e) {
|
|
38
|
+
admins = await wpApiCall('/users?per_page=100');
|
|
39
|
+
}
|
|
40
|
+
if (!Array.isArray(admins)) admins = [];
|
|
41
|
+
|
|
42
|
+
// Try to get last activity from mu-plugin
|
|
43
|
+
let activityMap = {};
|
|
44
|
+
try {
|
|
45
|
+
const activity = await wpApiCall('/user-activity', { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
46
|
+
if (activity.users) {
|
|
47
|
+
for (const u of activity.users) {
|
|
48
|
+
activityMap[u.user_id] = u.last_login;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (_e) { /* mu-plugin not installed */ }
|
|
52
|
+
|
|
53
|
+
// Check for 2FA plugin
|
|
54
|
+
let twoFactorPlugin = null;
|
|
55
|
+
if (secCheck2fa) {
|
|
56
|
+
try {
|
|
57
|
+
const plugins = await wpApiCall('/plugins', { basePath: '/wp-json/wp/v2' });
|
|
58
|
+
if (Array.isArray(plugins)) {
|
|
59
|
+
const tfaSlugs = ['wp-2fa', 'two-factor', 'wordfence', 'miniOrange-2-factor-authentication'];
|
|
60
|
+
for (const p of plugins) {
|
|
61
|
+
const pSlug = (p.plugin || '').split('/')[0];
|
|
62
|
+
if (tfaSlugs.some(s => pSlug.toLowerCase().includes(s.toLowerCase())) && p.status === 'active') {
|
|
63
|
+
twoFactorPlugin = pSlug;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch (_e) { /* no plugin access */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const summary = { critical: 0, high: 0, medium: 0, low: 0, total_admins: admins.length };
|
|
72
|
+
const secUsers = admins.map(u => {
|
|
73
|
+
const risks = [];
|
|
74
|
+
const login = u.slug || u.username || u.name || '';
|
|
75
|
+
const email = u.email || '';
|
|
76
|
+
const displayName = u.name || '';
|
|
77
|
+
const registered = u.registered_date || u.date || null;
|
|
78
|
+
const lastLogin = activityMap[u.id] || null;
|
|
79
|
+
|
|
80
|
+
// CRITICAL: default username
|
|
81
|
+
if (login === 'admin' || login === 'administrator') {
|
|
82
|
+
risks.push({ level: 'critical', reason: `Default username "${login}"`, recommendation: 'Create a new admin account with a unique username and delete this one.' });
|
|
83
|
+
summary.critical++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// HIGH: inactivity
|
|
87
|
+
if (lastLogin) {
|
|
88
|
+
const daysSince = Math.floor((Date.now() - new Date(lastLogin).getTime()) / 86400000);
|
|
89
|
+
if (daysSince > secDays) {
|
|
90
|
+
risks.push({ level: 'high', reason: `No activity for ${daysSince} days (threshold: ${secDays})`, recommendation: 'Review account necessity. Consider disabling or deleting inactive admin accounts.' });
|
|
91
|
+
summary.high++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// HIGH: generic email
|
|
96
|
+
if (secGenericEmails && email) {
|
|
97
|
+
const domain = email.split('@')[1] || '';
|
|
98
|
+
if (genericDomains.includes(domain.toLowerCase())) {
|
|
99
|
+
risks.push({ level: 'high', reason: `Generic email provider (${domain}) on admin account`, recommendation: 'Use a professional/company email for administrator accounts.' });
|
|
100
|
+
summary.high++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// MEDIUM: no 2FA
|
|
105
|
+
if (secCheck2fa && !twoFactorPlugin) {
|
|
106
|
+
risks.push({ level: 'medium', reason: 'No 2FA plugin detected', recommendation: 'Install and configure a 2FA plugin (WP 2FA, Two Factor, or Wordfence).' });
|
|
107
|
+
summary.medium++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// LOW: display_name matches login
|
|
111
|
+
if (displayName && displayName === login) {
|
|
112
|
+
risks.push({ level: 'low', reason: 'Display name identical to login (user enumeration risk)', recommendation: 'Set a distinct display name different from the login username.' });
|
|
113
|
+
summary.low++;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const riskLevel = risks.some(r => r.level === 'critical') ? 'critical' : risks.some(r => r.level === 'high') ? 'high' : risks.some(r => r.level === 'medium') ? 'medium' : risks.some(r => r.level === 'low') ? 'low' : 'none';
|
|
117
|
+
return { id: u.id, login, email, display_name: displayName, registered, last_login: lastLogin, risk_level: riskLevel, risks };
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
result = json({ users: secUsers, summary, plugins_checked: { two_factor_plugin: twoFactorPlugin } });
|
|
121
|
+
auditLog({ tool: name, action: 'audit_user_security', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
122
|
+
return result;
|
|
123
|
+
};
|
|
124
|
+
handlers['wp_check_file_permissions'] = async (args) => {
|
|
125
|
+
const t0 = Date.now();
|
|
126
|
+
let result;
|
|
127
|
+
const { wpApiCall, auditLog, sanitizeParams, name } = rt;
|
|
128
|
+
let fpFiles;
|
|
129
|
+
try {
|
|
130
|
+
const fpData = await wpApiCall('/file-permissions', { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
131
|
+
fpFiles = fpData.files || [];
|
|
132
|
+
} catch (e) {
|
|
133
|
+
result = json({ files: [], overall_status: 'unavailable', message: 'Companion mu-plugin not installed. Copy mcp-diagnostics.php to wp-content/mu-plugins/mcp-diagnostics.php' });
|
|
134
|
+
auditLog({ tool: name, action: 'check_file_permissions', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const permChecks = {
|
|
139
|
+
'wp-config.php': { recommended: '400', max_octal: 0o440 },
|
|
140
|
+
'.htaccess': { recommended: '644', max_octal: 0o644 },
|
|
141
|
+
'uploads': { recommended: '755', max_octal: 0o755 },
|
|
142
|
+
'wp-includes': { recommended: '755', max_octal: 0o755 },
|
|
143
|
+
'wp-admin': { recommended: '755', max_octal: 0o755 },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let overallStatus = 'secure';
|
|
147
|
+
const fpResult = fpFiles.map(f => {
|
|
148
|
+
const shortPath = f.path.replace(/^.*[/\\]/, '') || f.path;
|
|
149
|
+
const matchKey = Object.keys(permChecks).find(k => f.path.includes(k)) || shortPath;
|
|
150
|
+
const check = permChecks[matchKey] || { recommended: '755', max_octal: 0o755 };
|
|
151
|
+
const currentOctal = parseInt(f.permission_octal, 8);
|
|
152
|
+
const maxOctal = check.max_octal;
|
|
153
|
+
|
|
154
|
+
let status = 'ok';
|
|
155
|
+
let risk = 'none';
|
|
156
|
+
if (!f.exists) {
|
|
157
|
+
status = 'missing';
|
|
158
|
+
risk = 'info';
|
|
159
|
+
} else if (currentOctal > maxOctal) {
|
|
160
|
+
if (f.path.includes('wp-config') && currentOctal > 0o440) {
|
|
161
|
+
status = 'critical';
|
|
162
|
+
risk = 'critical';
|
|
163
|
+
overallStatus = 'critical';
|
|
164
|
+
} else if (f.permission_octal === '777') {
|
|
165
|
+
status = 'critical';
|
|
166
|
+
risk = 'critical';
|
|
167
|
+
overallStatus = 'critical';
|
|
168
|
+
} else {
|
|
169
|
+
status = 'warning';
|
|
170
|
+
risk = 'high';
|
|
171
|
+
if (overallStatus !== 'critical') overallStatus = 'warnings';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
path: f.path,
|
|
177
|
+
current_permission: f.permission_octal,
|
|
178
|
+
recommended: check.recommended,
|
|
179
|
+
status,
|
|
180
|
+
risk,
|
|
181
|
+
fix_command: status !== 'ok' && status !== 'missing' ? `chmod ${check.recommended} ${f.path}` : null
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
result = json({ files: fpResult, overall_status: overallStatus });
|
|
186
|
+
auditLog({ tool: name, action: 'check_file_permissions', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
187
|
+
return result;
|
|
188
|
+
};
|
|
189
|
+
handlers['wp_list_recently_modified_files'] = async (args) => {
|
|
190
|
+
const t0 = Date.now();
|
|
191
|
+
let result;
|
|
192
|
+
const { wpApiCall, auditLog, sanitizeParams, name } = rt;
|
|
193
|
+
const rmfDays = args.days || 7;
|
|
194
|
+
const rmfPaths = args.paths || ['plugins', 'themes'];
|
|
195
|
+
const rmfExtensions = args.extensions || ['.php', '.js'];
|
|
196
|
+
const rmfExcludeLegitimate = args.exclude_legitimate !== false;
|
|
197
|
+
|
|
198
|
+
let modifiedFiles;
|
|
199
|
+
try {
|
|
200
|
+
const pathsParam = rmfPaths.join(',');
|
|
201
|
+
const extParam = rmfExtensions.join(',');
|
|
202
|
+
const rmfData = await wpApiCall(`/modified-files?days=${rmfDays}&paths=${encodeURIComponent(pathsParam)}&extensions=${encodeURIComponent(extParam)}`, { basePath: '/wp-json/mcp-diagnostics/v1' });
|
|
203
|
+
modifiedFiles = rmfData.files || [];
|
|
204
|
+
} catch (e) {
|
|
205
|
+
result = json({ files: [], summary: { total_modified: 0, suspicious_count: 0, paths_scanned: rmfPaths }, alert: false, message: 'Companion mu-plugin not installed. Copy mcp-diagnostics.php to wp-content/mu-plugins/mcp-diagnostics.php' });
|
|
206
|
+
auditLog({ tool: name, action: 'list_recently_modified_files', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Get plugin update timestamps for legitimate change detection
|
|
211
|
+
let pluginUpdates = {};
|
|
212
|
+
if (rmfExcludeLegitimate) {
|
|
213
|
+
try {
|
|
214
|
+
const plugins = await wpApiCall('/plugins', { basePath: '/wp-json/wp/v2' });
|
|
215
|
+
if (Array.isArray(plugins)) {
|
|
216
|
+
for (const p of plugins) {
|
|
217
|
+
const pDir = (p.plugin || '').split('/')[0];
|
|
218
|
+
if (pDir && p.status === 'active') {
|
|
219
|
+
pluginUpdates[pDir] = p.updated || p.last_updated || null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (_e) { /* no plugin access */ }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const randomNameRegex = /[a-f0-9]{8,}\.php$/;
|
|
227
|
+
let suspiciousCount = 0;
|
|
228
|
+
|
|
229
|
+
const rmfResult = modifiedFiles.map(f => {
|
|
230
|
+
let suspicious = false;
|
|
231
|
+
let reason = null;
|
|
232
|
+
let pluginContext = null;
|
|
233
|
+
|
|
234
|
+
// Check plugin context
|
|
235
|
+
const pathParts = f.path.split('/');
|
|
236
|
+
const pluginDirIdx = pathParts.indexOf('plugins');
|
|
237
|
+
if (pluginDirIdx >= 0 && pathParts.length > pluginDirIdx + 1) {
|
|
238
|
+
pluginContext = pathParts[pluginDirIdx + 1];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Suspicious: PHP in uploads
|
|
242
|
+
if (f.path.includes('/uploads/') && f.extension === '.php') {
|
|
243
|
+
suspicious = true;
|
|
244
|
+
reason = 'PHP file in uploads directory';
|
|
245
|
+
}
|
|
246
|
+
// Suspicious: random filename
|
|
247
|
+
else if (randomNameRegex.test(f.path)) {
|
|
248
|
+
suspicious = true;
|
|
249
|
+
reason = 'Random hex filename pattern';
|
|
250
|
+
}
|
|
251
|
+
// Suspicious: out of update window
|
|
252
|
+
else if (rmfExcludeLegitimate && pluginContext && pluginUpdates[pluginContext]) {
|
|
253
|
+
const updateTime = new Date(pluginUpdates[pluginContext]).getTime();
|
|
254
|
+
const fileTime = new Date(f.modified_at).getTime();
|
|
255
|
+
const diffHours = Math.abs(fileTime - updateTime) / 3600000;
|
|
256
|
+
if (diffHours > 24) {
|
|
257
|
+
suspicious = true;
|
|
258
|
+
reason = `Modified ${Math.round(diffHours)}h outside plugin update window`;
|
|
259
|
+
}
|
|
260
|
+
} else if (rmfExcludeLegitimate && pluginContext && !pluginUpdates[pluginContext]) {
|
|
261
|
+
// Plugin not in update list — could be suspicious
|
|
262
|
+
suspicious = true;
|
|
263
|
+
reason = 'Plugin not found in active plugins list';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (suspicious) suspiciousCount++;
|
|
267
|
+
return { path: f.path, modified_at: f.modified_at, size_bytes: f.size, extension: f.extension, suspicious, reason, plugin_context: pluginContext };
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
result = json({ files: rmfResult, summary: { total_modified: rmfResult.length, suspicious_count: suspiciousCount, paths_scanned: rmfPaths }, alert: suspiciousCount > 0 });
|
|
271
|
+
auditLog({ tool: name, action: 'list_recently_modified_files', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
272
|
+
return result;
|
|
273
|
+
};
|
|
274
|
+
handlers['wp_audit_plugin_vulnerabilities'] = async (args) => {
|
|
275
|
+
const t0 = Date.now();
|
|
276
|
+
let result;
|
|
277
|
+
const { wpApiCall, VERSION, log, fetch, auditLog, sanitizeParams, name } = rt;
|
|
278
|
+
const vulnSeverity = args.severity_filter || 'all';
|
|
279
|
+
const vulnInactive = args.include_inactive || false;
|
|
280
|
+
const wpscanKey = process.env.WPSCAN_API_KEY || null;
|
|
281
|
+
|
|
282
|
+
let plugins;
|
|
283
|
+
try {
|
|
284
|
+
plugins = await wpApiCall('/plugins', { basePath: '/wp-json/wp/v2' });
|
|
285
|
+
} catch (e) {
|
|
286
|
+
throw new Error('Cannot access /wp/v2/plugins. Administrator privileges required.');
|
|
287
|
+
}
|
|
288
|
+
if (!Array.isArray(plugins)) plugins = [];
|
|
289
|
+
|
|
290
|
+
// Filter by status
|
|
291
|
+
if (!vulnInactive) {
|
|
292
|
+
plugins = plugins.filter(p => p.status === 'active');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const pluginCount = plugins.length;
|
|
296
|
+
const vulnerable = [];
|
|
297
|
+
let cleanCount = 0;
|
|
298
|
+
let quotaRemaining = null;
|
|
299
|
+
|
|
300
|
+
if (wpscanKey) {
|
|
301
|
+
if (pluginCount > 25) {
|
|
302
|
+
log.warn(`Scanning ${pluginCount} plugins against WPScan API. May take ${Math.round(pluginCount * 2.5)}s due to rate limiting.`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
306
|
+
const p = plugins[i];
|
|
307
|
+
const pSlug = (p.plugin || '').split('/')[0];
|
|
308
|
+
if (!pSlug) { cleanCount++; continue; }
|
|
309
|
+
const pVersion = (p.version || '').trim();
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const controller = new AbortController();
|
|
313
|
+
const tid = setTimeout(() => controller.abort(), 15000);
|
|
314
|
+
const wpscanResp = await fetch(`https://wpscan.com/api/v3/plugins/${pSlug}`, {
|
|
315
|
+
headers: { 'Authorization': `Token token=${wpscanKey}`, 'User-Agent': `WordPress-MCP-Server/${VERSION}` },
|
|
316
|
+
signal: controller.signal
|
|
317
|
+
});
|
|
318
|
+
clearTimeout(tid);
|
|
319
|
+
|
|
320
|
+
if (wpscanResp.headers.get('x-ratelimit-remaining')) {
|
|
321
|
+
quotaRemaining = parseInt(wpscanResp.headers.get('x-ratelimit-remaining'), 10);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!wpscanResp.ok) { cleanCount++; continue; }
|
|
325
|
+
const wpscanData = await wpscanResp.json();
|
|
326
|
+
const pluginData = wpscanData[pSlug];
|
|
327
|
+
if (!pluginData || !pluginData.vulnerabilities || pluginData.vulnerabilities.length === 0) {
|
|
328
|
+
cleanCount++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const vulns = pluginData.vulnerabilities
|
|
333
|
+
.filter(v => {
|
|
334
|
+
if (v.fixed_in && pVersion && v.fixed_in <= pVersion) return false;
|
|
335
|
+
if (vulnSeverity === 'all') return true;
|
|
336
|
+
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
337
|
+
const vSev = v.cvss ? (v.cvss.score >= 9 ? 'critical' : v.cvss.score >= 7 ? 'high' : v.cvss.score >= 4 ? 'medium' : 'low') : 'medium';
|
|
338
|
+
return (severityOrder[vSev] || 0) >= (severityOrder[vulnSeverity] || 0);
|
|
339
|
+
})
|
|
340
|
+
.map(v => ({
|
|
341
|
+
title: v.title,
|
|
342
|
+
cve_id: v.references?.cve?.[0] ? `CVE-${v.references.cve[0]}` : null,
|
|
343
|
+
cvss_score: v.cvss?.score || null,
|
|
344
|
+
severity: v.cvss ? (v.cvss.score >= 9 ? 'critical' : v.cvss.score >= 7 ? 'high' : v.cvss.score >= 4 ? 'medium' : 'low') : 'unknown',
|
|
345
|
+
fixed_in_version: v.fixed_in || null,
|
|
346
|
+
patched: !!(v.fixed_in && pVersion && v.fixed_in <= pVersion),
|
|
347
|
+
references: v.references?.url || []
|
|
348
|
+
}));
|
|
349
|
+
|
|
350
|
+
if (vulns.length > 0) {
|
|
351
|
+
vulnerable.push({ slug: pSlug, name: p.name || pSlug, version: pVersion, vulnerabilities: vulns });
|
|
352
|
+
} else {
|
|
353
|
+
cleanCount++;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Rate limiting: 2.5s between requests for free plan
|
|
357
|
+
if (i < plugins.length - 1) {
|
|
358
|
+
await new Promise(r => setTimeout(r, 2500));
|
|
359
|
+
}
|
|
360
|
+
} catch (_e) {
|
|
361
|
+
cleanCount++;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
cleanCount = pluginCount;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const resultData = {
|
|
369
|
+
plugins_scanned: pluginCount,
|
|
370
|
+
vulnerable,
|
|
371
|
+
clean_count: cleanCount,
|
|
372
|
+
api_source: wpscanKey ? 'wpscan' : 'none'
|
|
373
|
+
};
|
|
374
|
+
if (!wpscanKey) {
|
|
375
|
+
resultData.message = 'Add WPSCAN_API_KEY to .env for vulnerability scanning. Free API key: https://wpscan.com/register';
|
|
376
|
+
resultData.plugins = plugins.map(p => ({ slug: (p.plugin || '').split('/')[0], name: p.name, version: p.version, status: p.status }));
|
|
377
|
+
}
|
|
378
|
+
if (quotaRemaining !== null) resultData.wpscan_quota_remaining = quotaRemaining;
|
|
379
|
+
|
|
380
|
+
result = json(resultData);
|
|
381
|
+
auditLog({ tool: name, action: 'audit_plugin_vulnerabilities', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
382
|
+
return result;
|
|
383
|
+
};
|
|
384
|
+
handlers['wp_check_ssl_certificate'] = async (args) => {
|
|
385
|
+
const t0 = Date.now();
|
|
386
|
+
let result;
|
|
387
|
+
const { getActiveAuth, VERSION, fetch, auditLog, name } = rt;
|
|
388
|
+
const { url: siteUrl } = getActiveAuth();
|
|
389
|
+
const sslDomain = args.domain || new URL(siteUrl).hostname;
|
|
390
|
+
const sslWarnDays = args.warn_days || 30;
|
|
391
|
+
|
|
392
|
+
// Step 1: TLS handshake via Node.js tls module
|
|
393
|
+
let certInfo = {};
|
|
394
|
+
let sslValid = false;
|
|
395
|
+
let sslGrade = 'F';
|
|
396
|
+
try {
|
|
397
|
+
const tls = await import('tls');
|
|
398
|
+
const certData = await new Promise((resolve, reject) => {
|
|
399
|
+
const sock = tls.connect(443, sslDomain, { servername: sslDomain, rejectUnauthorized: false }, () => {
|
|
400
|
+
const cert = sock.getPeerCertificate();
|
|
401
|
+
const authorized = sock.authorized;
|
|
402
|
+
sock.end();
|
|
403
|
+
resolve({ cert, authorized });
|
|
404
|
+
});
|
|
405
|
+
sock.on('error', reject);
|
|
406
|
+
setTimeout(() => { sock.destroy(); reject(new Error('TLS timeout')); }, 10000);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const cert = certData.cert;
|
|
410
|
+
const notBefore = cert.valid_from ? new Date(cert.valid_from).toISOString() : null;
|
|
411
|
+
const notAfter = cert.valid_to ? new Date(cert.valid_to).toISOString() : null;
|
|
412
|
+
const daysUntilExpiry = notAfter ? Math.floor((new Date(notAfter).getTime() - Date.now()) / 86400000) : null;
|
|
413
|
+
const selfSigned = cert.issuer && cert.subject && JSON.stringify(cert.issuer) === JSON.stringify(cert.subject);
|
|
414
|
+
const isWildcard = (cert.subject?.CN || '').startsWith('*.');
|
|
415
|
+
|
|
416
|
+
sslValid = certData.authorized && daysUntilExpiry > 0;
|
|
417
|
+
certInfo = {
|
|
418
|
+
domain: sslDomain,
|
|
419
|
+
valid: sslValid,
|
|
420
|
+
issuer: cert.issuer ? `${cert.issuer.O || ''} ${cert.issuer.CN || ''}`.trim() : null,
|
|
421
|
+
subject: cert.subject?.CN || null,
|
|
422
|
+
san: cert.subjectaltname || null,
|
|
423
|
+
not_before: notBefore,
|
|
424
|
+
not_after: notAfter,
|
|
425
|
+
days_until_expiry: daysUntilExpiry,
|
|
426
|
+
expires_soon: daysUntilExpiry !== null && daysUntilExpiry < sslWarnDays,
|
|
427
|
+
self_signed: selfSigned,
|
|
428
|
+
wildcard: isWildcard
|
|
429
|
+
};
|
|
430
|
+
} catch (e) {
|
|
431
|
+
certInfo = { domain: sslDomain, valid: false, error: e.message, days_until_expiry: null, expires_soon: false, self_signed: false };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Step 2: Security headers
|
|
435
|
+
let hstsInfo = { present: false, max_age: 0, includeSubDomains: false };
|
|
436
|
+
let securityHeaders = { x_content_type: false, x_frame_options: false, csp: false };
|
|
437
|
+
try {
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const tid = setTimeout(() => controller.abort(), 10000);
|
|
440
|
+
const headerResp = await fetch(`https://${sslDomain}`, {
|
|
441
|
+
headers: { 'User-Agent': `WordPress-MCP-Server/${VERSION}` },
|
|
442
|
+
signal: controller.signal,
|
|
443
|
+
redirect: 'follow'
|
|
444
|
+
});
|
|
445
|
+
clearTimeout(tid);
|
|
446
|
+
|
|
447
|
+
const hsts = headerResp.headers.get('strict-transport-security') || '';
|
|
448
|
+
if (hsts) {
|
|
449
|
+
hstsInfo.present = true;
|
|
450
|
+
const maxAgeMatch = hsts.match(/max-age=(\d+)/);
|
|
451
|
+
if (maxAgeMatch) hstsInfo.max_age = parseInt(maxAgeMatch[1], 10);
|
|
452
|
+
hstsInfo.includeSubDomains = hsts.includes('includeSubDomains');
|
|
453
|
+
}
|
|
454
|
+
securityHeaders.x_content_type = !!(headerResp.headers.get('x-content-type-options'));
|
|
455
|
+
securityHeaders.x_frame_options = !!(headerResp.headers.get('x-frame-options'));
|
|
456
|
+
securityHeaders.csp = !!(headerResp.headers.get('content-security-policy'));
|
|
457
|
+
} catch (_e) { /* headers unavailable */ }
|
|
458
|
+
|
|
459
|
+
// Grade calculation
|
|
460
|
+
if (!certInfo.valid || certInfo.self_signed) {
|
|
461
|
+
sslGrade = 'F';
|
|
462
|
+
} else if (certInfo.expires_soon) {
|
|
463
|
+
sslGrade = 'C';
|
|
464
|
+
} else if (hstsInfo.present && hstsInfo.max_age >= 31536000 && securityHeaders.x_content_type && securityHeaders.x_frame_options) {
|
|
465
|
+
sslGrade = 'A+';
|
|
466
|
+
} else if (hstsInfo.present) {
|
|
467
|
+
sslGrade = 'A';
|
|
468
|
+
} else {
|
|
469
|
+
sslGrade = 'B';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
result = json({
|
|
473
|
+
...certInfo,
|
|
474
|
+
grade: sslGrade,
|
|
475
|
+
hsts: hstsInfo,
|
|
476
|
+
security_headers: securityHeaders,
|
|
477
|
+
alert: sslGrade > 'B' || certInfo.expires_soon
|
|
478
|
+
});
|
|
479
|
+
auditLog({ tool: name, action: 'check_ssl_certificate', status: 'success', latency_ms: Date.now() - t0, params: { domain: sslDomain } });
|
|
480
|
+
return result;
|
|
481
|
+
};
|
|
482
|
+
handlers['wp_audit_login_security'] = async (args) => {
|
|
483
|
+
const t0 = Date.now();
|
|
484
|
+
let result;
|
|
485
|
+
const { wpApiCall, getActiveAuth, VERSION, fetch, auditLog, sanitizeParams, name } = rt;
|
|
486
|
+
const { url: loginSiteUrl } = getActiveAuth();
|
|
487
|
+
const siteHostUrl = loginSiteUrl.replace(/\/wp-json.*$/, '').replace(/\/$/, '');
|
|
488
|
+
let totalScore = 0;
|
|
489
|
+
const checks = [];
|
|
490
|
+
|
|
491
|
+
// [20pts] XML-RPC disabled
|
|
492
|
+
let xmlrpcPassed = false;
|
|
493
|
+
try {
|
|
494
|
+
const controller = new AbortController();
|
|
495
|
+
const tid = setTimeout(() => controller.abort(), 10000);
|
|
496
|
+
const xmlrpcResp = await fetch(`${siteHostUrl}/xmlrpc.php`, {
|
|
497
|
+
method: 'GET',
|
|
498
|
+
headers: { 'User-Agent': `WordPress-MCP-Server/${VERSION}` },
|
|
499
|
+
signal: controller.signal,
|
|
500
|
+
redirect: 'follow'
|
|
501
|
+
});
|
|
502
|
+
clearTimeout(tid);
|
|
503
|
+
xmlrpcPassed = xmlrpcResp.status === 403 || xmlrpcResp.status === 404;
|
|
504
|
+
} catch (_e) { xmlrpcPassed = true; }
|
|
505
|
+
checks.push({ name: 'xmlrpc_disabled', passed: xmlrpcPassed, score_awarded: xmlrpcPassed ? 20 : 0, detail: xmlrpcPassed ? 'XML-RPC is disabled or blocked' : 'XML-RPC is accessible (brute force vector)', recommendation: xmlrpcPassed ? null : 'Disable XML-RPC via .htaccess, plugin, or web server config.' });
|
|
506
|
+
totalScore += xmlrpcPassed ? 20 : 0;
|
|
507
|
+
|
|
508
|
+
// [20pts] User enumeration blocked
|
|
509
|
+
let enumPassed = false;
|
|
510
|
+
try {
|
|
511
|
+
const controller = new AbortController();
|
|
512
|
+
const tid = setTimeout(() => controller.abort(), 10000);
|
|
513
|
+
const enumResp = await fetch(`${siteHostUrl}/wp-json/wp/v2/users`, {
|
|
514
|
+
headers: { 'User-Agent': `WordPress-MCP-Server/${VERSION}` },
|
|
515
|
+
signal: controller.signal
|
|
516
|
+
});
|
|
517
|
+
clearTimeout(tid);
|
|
518
|
+
enumPassed = enumResp.status === 401 || enumResp.status === 403;
|
|
519
|
+
} catch (_e) { enumPassed = true; }
|
|
520
|
+
checks.push({ name: 'user_enumeration_blocked', passed: enumPassed, score_awarded: enumPassed ? 20 : 0, detail: enumPassed ? 'User enumeration is blocked' : 'User list is publicly accessible', recommendation: enumPassed ? null : 'Restrict /wp-json/wp/v2/users to authenticated requests only.' });
|
|
521
|
+
totalScore += enumPassed ? 20 : 0;
|
|
522
|
+
|
|
523
|
+
// [15pts] Login URL changed
|
|
524
|
+
let loginUrlPassed = false;
|
|
525
|
+
try {
|
|
526
|
+
const controller = new AbortController();
|
|
527
|
+
const tid = setTimeout(() => controller.abort(), 10000);
|
|
528
|
+
const loginResp = await fetch(`${siteHostUrl}/wp-login.php`, {
|
|
529
|
+
method: 'GET',
|
|
530
|
+
headers: { 'User-Agent': `WordPress-MCP-Server/${VERSION}` },
|
|
531
|
+
signal: controller.signal,
|
|
532
|
+
redirect: 'manual'
|
|
533
|
+
});
|
|
534
|
+
clearTimeout(tid);
|
|
535
|
+
loginUrlPassed = loginResp.status === 404;
|
|
536
|
+
} catch (_e) { loginUrlPassed = false; }
|
|
537
|
+
checks.push({ name: 'login_url_changed', passed: loginUrlPassed, score_awarded: loginUrlPassed ? 15 : 0, detail: loginUrlPassed ? 'Default login URL is hidden (security plugin active)' : 'Standard wp-login.php is accessible', recommendation: loginUrlPassed ? null : 'Consider using a login URL plugin (WPS Hide Login, iThemes Security).' });
|
|
538
|
+
totalScore += loginUrlPassed ? 15 : 0;
|
|
539
|
+
|
|
540
|
+
// [15pts] 2FA active
|
|
541
|
+
let tfaPassed = false;
|
|
542
|
+
let securityPlugins = [];
|
|
543
|
+
try {
|
|
544
|
+
const plugins = await wpApiCall('/plugins', { basePath: '/wp-json/wp/v2' });
|
|
545
|
+
if (Array.isArray(plugins)) {
|
|
546
|
+
const secSlugs = { 'wp-2fa': '2FA', 'two-factor': '2FA', 'wordfence': 'Wordfence', 'miniOrange-2-factor-authentication': '2FA', 'limit-login-attempts-reloaded': 'Brute Force', 'jetpack': 'Jetpack', 'all-in-one-wp-security-and-firewall': 'Security', 'better-wp-security': 'iThemes Security', 'sucuri-scanner': 'Sucuri' };
|
|
547
|
+
for (const p of plugins) {
|
|
548
|
+
if (p.status !== 'active') continue;
|
|
549
|
+
const pSlug = (p.plugin || '').split('/')[0];
|
|
550
|
+
for (const [slug, label] of Object.entries(secSlugs)) {
|
|
551
|
+
if (pSlug.toLowerCase().includes(slug.toLowerCase())) {
|
|
552
|
+
securityPlugins.push(pSlug);
|
|
553
|
+
if (['wp-2fa', 'two-factor', 'wordfence', 'miniOrange-2-factor-authentication'].some(s => pSlug.toLowerCase().includes(s.toLowerCase()))) {
|
|
554
|
+
tfaPassed = true;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
} catch (_e) { /* no access */ }
|
|
561
|
+
checks.push({ name: 'two_factor_active', passed: tfaPassed, score_awarded: tfaPassed ? 15 : 0, detail: tfaPassed ? '2FA plugin is active' : 'No 2FA plugin detected', recommendation: tfaPassed ? null : 'Install a 2FA plugin (WP 2FA, Two Factor, or Wordfence).' });
|
|
562
|
+
totalScore += tfaPassed ? 15 : 0;
|
|
563
|
+
|
|
564
|
+
// [15pts] Brute force protection
|
|
565
|
+
const bfSlugs = ['limit-login-attempts-reloaded', 'wordfence', 'jetpack', 'all-in-one-wp-security', 'better-wp-security', 'sucuri-scanner'];
|
|
566
|
+
const bfPassed = securityPlugins.some(p => bfSlugs.some(s => p.toLowerCase().includes(s.toLowerCase())));
|
|
567
|
+
checks.push({ name: 'brute_force_protection', passed: bfPassed, score_awarded: bfPassed ? 15 : 0, detail: bfPassed ? 'Brute force protection plugin detected' : 'No brute force protection detected', recommendation: bfPassed ? null : 'Install Limit Login Attempts Reloaded, Wordfence, or a similar plugin.' });
|
|
568
|
+
totalScore += bfPassed ? 15 : 0;
|
|
569
|
+
|
|
570
|
+
// [15pts] Admin username clean
|
|
571
|
+
let adminClean = true;
|
|
572
|
+
try {
|
|
573
|
+
const users = await wpApiCall('/users?per_page=100');
|
|
574
|
+
if (Array.isArray(users)) {
|
|
575
|
+
for (const u of users) {
|
|
576
|
+
const uSlug = u.slug || u.username || '';
|
|
577
|
+
if (uSlug === 'admin' || uSlug === 'administrator') { adminClean = false; break; }
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch (_e) { /* no access */ }
|
|
581
|
+
checks.push({ name: 'admin_username_clean', passed: adminClean, score_awarded: adminClean ? 15 : 0, detail: adminClean ? 'No default admin/administrator username found' : 'Default "admin" or "administrator" username detected', recommendation: adminClean ? null : 'Rename or replace the default admin account.' });
|
|
582
|
+
totalScore += adminClean ? 15 : 0;
|
|
583
|
+
|
|
584
|
+
const loginGrade = totalScore >= 80 ? 'A' : totalScore >= 60 ? 'B' : totalScore >= 40 ? 'C' : 'F';
|
|
585
|
+
const criticalIssues = checks.filter(c => !c.passed && c.score_awarded === 0 && (c.name === 'xmlrpc_disabled' || c.name === 'user_enumeration_blocked' || c.name === 'admin_username_clean')).map(c => c.detail);
|
|
586
|
+
|
|
587
|
+
result = json({ score: totalScore, grade: loginGrade, checks, critical_issues: criticalIssues, plugins_detected: { security_plugins: [...new Set(securityPlugins)] } });
|
|
588
|
+
auditLog({ tool: name, action: 'audit_login_security', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(args) });
|
|
589
|
+
return result;
|
|
590
|
+
};
|