drupal-mcp-connector 1.5.0 → 1.6.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/CHANGELOG.md +88 -0
- package/README.md +7 -2
- package/config/config.example.json +12 -1
- package/package.json +1 -1
- package/src/index.js +27 -1
- package/src/lib/audit-sources.js +66 -0
- package/src/lib/audit-support.js +170 -0
- package/src/lib/backends/backend-interface.js +10 -0
- package/src/lib/backends/jsonapi.js +23 -0
- package/src/lib/link-checker.js +201 -0
- package/src/lib/server-tools.js +17 -0
- package/src/tools/audit-composite.js +152 -0
- package/src/tools/drush.js +25 -3
- package/src/tools/nodes.js +137 -9
- package/src/tools/redirects.js +30 -8
- package/src/tools/reports-config.js +634 -0
- package/src/tools/reports-content.js +649 -0
- package/src/tools/reports-links.js +595 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarded live link checker — opt-in outbound HTTP for the broken-links audit.
|
|
3
|
+
*
|
|
4
|
+
* Single responsibility: given a set of URLs, perform bounded liveness checks
|
|
5
|
+
* (HEAD, falling back to GET) and report each URL's status. Outbound HTTP is a
|
|
6
|
+
* privileged capability, so this module is the one place network egress is
|
|
7
|
+
* allowed during an audit, and only when the caller explicitly opts in.
|
|
8
|
+
*
|
|
9
|
+
* Safeguards (see checkLinks):
|
|
10
|
+
* - SSRF guard: only http(s); refuse loopback/private/link-local/metadata
|
|
11
|
+
* hosts and IP literals in private ranges.
|
|
12
|
+
* - Allowlist: external hosts are skipped unless listed in
|
|
13
|
+
* `allowedHosts`; same-origin (internal) hosts are always allowed.
|
|
14
|
+
* - Caps: bounded concurrency, per-request timeout, and a hard ceiling on the
|
|
15
|
+
* number of URLs checked (results flag `truncated` when the ceiling is hit).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import nodeFetch from "node-fetch";
|
|
19
|
+
|
|
20
|
+
/** Default per-request timeout (ms). */
|
|
21
|
+
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
22
|
+
/** Default number of concurrent in-flight checks. */
|
|
23
|
+
export const DEFAULT_CONCURRENCY = 5;
|
|
24
|
+
/** Default hard ceiling on URLs checked in one call. */
|
|
25
|
+
export const DEFAULT_MAX_LINKS = 200;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hostnames that must never be probed regardless of allowlist — loopback and
|
|
29
|
+
* cloud metadata endpoints.
|
|
30
|
+
*/
|
|
31
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
32
|
+
"localhost",
|
|
33
|
+
"metadata.google.internal",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether a URL is safe to probe: http(s) only, and not pointed at a
|
|
38
|
+
* loopback, private, link-local, unique-local, or metadata address. Hostnames
|
|
39
|
+
* that are not IP literals are allowed past the IP-range check (DNS is not
|
|
40
|
+
* resolved here); the allowlist in checkLinks is the second gate for those.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} url Absolute URL to validate.
|
|
43
|
+
* @returns {{safe: boolean, reason: ?string, host: ?string}} Verdict.
|
|
44
|
+
*/
|
|
45
|
+
export function isSafeUrl(url) {
|
|
46
|
+
let parsed;
|
|
47
|
+
try { parsed = new URL(url); }
|
|
48
|
+
catch { return { safe: false, reason: "unparseable URL", host: null }; }
|
|
49
|
+
|
|
50
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
51
|
+
return { safe: false, reason: `unsupported protocol ${parsed.protocol}`, host: parsed.host };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// URL.hostname returns IPv6 literals wrapped in brackets; strip them so the
|
|
55
|
+
// range checks below see a bare address.
|
|
56
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
57
|
+
if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
|
|
58
|
+
return { safe: false, reason: "loopback host", host: parsed.host };
|
|
59
|
+
}
|
|
60
|
+
if (isPrivateIp(hostname)) {
|
|
61
|
+
return { safe: false, reason: "private or link-local address", host: parsed.host };
|
|
62
|
+
}
|
|
63
|
+
return { safe: true, reason: null, host: parsed.host };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether a hostname is an IP literal in a loopback/private/link-local/ULA range.
|
|
68
|
+
* Non-IP hostnames return false (resolution happens at fetch time, not here).
|
|
69
|
+
* @param {string} hostname Lowercased hostname from a URL.
|
|
70
|
+
* @returns {boolean} True when the literal IP is in a blocked range.
|
|
71
|
+
*/
|
|
72
|
+
function isPrivateIp(hostname) {
|
|
73
|
+
// IPv6 literals arrive bracket-stripped from URL.hostname.
|
|
74
|
+
if (hostname.includes(":")) {
|
|
75
|
+
if (hostname === "::1") return true; // loopback
|
|
76
|
+
if (hostname.startsWith("fe80")) return true; // link-local
|
|
77
|
+
if (hostname.startsWith("fc") || hostname.startsWith("fd")) return true; // ULA
|
|
78
|
+
if (hostname.startsWith("::ffff:")) return isPrivateIp(hostname.slice(7)); // mapped v4
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const m = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
82
|
+
if (!m) return false;
|
|
83
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
84
|
+
if (a === 127) return true; // 127.0.0.0/8 loopback
|
|
85
|
+
if (a === 10) return true; // 10.0.0.0/8
|
|
86
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
87
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + metadata
|
|
88
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
|
89
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check a list of URLs for liveness with bounded concurrency.
|
|
95
|
+
*
|
|
96
|
+
* @param {string[]} urls URLs to check (duplicates are de-duped).
|
|
97
|
+
* @param {object} [opts]
|
|
98
|
+
* @param {string[]} [opts.allowedHosts] Hosts allowed for external probes. A
|
|
99
|
+
* same-origin host (see opts.internalHost) is always allowed.
|
|
100
|
+
* @param {?string} [opts.internalHost] The site's own host; never needs listing.
|
|
101
|
+
* @param {number} [opts.maxConcurrency] In-flight cap (default 5).
|
|
102
|
+
* @param {number} [opts.timeoutMs] Per-request timeout (default 5000).
|
|
103
|
+
* @param {number} [opts.maxLinks] Hard ceiling on URLs checked (default 200).
|
|
104
|
+
* @param {Function} [opts.fetchImpl] Injected fetch (for tests); defaults to node-fetch.
|
|
105
|
+
* @returns {Promise<{checked: number, truncated: boolean, results: Array<{
|
|
106
|
+
* url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}>}>}
|
|
107
|
+
*/
|
|
108
|
+
export async function checkLinks(urls, opts = {}) {
|
|
109
|
+
const {
|
|
110
|
+
allowedHosts = [],
|
|
111
|
+
internalHost = null,
|
|
112
|
+
maxConcurrency = DEFAULT_CONCURRENCY,
|
|
113
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
114
|
+
maxLinks = DEFAULT_MAX_LINKS,
|
|
115
|
+
fetchImpl = nodeFetch,
|
|
116
|
+
} = opts;
|
|
117
|
+
|
|
118
|
+
const allow = new Set(allowedHosts.map((h) => String(h).toLowerCase()));
|
|
119
|
+
if (internalHost) allow.add(String(internalHost).toLowerCase());
|
|
120
|
+
|
|
121
|
+
const unique = [...new Set(urls)];
|
|
122
|
+
const truncated = unique.length > maxLinks;
|
|
123
|
+
const queue = unique.slice(0, maxLinks);
|
|
124
|
+
|
|
125
|
+
const results = [];
|
|
126
|
+
let cursor = 0;
|
|
127
|
+
/**
|
|
128
|
+
* Worker: pull URLs off the shared queue until drained.
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
async function worker() {
|
|
132
|
+
while (cursor < queue.length) {
|
|
133
|
+
const url = queue[cursor++];
|
|
134
|
+
results.push(await checkOne(url, { allow, timeoutMs, fetchImpl }));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const workers = Array.from({ length: Math.max(1, Math.min(maxConcurrency, queue.length)) }, worker);
|
|
138
|
+
await Promise.all(workers);
|
|
139
|
+
|
|
140
|
+
return { checked: results.length, truncated, results };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check a single URL, applying the SSRF guard and host allowlist before any
|
|
145
|
+
* network call. Safe failures (blocked/not-allowlisted) are returned as
|
|
146
|
+
* `skipped`, not errors.
|
|
147
|
+
* @param {string} url URL to check.
|
|
148
|
+
* @param {{allow: Set<string>, timeoutMs: number, fetchImpl: Function}} ctx Shared context.
|
|
149
|
+
* @returns {Promise<{url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}>}
|
|
150
|
+
*/
|
|
151
|
+
async function checkOne(url, { allow, timeoutMs, fetchImpl }) {
|
|
152
|
+
const guard = isSafeUrl(url);
|
|
153
|
+
if (!guard.safe) {
|
|
154
|
+
return { url, ok: false, status: null, skipped: true, reason: guard.reason };
|
|
155
|
+
}
|
|
156
|
+
if (!allow.has(guard.host.toLowerCase())) {
|
|
157
|
+
return { url, ok: false, status: null, skipped: true, reason: "host not in allowlist" };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// HEAD first; some servers reject HEAD (405/501) so retry once with GET.
|
|
161
|
+
const head = await timedFetch(url, "HEAD", { timeoutMs, fetchImpl });
|
|
162
|
+
if (head.status && head.status !== 405 && head.status !== 501) {
|
|
163
|
+
return finalize(url, head);
|
|
164
|
+
}
|
|
165
|
+
const get = await timedFetch(url, "GET", { timeoutMs, fetchImpl });
|
|
166
|
+
return finalize(url, get);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Build the public result record from a fetch outcome. Treats 2xx/3xx as ok.
|
|
171
|
+
* @param {string} url URL checked.
|
|
172
|
+
* @param {{status: ?number, error: ?string}} res Fetch outcome.
|
|
173
|
+
* @returns {{url: string, ok: boolean, status: ?number, skipped: boolean, reason: ?string}}
|
|
174
|
+
*/
|
|
175
|
+
function finalize(url, res) {
|
|
176
|
+
if (res.error) return { url, ok: false, status: null, skipped: false, reason: res.error };
|
|
177
|
+
const ok = res.status >= 200 && res.status < 400;
|
|
178
|
+
return { url, ok, status: res.status, skipped: false, reason: ok ? null : `HTTP ${res.status}` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Perform a single fetch with an abort-based timeout. Never throws — network and
|
|
183
|
+
* timeout failures are returned as `{ status: null, error }`.
|
|
184
|
+
* @param {string} url URL to fetch.
|
|
185
|
+
* @param {"HEAD"|"GET"} method HTTP method.
|
|
186
|
+
* @param {{timeoutMs: number, fetchImpl: Function}} ctx Timeout + fetch impl.
|
|
187
|
+
* @returns {Promise<{status: ?number, error: ?string}>}
|
|
188
|
+
*/
|
|
189
|
+
async function timedFetch(url, method, { timeoutMs, fetchImpl }) {
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetchImpl(url, { method, redirect: "follow", signal: controller.signal });
|
|
194
|
+
return { status: res.status, error: null };
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const reason = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : (err?.message || "request failed");
|
|
197
|
+
return { status: null, error: reason };
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
}
|
package/src/lib/server-tools.js
CHANGED
|
@@ -309,6 +309,23 @@ export async function callServerTool(site, toolName, args = {}) {
|
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Extract the structured data a server tool returned, for callers (the audit
|
|
314
|
+
* tools) that need to inspect the payload rather than relay it. Prefers the MCP
|
|
315
|
+
* `structuredContent` field; otherwise parses the joined text content as JSON,
|
|
316
|
+
* falling back to the raw text when it isn't JSON.
|
|
317
|
+
* @param {object} result MCP tools/call result (as returned by callServerTool).
|
|
318
|
+
* @returns {*} Parsed structured data, raw text, or null when empty.
|
|
319
|
+
*/
|
|
320
|
+
export function toolResultData(result) {
|
|
321
|
+
if (!result) return null;
|
|
322
|
+
if (result.structuredContent !== undefined) return result.structuredContent;
|
|
323
|
+
const text = extractTextContent(result);
|
|
324
|
+
if (!text) return null;
|
|
325
|
+
try { return JSON.parse(text); }
|
|
326
|
+
catch { return text; }
|
|
327
|
+
}
|
|
328
|
+
|
|
312
329
|
/**
|
|
313
330
|
* Pull the concatenated text from an MCP tool result's content array.
|
|
314
331
|
* @param {object} result MCP tools/call result.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool group: Composite site-health audit.
|
|
3
|
+
*
|
|
4
|
+
* `drupal_audit_site_health` runs a configurable battery of the individual audit
|
|
5
|
+
* tools (content + links + config) and rolls their results into one scored
|
|
6
|
+
* dashboard. Each section runs independently: a section that throws or returns a
|
|
7
|
+
* gated/unavailable payload is recorded as such and does not abort the others, so
|
|
8
|
+
* the dashboard is always returned. The roll-up grade reflects only the sections
|
|
9
|
+
* that produced findings.
|
|
10
|
+
*
|
|
11
|
+
* This module composes the other tool groups' handlers rather than re-querying
|
|
12
|
+
* the backend, so its behavior always matches the standalone tools.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getSiteConfig } from "../lib/config.js";
|
|
16
|
+
import * as reports from "./reports.js";
|
|
17
|
+
import * as reportsLinks from "./reports-links.js";
|
|
18
|
+
import * as reportsConfig from "./reports-config.js";
|
|
19
|
+
import * as reportsContent from "./reports-content.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The audit battery. Each section names its group, the handler to run, the args
|
|
23
|
+
* to pass (derived from the composite's options), and a scorer that extracts an
|
|
24
|
+
* `{ issues, high }` signal from the section's result.
|
|
25
|
+
*/
|
|
26
|
+
const SECTIONS = [
|
|
27
|
+
// Content
|
|
28
|
+
{ key: "content_summary", group: "content", run: (o) => reports.handlers.drupal_report_content_summary({ site: o.site }), score: () => ({ issues: 0, high: 0 }) },
|
|
29
|
+
{ key: "stale_content", group: "content", run: (o) => reports.handlers.drupal_report_stale_content({ site: o.site, type: o.type, days: 180, limit: o.sampleSize }), score: (r) => ({ issues: (r.findings || r.nodes || []).length, high: 0 }) },
|
|
30
|
+
{ key: "seo_audit", group: "content", run: (o) => reports.handlers.drupal_report_seo_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
|
|
31
|
+
{ key: "accessibility", group: "content", run: (o) => reports.handlers.drupal_report_accessibility_audit({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: sumIssueCounts(r.issues), high: 0 }) },
|
|
32
|
+
{ key: "duplicate_content", group: "content", run: (o) => reportsContent.handlers.drupal_report_duplicate_content({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.duplicateGroups || 0, high: 0 }) },
|
|
33
|
+
{ key: "readability", group: "content", run: (o) => reportsContent.handlers.drupal_report_readability({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.hardToRead || []).length, high: 0 }) },
|
|
34
|
+
{ key: "pii_exposure", group: "content", run: (o) => reportsContent.handlers.drupal_report_pii_exposure({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.flaggedNodes || 0, high: r.flaggedNodes || 0 }) },
|
|
35
|
+
// Links
|
|
36
|
+
{ key: "broken_links", group: "links", run: (o) => reportsLinks.handlers.drupal_report_broken_links({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: r.summary?.malformed || 0, high: 0 }) },
|
|
37
|
+
{ key: "redirect_health", group: "links", run: (o) => reportsLinks.handlers.drupal_report_redirect_health({ site: o.site }), score: (r) => ({ issues: (r.summary?.duplicateSources || 0) + (r.summary?.chains || 0) + (r.summary?.loops || 0), high: r.summary?.loops || 0 }) },
|
|
38
|
+
{ key: "alias_coverage", group: "links", run: (o) => reportsLinks.handlers.drupal_report_alias_coverage({ site: o.site, type: o.type, sampleSize: o.sampleSize }), score: (r) => ({ issues: (r.totalMissingAlias || 0) + (r.aliasConflicts?.conflicting?.length || 0), high: 0 }) },
|
|
39
|
+
{ key: "log_404", group: "links", run: (o) => reportsLinks.handlers.drupal_report_404_log({ site: o.site }), score: (r) => ({ issues: (r.findings || []).length, high: 0 }) },
|
|
40
|
+
// Config
|
|
41
|
+
{ key: "config_best_practices", group: "config", run: (o) => reportsConfig.handlers.drupal_audit_config_best_practices({ site: o.site }), score: (r) => ({ issues: (r.counts?.high || 0) + (r.counts?.medium || 0) + (r.counts?.low || 0), high: r.counts?.high || 0 }) },
|
|
42
|
+
{ key: "module_audit", group: "config", run: (o) => reportsConfig.handlers.drupal_report_module_audit({ site: o.site }), score: (r) => ({ issues: (r.summary?.devModulesEnabled || 0) + (r.summary?.securityAdvisories || 0), high: r.summary?.securityAdvisories || 0 }) },
|
|
43
|
+
{ key: "permission_audit", group: "config", run: (o) => reportsConfig.handlers.drupal_report_permission_audit({ site: o.site }), score: (r) => ({ issues: (r.findings || []).length, high: r.counts?.high || 0 }) },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const SECTION_KEYS = SECTIONS.map((s) => s.key);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sum the `count` values out of an seo/accessibility audit's `issues` map.
|
|
50
|
+
* @param {object} issues The audit's keyed issues object.
|
|
51
|
+
* @returns {number} Total issue count.
|
|
52
|
+
*/
|
|
53
|
+
function sumIssueCounts(issues) {
|
|
54
|
+
if (!issues || typeof issues !== "object") return 0;
|
|
55
|
+
return Object.values(issues).reduce((sum, v) => sum + (v?.count || 0), 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a section result is a gated/unavailable payload.
|
|
60
|
+
* @param {object} r A section result.
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function isGated(r) {
|
|
64
|
+
return Boolean(r && (r.unavailable === true || r.gated === true));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Convert aggregate high/issue counts into a letter grade.
|
|
69
|
+
* @param {number} high Total high-severity findings.
|
|
70
|
+
* @param {number} issues Total findings.
|
|
71
|
+
* @returns {"A"|"B"|"C"|"D"|"F"} Roll-up grade.
|
|
72
|
+
*/
|
|
73
|
+
function grade(high, issues) {
|
|
74
|
+
if (high === 0 && issues === 0) return "A";
|
|
75
|
+
if (high === 0 && issues <= 5) return "B";
|
|
76
|
+
if (high <= 2) return "C";
|
|
77
|
+
if (high <= 5) return "D";
|
|
78
|
+
return "F";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run a battery of audits and roll the results into a scored dashboard.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} args - { site?, type?, sampleSize?, sections? }.
|
|
85
|
+
* @returns {Promise<object>} The dashboard: per-section status + a roll-up grade.
|
|
86
|
+
*/
|
|
87
|
+
async function siteHealth({ site: siteName, type = "article", sampleSize = 50, sections }) {
|
|
88
|
+
const site = getSiteConfig(siteName);
|
|
89
|
+
const selected = sections && sections.length
|
|
90
|
+
? SECTIONS.filter((s) => sections.includes(s.key))
|
|
91
|
+
: SECTIONS;
|
|
92
|
+
const opts = { site: site._name, type, sampleSize };
|
|
93
|
+
|
|
94
|
+
const results = [];
|
|
95
|
+
let totalHigh = 0;
|
|
96
|
+
let totalIssues = 0;
|
|
97
|
+
for (const section of selected) {
|
|
98
|
+
try {
|
|
99
|
+
const result = await section.run(opts);
|
|
100
|
+
if (isGated(result)) {
|
|
101
|
+
results.push({ key: section.key, group: section.group, status: "unavailable", reason: result.reason || result.attempts?.join("; ") || null });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const { issues, high } = section.score(result);
|
|
105
|
+
totalHigh += high;
|
|
106
|
+
totalIssues += issues;
|
|
107
|
+
results.push({ key: section.key, group: section.group, status: "ok", issues, high, result });
|
|
108
|
+
} catch (err) {
|
|
109
|
+
results.push({ key: section.key, group: section.group, status: "error", error: err?.message || String(err) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const byStatus = (s) => results.filter((r) => r.status === s).length;
|
|
114
|
+
return {
|
|
115
|
+
site: site._name,
|
|
116
|
+
type,
|
|
117
|
+
grade: grade(totalHigh, totalIssues),
|
|
118
|
+
summary: {
|
|
119
|
+
sectionsRun: results.length,
|
|
120
|
+
ok: byStatus("ok"),
|
|
121
|
+
unavailable: byStatus("unavailable"),
|
|
122
|
+
errored: byStatus("error"),
|
|
123
|
+
totalHigh,
|
|
124
|
+
totalIssues,
|
|
125
|
+
},
|
|
126
|
+
sections: results,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Definitions & handlers
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export const definitions = [
|
|
135
|
+
{
|
|
136
|
+
name: "drupal_audit_site_health",
|
|
137
|
+
description: "Composite site-health dashboard: runs a battery of content, link, and configuration audits and rolls them into one scored report with a letter grade. Each section degrades independently — gated/errored sections are recorded, not fatal. Read-only.",
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: "object",
|
|
140
|
+
properties: {
|
|
141
|
+
site: { type: "string" },
|
|
142
|
+
type: { type: "string", description: "Primary content type for content/link sections (default: article)" },
|
|
143
|
+
sampleSize: { type: "number", default: 50, description: "Per-section scan cap (kept small for a fast roll-up)" },
|
|
144
|
+
sections: { type: "array", items: { type: "string", enum: SECTION_KEYS }, description: `Subset of sections to run (default: all). Available: ${SECTION_KEYS.join(", ")}` },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
export const handlers = {
|
|
151
|
+
drupal_audit_site_health: siteHealth,
|
|
152
|
+
};
|
package/src/tools/drush.js
CHANGED
|
@@ -93,8 +93,12 @@ function resolveKeyPath(rawPath) {
|
|
|
93
93
|
*
|
|
94
94
|
* Security: args are individually shell-escaped via sanitizeSshArg().
|
|
95
95
|
* No raw user input is ever interpolated directly into the command string.
|
|
96
|
+
*
|
|
97
|
+
* Exported so the audit tool groups can reuse the hardened bridge for their
|
|
98
|
+
* drush fallback paths (e.g. config:status, pm:list, watchdog:show) without
|
|
99
|
+
* re-implementing SSH handling.
|
|
96
100
|
*/
|
|
97
|
-
function sshDrush(site, drushArgs, timeoutMs = 30000) {
|
|
101
|
+
export function sshDrush(site, drushArgs, timeoutMs = 30000) {
|
|
98
102
|
const sshCfg = getDrushConfig(site);
|
|
99
103
|
assertCommandAllowed(sshCfg, drushArgs[0]);
|
|
100
104
|
const keyPath = resolveKeyPath(sshCfg.keyPath);
|
|
@@ -110,7 +114,7 @@ function sshDrush(site, drushArgs, timeoutMs = 30000) {
|
|
|
110
114
|
const drushBin = `${drupalRoot}/vendor/bin/drush`;
|
|
111
115
|
const command = `cd ${sanitizeSshArg(drupalRoot)} && ${drushBin} ${escapedArgs} --yes`;
|
|
112
116
|
|
|
113
|
-
console.error(`[drush-bridge] ${site._name}: drush ${drushArgs
|
|
117
|
+
console.error(`[drush-bridge] ${site._name}: drush ${redactSecretArgs(drushArgs)}`);
|
|
114
118
|
|
|
115
119
|
return new Promise((resolve, reject) => {
|
|
116
120
|
const conn = new Client();
|
|
@@ -165,10 +169,28 @@ function sshDrush(site, drushArgs, timeoutMs = 30000) {
|
|
|
165
169
|
});
|
|
166
170
|
}
|
|
167
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Redact secret-bearing flag values from a Drush argument list for safe logging.
|
|
174
|
+
* `user:create` passes `--password=…`, and other admin flows may pass tokens/keys;
|
|
175
|
+
* the operational log must not echo them in clear text.
|
|
176
|
+
* @param {string[]} drushArgs The Drush subcommand + flags.
|
|
177
|
+
* @returns {string} A space-joined, secret-redacted rendering for the log line.
|
|
178
|
+
*/
|
|
179
|
+
export function redactSecretArgs(drushArgs) {
|
|
180
|
+
const SECRET_FLAG = /^(--(?:password|pass|token|secret|client[-_]?secret|api[-_]?key|key)=)(.*)$/i;
|
|
181
|
+
return drushArgs
|
|
182
|
+
.map((arg) => {
|
|
183
|
+
const m = String(arg).match(SECRET_FLAG);
|
|
184
|
+
return m ? `${m[1]}***` : arg;
|
|
185
|
+
})
|
|
186
|
+
.join(" ");
|
|
187
|
+
}
|
|
188
|
+
|
|
168
189
|
/**
|
|
169
190
|
* Parse Drush JSON output; fall back to raw string if not JSON.
|
|
191
|
+
* Exported for reuse by the audit tool groups' drush fallback paths.
|
|
170
192
|
*/
|
|
171
|
-
function parseDrush(raw) {
|
|
193
|
+
export function parseDrush(raw) {
|
|
172
194
|
if (!raw) return null;
|
|
173
195
|
try { return JSON.parse(raw); }
|
|
174
196
|
catch { return raw; }
|
package/src/tools/nodes.js
CHANGED
|
@@ -9,7 +9,118 @@
|
|
|
9
9
|
|
|
10
10
|
import { getSiteConfig } from "../lib/config.js";
|
|
11
11
|
import { resolveBackend } from "../lib/backends/index.js";
|
|
12
|
-
import { resolveSecurityConfig, redactCanonicalEntity } from "../lib/security.js";
|
|
12
|
+
import { resolveSecurityConfig, redactCanonicalEntity, assertWriteAllowed } from "../lib/security.js";
|
|
13
|
+
import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
|
|
14
|
+
|
|
15
|
+
/** Fallback language for an alias when the node exposes none. */
|
|
16
|
+
const DEFAULT_ALIAS_LANGCODE = "en";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalize a URL-alias path for storage/comparison: trim, ensure a single
|
|
20
|
+
* leading slash, drop a trailing slash (except root).
|
|
21
|
+
* @param {*} value A raw alias.
|
|
22
|
+
* @returns {?string} The normalized alias, or null when empty.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeAlias(value) {
|
|
25
|
+
if (value === undefined || value === null) return null;
|
|
26
|
+
let s = String(value).trim();
|
|
27
|
+
if (!s) return null;
|
|
28
|
+
if (!s.startsWith("/")) s = `/${s}`;
|
|
29
|
+
if (s.length > 1) s = s.replace(/\/+$/, "");
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the `path` attribute to send on an alias-aware node write so the alias
|
|
35
|
+
* actually persists, and decide whether a rename redirect is needed.
|
|
36
|
+
*
|
|
37
|
+
* The bug (DEV-116): JSON:API deserializes `{ alias, pathauto }` onto the node's
|
|
38
|
+
* `path` field, dropping the existing alias's `pid`; Drupal's `PathItem::postSave`
|
|
39
|
+
* then *creates a duplicate* `path_alias` (the older one stays canonical) instead
|
|
40
|
+
* of updating in place. The fix is to round-trip the existing `pid` so the update
|
|
41
|
+
* is in place. DEV-114's path-omitted "preserve" had the same defect (no `pid`),
|
|
42
|
+
* so it is fixed here too.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} args - { backend, type, id, providedPath, isCreate }.
|
|
45
|
+
* @returns {Promise<{pathAttr: (object|undefined), redirect: ?object}>}
|
|
46
|
+
* `pathAttr` is the `path` value to send (or undefined to omit, letting pathauto
|
|
47
|
+
* run on create); `redirect` is `{ from, to, nid }` when a rename redirect is due.
|
|
48
|
+
*/
|
|
49
|
+
async function resolvePathWrite({ backend, type, id, providedPath, isCreate }) {
|
|
50
|
+
const info = id
|
|
51
|
+
? await backend.getPathInfo({ entityType: "node", bundle: type, id }).catch(() => ({}))
|
|
52
|
+
: {};
|
|
53
|
+
const oldAlias = normalizeAlias(info.alias);
|
|
54
|
+
const langcode = info.langcode || DEFAULT_ALIAS_LANGCODE;
|
|
55
|
+
|
|
56
|
+
if (providedPath && typeof providedPath === "object") {
|
|
57
|
+
// Explicit alias set/replace → manual alias, round-trip the existing pid so
|
|
58
|
+
// Drupal updates in place rather than creating a duplicate.
|
|
59
|
+
if (providedPath.alias) {
|
|
60
|
+
const newAlias = normalizeAlias(providedPath.alias);
|
|
61
|
+
const pathAttr = { alias: newAlias, pathauto: false, langcode };
|
|
62
|
+
if (info.pid !== undefined && info.pid !== null) pathAttr.pid = info.pid;
|
|
63
|
+
const redirect = !isCreate && oldAlias && oldAlias !== newAlias
|
|
64
|
+
? { from: oldAlias, to: newAlias, nid: info.drupalId }
|
|
65
|
+
: null;
|
|
66
|
+
return { pathAttr, redirect };
|
|
67
|
+
}
|
|
68
|
+
// Caller passed `path` without an alias (e.g. re-enabling pathauto) — respect it as-is.
|
|
69
|
+
return { pathAttr: providedPath, redirect: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// No explicit path on update → preserve the current alias *with its pid* so the
|
|
73
|
+
// save can neither revert nor duplicate it.
|
|
74
|
+
if (!isCreate && oldAlias) {
|
|
75
|
+
const pathAttr = { alias: oldAlias, pathauto: false, langcode };
|
|
76
|
+
if (info.pid !== undefined && info.pid !== null) pathAttr.pid = info.pid;
|
|
77
|
+
return { pathAttr, redirect: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create without an explicit path → omit it so pathauto generates the alias.
|
|
81
|
+
return { pathAttr: undefined, redirect: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Best-effort create a 301 redirect from a node's old alias to the node after a
|
|
86
|
+
* rename, so the previous URL keeps resolving. Governed like any redirect write;
|
|
87
|
+
* never fails the node update (failures are reported, not thrown). Idempotent:
|
|
88
|
+
* skips when a redirect already exists for the source.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} backend Resolved backend.
|
|
91
|
+
* @param {object} sec Resolved security config.
|
|
92
|
+
* @param {{from: string, to: string, nid: ?(number|string)}} redirect
|
|
93
|
+
* @returns {Promise<object>} Outcome `{ created, ... }` for the response.
|
|
94
|
+
*/
|
|
95
|
+
async function createRenameRedirect(backend, sec, redirect) {
|
|
96
|
+
try {
|
|
97
|
+
assertWriteAllowed(sec, "create", REDIRECT_ENTITY_TYPE, REDIRECT_ENTITY_TYPE);
|
|
98
|
+
} catch {
|
|
99
|
+
return { created: false, reason: "redirect creation not permitted by policy", source: redirect.from };
|
|
100
|
+
}
|
|
101
|
+
// Prefer an alias-independent target so a future rename can't break the redirect.
|
|
102
|
+
const target = redirect.nid !== undefined && redirect.nid !== null
|
|
103
|
+
? `entity:node/${redirect.nid}`
|
|
104
|
+
: redirect.to;
|
|
105
|
+
const sourceStored = redirect.from.replace(/^\/+/, "");
|
|
106
|
+
try {
|
|
107
|
+
const existing = await backend.listEntities({
|
|
108
|
+
entityType: REDIRECT_ENTITY_TYPE, bundle: REDIRECT_ENTITY_TYPE,
|
|
109
|
+
filters: [{ field: "redirect_source.path", op: "eq", value: sourceStored }],
|
|
110
|
+
page: { limit: 1 },
|
|
111
|
+
}).catch(() => null);
|
|
112
|
+
if (existing?.entities?.length) {
|
|
113
|
+
return { created: false, reason: "redirect already exists", source: redirect.from };
|
|
114
|
+
}
|
|
115
|
+
await backend.createEntity({
|
|
116
|
+
entityType: REDIRECT_ENTITY_TYPE, bundle: REDIRECT_ENTITY_TYPE,
|
|
117
|
+
attributes: buildRedirectAttributes(redirect.from, target, 301),
|
|
118
|
+
});
|
|
119
|
+
return { created: true, source: redirect.from, target };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { created: false, reason: err?.message || String(err), source: redirect.from };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
13
124
|
|
|
14
125
|
/**
|
|
15
126
|
* Build a Drupal body field descriptor from plain HTML + optional summary.
|
|
@@ -119,7 +230,16 @@ async function createNode({ site: siteName, type, title, body, summary, status,
|
|
|
119
230
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
120
231
|
if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes };
|
|
121
232
|
const backend = await resolveBackend(site);
|
|
122
|
-
|
|
233
|
+
// Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
|
|
234
|
+
// `path` is omitted so pathauto generates the alias (DEV-116).
|
|
235
|
+
const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
|
|
236
|
+
if (pathAttr === undefined) delete attributes.path;
|
|
237
|
+
else attributes.path = pathAttr;
|
|
238
|
+
const created = await backend.createEntity({ entityType: "node", bundle: type, attributes });
|
|
239
|
+
// Honest response: re-read so the persisted alias (explicit or pathauto-generated)
|
|
240
|
+
// is reflected rather than the pre-alias write response.
|
|
241
|
+
const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
|
|
242
|
+
return fresh ?? created;
|
|
123
243
|
}
|
|
124
244
|
|
|
125
245
|
/**
|
|
@@ -149,13 +269,21 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
|
|
|
149
269
|
if (bodyAttr) attributes.body = bodyAttr;
|
|
150
270
|
if (dryRun) return { dryRun: true, operation: "update", entityType: "node", bundle: type, id, attributes };
|
|
151
271
|
const backend = await resolveBackend(site);
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
272
|
+
const sec = resolveSecurityConfig(site);
|
|
273
|
+
// Alias handling (DEV-116): an explicit `path.alias` is set in place by
|
|
274
|
+
// round-tripping the existing alias's pid (no duplicate); a path-less update
|
|
275
|
+
// re-pins the current alias *with its pid* so the save can't revert/duplicate
|
|
276
|
+
// it. A rename (alias changed) also gets a 301 redirect from the old path.
|
|
277
|
+
const { pathAttr, redirect } = await resolvePathWrite({ backend, type, id, providedPath: attributes.path, isCreate: false });
|
|
278
|
+
if (pathAttr === undefined) delete attributes.path;
|
|
279
|
+
else attributes.path = pathAttr;
|
|
280
|
+
await backend.updateEntity({ entityType: "node", bundle: type, id, attributes });
|
|
281
|
+
const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
|
|
282
|
+
// Honest response: re-read persisted state so the returned `url` is the alias
|
|
283
|
+
// that actually resolves, never the just-sent value.
|
|
284
|
+
const fresh = await backend.getEntity({ entityType: "node", bundle: type, id }).catch(() => null);
|
|
285
|
+
if (fresh && redirectResult) return { ...fresh, _redirect: redirectResult };
|
|
286
|
+
return fresh ?? { id };
|
|
159
287
|
}
|
|
160
288
|
|
|
161
289
|
/**
|
package/src/tools/redirects.js
CHANGED
|
@@ -83,6 +83,32 @@ function assertStatusCode(code) {
|
|
|
83
83
|
return code;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/** Entity type id for redirects, exported so side-effect callers can reuse it. */
|
|
87
|
+
export const REDIRECT_ENTITY_TYPE = REDIRECT_TYPE;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the JSON:API attribute map for a `redirect` entity from a plain
|
|
91
|
+
* source/target, applying the leading-slash and link-URI normalization that
|
|
92
|
+
* keeps a created redirect live. Exported so other tools (e.g. the node
|
|
93
|
+
* rename-redirect side-effect in nodes.js) create redirects identically.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} source Old path (leading slash optional; stripped).
|
|
96
|
+
* @param {string} target Destination path / `entity:node/ID` / absolute URL.
|
|
97
|
+
* @param {number} [statusCode] HTTP redirect code (default 301).
|
|
98
|
+
* @param {string} [language] Redirect language (default 'und').
|
|
99
|
+
* @returns {object} The redirect attribute map for backend.createEntity.
|
|
100
|
+
* @throws {Error} If the status code is unsupported.
|
|
101
|
+
*/
|
|
102
|
+
export function buildRedirectAttributes(source, target, statusCode = 301, language = "und") {
|
|
103
|
+
assertStatusCode(statusCode);
|
|
104
|
+
return {
|
|
105
|
+
redirect_source: { path: normalizeSource(source), query: null },
|
|
106
|
+
redirect_redirect: { uri: normalizeTargetUri(target) },
|
|
107
|
+
status_code: statusCode,
|
|
108
|
+
language,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
86
112
|
/**
|
|
87
113
|
* Create an active URL redirect.
|
|
88
114
|
*
|
|
@@ -98,18 +124,14 @@ function assertStatusCode(code) {
|
|
|
98
124
|
async function createRedirect({ site: siteName, source, target, statusCode = 301, language = "und" }) {
|
|
99
125
|
if (!source) throw new Error("A redirect 'source' path is required (e.g. '/old-path').");
|
|
100
126
|
if (!target) throw new Error("A redirect 'target' is required (a path, 'entity:node/ID', or an absolute URL).");
|
|
101
|
-
assertStatusCode(statusCode);
|
|
102
127
|
const site = getSiteConfig(siteName);
|
|
103
128
|
const sec = resolveSecurityConfig(site);
|
|
104
129
|
assertWriteAllowed(sec, "create", REDIRECT_TYPE, REDIRECT_TYPE);
|
|
105
130
|
const backend = await resolveBackend(site);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
language,
|
|
111
|
-
};
|
|
112
|
-
return backend.createEntity({ entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE, attributes });
|
|
131
|
+
return backend.createEntity({
|
|
132
|
+
entityType: REDIRECT_TYPE, bundle: REDIRECT_TYPE,
|
|
133
|
+
attributes: buildRedirectAttributes(source, target, statusCode, language),
|
|
134
|
+
});
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
/**
|