@pbpyrojust/universal-security-audit 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +412 -2
- package/auth-config.example.json +12 -0
- package/bin/universal-security-audit.mjs +129 -0
- package/branding.example.json +10 -0
- package/package.json +60 -4
- package/scripts/compare-security-runs.mjs +94 -0
- package/scripts/generate-baseline.mjs +66 -0
- package/scripts/generate-security-report.mjs +99 -0
- package/scripts/generate-security-sarif.mjs +93 -0
- package/scripts/generate-security-tickets.mjs +126 -0
- package/scripts/lib/baseline.mjs +37 -0
- package/scripts/lib/drupal-admin-audit.mjs +84 -0
- package/scripts/lib/exposed-paths.mjs +111 -0
- package/scripts/lib/fingerprint.mjs +154 -0
- package/scripts/lib/graphql-introspect.mjs +55 -0
- package/scripts/lib/infra-topology.mjs +71 -0
- package/scripts/lib/intensity.mjs +30 -0
- package/scripts/lib/misc-checks.mjs +78 -0
- package/scripts/lib/pentest-recon.mjs +215 -0
- package/scripts/lib/pii-payment.mjs +79 -0
- package/scripts/lib/port-scan.mjs +72 -0
- package/scripts/lib/proxy-support.mjs +14 -0
- package/scripts/lib/report-builder.mjs +113 -0
- package/scripts/lib/scoring.mjs +44 -0
- package/scripts/lib/script-inventory.mjs +131 -0
- package/scripts/lib/security-headers.mjs +115 -0
- package/scripts/lib/security-txt.mjs +69 -0
- package/scripts/lib/subdomain-enum.mjs +64 -0
- package/scripts/lib/tls-enum.mjs +49 -0
- package/scripts/lib/vuln-lookup.mjs +118 -0
- package/scripts/lib/whois-lookup.mjs +44 -0
- package/scripts/lib/wp-admin-audit.mjs +102 -0
- package/scripts/lib/wp-plugin-directory.mjs +58 -0
- package/scripts/lib/wp-rest-audit.mjs +59 -0
- package/scripts/run-batch-audit.mjs +111 -0
- package/scripts/run-security-audit.mjs +1146 -0
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pbpyrojust/universal-security-audit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"packageManager": "pnpm@10.8.0",
|
|
6
|
+
"description": "Technical security & attack-surface audit CLI: security header grading, CMS/hosting fingerprinting, exposed-path & admin/API discovery, third-party script + CVE lookups (OSV.dev/WPScan), PII & payment-processor detection, and a risk-graded HTML/PDF report.",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"security-audit": "node scripts/run-security-audit.mjs",
|
|
9
|
+
"report": "node scripts/generate-security-report.mjs",
|
|
10
|
+
"tickets": "node scripts/generate-security-tickets.mjs",
|
|
11
|
+
"compare": "node scripts/compare-security-runs.mjs",
|
|
12
|
+
"sarif": "node scripts/generate-security-sarif.mjs",
|
|
13
|
+
"baseline": "node scripts/generate-baseline.mjs",
|
|
14
|
+
"batch-audit": "node scripts/run-batch-audit.mjs",
|
|
15
|
+
"cli": "node bin/universal-security-audit.mjs"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"universal-security-audit": "bin/universal-security-audit.mjs",
|
|
19
|
+
"usaudit": "bin/universal-security-audit.mjs"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"chrome-launcher": "^1.2.2",
|
|
23
|
+
"csv-stringify": "^6.9.0",
|
|
24
|
+
"playwright": "^1.64.1",
|
|
25
|
+
"undici": "^8.12.3"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "Justin Adams (https://justwhat.net)",
|
|
32
|
+
"homepage": "https://github.com/pbpyrojust/universal-security-audit#readme",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/pbpyrojust/universal-security-audit.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/pbpyrojust/universal-security-audit/issues"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"security",
|
|
42
|
+
"pentest",
|
|
43
|
+
"attack-surface",
|
|
44
|
+
"vulnerability-scanner",
|
|
45
|
+
"security-headers",
|
|
46
|
+
"wordpress",
|
|
47
|
+
"cve",
|
|
48
|
+
"osv",
|
|
49
|
+
"wpscan",
|
|
50
|
+
"cli",
|
|
51
|
+
"audit",
|
|
52
|
+
"playwright",
|
|
53
|
+
"graphql",
|
|
54
|
+
"sarif",
|
|
55
|
+
"drupal",
|
|
56
|
+
"tls",
|
|
57
|
+
"port-scan"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Diffs two audit runs' findings-summary.csv + risk grade, mirroring compare-seo-runs.mjs output shape.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
function parseArgs(argv) {
|
|
7
|
+
const args = {};
|
|
8
|
+
for (let i = 2; i < argv.length; i++) {
|
|
9
|
+
const a = argv[i];
|
|
10
|
+
if (!a.startsWith('--')) continue;
|
|
11
|
+
const key = a.replace(/^--/, '');
|
|
12
|
+
const next = argv[i + 1];
|
|
13
|
+
if (!next || next.startsWith('--')) args[key] = true;
|
|
14
|
+
else { args[key] = next; i++; }
|
|
15
|
+
}
|
|
16
|
+
return args;
|
|
17
|
+
}
|
|
18
|
+
function parseCsv(csvText) {
|
|
19
|
+
const rows = [];
|
|
20
|
+
let row = [], field = '', inQuotes = false;
|
|
21
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
22
|
+
const pushRow = () => { if (!(row.length === 1 && row[0] === '')) rows.push(row); row = []; };
|
|
23
|
+
for (let i = 0; i < csvText.length; i++) {
|
|
24
|
+
const ch = csvText[i];
|
|
25
|
+
if (inQuotes) {
|
|
26
|
+
if (ch === '"') { if (csvText[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; }
|
|
27
|
+
else field += ch;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (ch === '"') { inQuotes = true; continue; }
|
|
31
|
+
if (ch === ',') { pushField(); continue; }
|
|
32
|
+
if (ch === '\n') { pushField(); pushRow(); continue; }
|
|
33
|
+
if (ch === '\r') { if (csvText[i + 1] === '\n') i++; pushField(); pushRow(); continue; }
|
|
34
|
+
field += ch;
|
|
35
|
+
}
|
|
36
|
+
pushField(); pushRow();
|
|
37
|
+
if (!rows.length) return [];
|
|
38
|
+
const headers = rows[0];
|
|
39
|
+
return rows.slice(1).map((r) => Object.fromEntries(headers.map((h, idx) => [h, r[idx] ?? ''])));
|
|
40
|
+
}
|
|
41
|
+
function esc(s) { s = String(s ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
|
|
42
|
+
function writeCsv(rows, columns, outPath) {
|
|
43
|
+
const body = [columns.join(','), ...rows.map((r) => columns.map((c) => esc(r[c])).join(','))].join('\n') + '\n';
|
|
44
|
+
fs.writeFileSync(outPath, body, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const args = parseArgs(process.argv);
|
|
48
|
+
if (!args.before || !args.after) {
|
|
49
|
+
console.error('Usage: node scripts/compare-security-runs.mjs --before reports/run-a --after reports/run-b');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const beforeDir = path.resolve(process.cwd(), args.before);
|
|
53
|
+
const afterDir = path.resolve(process.cwd(), args.after);
|
|
54
|
+
const outDir = args['out-dir'] ? path.resolve(process.cwd(), args['out-dir']) : afterDir;
|
|
55
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
56
|
+
|
|
57
|
+
const beforeFindings = parseCsv(fs.readFileSync(path.join(beforeDir, 'findings-summary.csv'), 'utf8')).filter((f) => f.suppressed !== 'true');
|
|
58
|
+
const afterFindings = parseCsv(fs.readFileSync(path.join(afterDir, 'findings-summary.csv'), 'utf8')).filter((f) => f.suppressed !== 'true');
|
|
59
|
+
const key = (r) => `${r.category}__${r.title}__${r.url}`;
|
|
60
|
+
const beforeMap = new Map(beforeFindings.map((r) => [key(r), r]));
|
|
61
|
+
const afterMap = new Map(afterFindings.map((r) => [key(r), r]));
|
|
62
|
+
const newFindings = afterFindings.filter((r) => !beforeMap.has(key(r)));
|
|
63
|
+
const resolvedFindings = beforeFindings.filter((r) => !afterMap.has(key(r)));
|
|
64
|
+
const unchangedFindings = afterFindings.filter((r) => beforeMap.has(key(r)));
|
|
65
|
+
|
|
66
|
+
const countsBySeverity = (rows) => rows.reduce((acc, r) => { acc[r.severity] = (acc[r.severity] || 0) + 1; return acc; }, {});
|
|
67
|
+
const beforeSevCounts = countsBySeverity(beforeFindings);
|
|
68
|
+
const afterSevCounts = countsBySeverity(afterFindings);
|
|
69
|
+
const severities = ['critical', 'high', 'medium', 'low', 'info'];
|
|
70
|
+
const severitySummary = severities.map((sev) => ({ severity: sev, before_count: beforeSevCounts[sev] || 0, after_count: afterSevCounts[sev] || 0, delta: (afterSevCounts[sev] || 0) - (beforeSevCounts[sev] || 0) }));
|
|
71
|
+
|
|
72
|
+
let beforeRisk = null, afterRisk = null;
|
|
73
|
+
try { beforeRisk = JSON.parse(fs.readFileSync(path.join(beforeDir, 'summary.json'), 'utf8')).risk; } catch {}
|
|
74
|
+
try { afterRisk = JSON.parse(fs.readFileSync(path.join(afterDir, 'summary.json'), 'utf8')).risk; } catch {}
|
|
75
|
+
|
|
76
|
+
writeCsv(severitySummary, ['severity', 'before_count', 'after_count', 'delta'], path.join(outDir, 'security-compare-summary.csv'));
|
|
77
|
+
writeCsv(newFindings, Object.keys(newFindings[0] || { category: '', severity: '', title: '', detail: '', url: '' }), path.join(outDir, 'security-compare-new-findings.csv'));
|
|
78
|
+
writeCsv(resolvedFindings, Object.keys(resolvedFindings[0] || { category: '', severity: '', title: '', detail: '', url: '' }), path.join(outDir, 'security-compare-resolved-findings.csv'));
|
|
79
|
+
|
|
80
|
+
const md = [
|
|
81
|
+
'# Security Run Comparison', '',
|
|
82
|
+
`Before: ${beforeDir}`, `After: ${afterDir}`, '',
|
|
83
|
+
beforeRisk && afterRisk ? `Risk grade: ${beforeRisk.grade} (${beforeRisk.score}/100) -> ${afterRisk.grade} (${afterRisk.score}/100)` : '', '',
|
|
84
|
+
`New findings: ${newFindings.length}`, `Resolved findings: ${resolvedFindings.length}`, `Unchanged findings: ${unchangedFindings.length}`, '',
|
|
85
|
+
'## Severity deltas', ...severitySummary.map((r) => `- ${r.severity}: ${r.before_count} -> ${r.after_count} (${r.delta >= 0 ? '+' : ''}${r.delta})`), '',
|
|
86
|
+
'## New findings', ...newFindings.slice(0, 30).map((r) => `- [${r.severity}] ${r.category} — ${r.title} — ${r.url}`), '',
|
|
87
|
+
'## Resolved findings', ...resolvedFindings.slice(0, 30).map((r) => `- [${r.severity}] ${r.category} — ${r.title} — ${r.url}`),
|
|
88
|
+
].filter((l) => l !== undefined);
|
|
89
|
+
fs.writeFileSync(path.join(outDir, 'security-compare-summary.md'), md.join('\n'), 'utf8');
|
|
90
|
+
|
|
91
|
+
console.log(`Wrote: ${path.join(outDir, 'security-compare-summary.csv')}`);
|
|
92
|
+
console.log(`Wrote: ${path.join(outDir, 'security-compare-new-findings.csv')}`);
|
|
93
|
+
console.log(`Wrote: ${path.join(outDir, 'security-compare-resolved-findings.csv')}`);
|
|
94
|
+
console.log(`Wrote: ${path.join(outDir, 'security-compare-summary.md')}`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Snapshots every finding in a run into a baseline/exceptions file (accept-all-current), so future
|
|
3
|
+
// runs stop failing on already-known-and-accepted findings. Edit the output afterward to add real
|
|
4
|
+
// `reason` text and/or `expires` dates — this just gives you the starting fingerprints.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fingerprintFinding } from './lib/baseline.mjs';
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const args = {};
|
|
11
|
+
for (let i = 2; i < argv.length; i++) {
|
|
12
|
+
const a = argv[i];
|
|
13
|
+
if (!a.startsWith('--')) continue;
|
|
14
|
+
const key = a.replace(/^--/, '');
|
|
15
|
+
const next = argv[i + 1];
|
|
16
|
+
if (!next || next.startsWith('--')) args[key] = true;
|
|
17
|
+
else { args[key] = next; i++; }
|
|
18
|
+
}
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
function parseCsv(csvText) {
|
|
22
|
+
const rows = [];
|
|
23
|
+
let row = [], field = '', inQuotes = false;
|
|
24
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
25
|
+
const pushRow = () => { if (!(row.length === 1 && row[0] === '')) rows.push(row); row = []; };
|
|
26
|
+
for (let i = 0; i < csvText.length; i++) {
|
|
27
|
+
const ch = csvText[i];
|
|
28
|
+
if (inQuotes) {
|
|
29
|
+
if (ch === '"') { if (csvText[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; }
|
|
30
|
+
else field += ch;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (ch === '"') { inQuotes = true; continue; }
|
|
34
|
+
if (ch === ',') { pushField(); continue; }
|
|
35
|
+
if (ch === '\n') { pushField(); pushRow(); continue; }
|
|
36
|
+
if (ch === '\r') { if (csvText[i + 1] === '\n') i++; pushField(); pushRow(); continue; }
|
|
37
|
+
field += ch;
|
|
38
|
+
}
|
|
39
|
+
pushField(); pushRow();
|
|
40
|
+
if (!rows.length) return [];
|
|
41
|
+
const headers = rows[0];
|
|
42
|
+
return rows.slice(1).map((r) => Object.fromEntries(headers.map((h, idx) => [h, r[idx] ?? ''])));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const args = parseArgs(process.argv);
|
|
46
|
+
if (!args['run-dir']) { console.error('Missing --run-dir'); process.exit(1); }
|
|
47
|
+
const runDir = path.resolve(process.cwd(), args['run-dir']);
|
|
48
|
+
const findings = parseCsv(fs.readFileSync(path.join(runDir, 'findings-summary.csv'), 'utf8'));
|
|
49
|
+
|
|
50
|
+
let existing = { accepted: [] };
|
|
51
|
+
const outPath = args.out ? path.resolve(process.cwd(), args.out) : path.resolve(process.cwd(), 'security-baseline.json');
|
|
52
|
+
if (fs.existsSync(outPath) && !args.overwrite) {
|
|
53
|
+
try { existing = JSON.parse(fs.readFileSync(outPath, 'utf8')); } catch {}
|
|
54
|
+
}
|
|
55
|
+
const existingFingerprints = new Set((existing.accepted || []).map((e) => e.fingerprint));
|
|
56
|
+
|
|
57
|
+
const newEntries = [];
|
|
58
|
+
for (const f of findings) {
|
|
59
|
+
const fingerprint = fingerprintFinding(f);
|
|
60
|
+
if (existingFingerprints.has(fingerprint)) continue;
|
|
61
|
+
newEntries.push({ fingerprint, category: f.category, title: f.title, url: f.url, reason: args.reason || 'Accepted at baseline snapshot time', expires: args.expires || null });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const merged = { accepted: [...(args.overwrite ? [] : existing.accepted || []), ...newEntries] };
|
|
65
|
+
fs.writeFileSync(outPath, JSON.stringify(merged, null, 2), 'utf8');
|
|
66
|
+
console.log(`Wrote: ${outPath} (${newEntries.length} new exception(s), ${merged.accepted.length} total)`);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Standalone report regenerator — rebuilds security-dashboard.html/.pdf from an existing run's
|
|
3
|
+
// summary.json + CSVs without re-scanning the site. Useful for re-branding a past run or fixing
|
|
4
|
+
// a report after a dashboard template change. Mirrors universal-seo-audit's
|
|
5
|
+
// generate-visual-report.mjs --run-dir convention.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { chromium } from 'playwright';
|
|
9
|
+
import { loadBranding, buildSecurityDashboardHtml } from './lib/report-builder.mjs';
|
|
10
|
+
import { sortFindingsBySeverity, computeRiskGrade } from './lib/scoring.mjs';
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const args = {};
|
|
14
|
+
for (let i = 2; i < argv.length; i++) {
|
|
15
|
+
const a = argv[i];
|
|
16
|
+
if (!a.startsWith('--')) continue;
|
|
17
|
+
const key = a.replace(/^--/, '');
|
|
18
|
+
const next = argv[i + 1];
|
|
19
|
+
if (!next || next.startsWith('--')) args[key] = true;
|
|
20
|
+
else { args[key] = next; i++; }
|
|
21
|
+
}
|
|
22
|
+
return args;
|
|
23
|
+
}
|
|
24
|
+
function parseCsv(csvText) {
|
|
25
|
+
const rows = [];
|
|
26
|
+
let row = [], field = '', inQuotes = false;
|
|
27
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
28
|
+
const pushRow = () => { if (!(row.length === 1 && row[0] === '')) rows.push(row); row = []; };
|
|
29
|
+
for (let i = 0; i < csvText.length; i++) {
|
|
30
|
+
const ch = csvText[i];
|
|
31
|
+
if (inQuotes) {
|
|
32
|
+
if (ch === '"') { if (csvText[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; }
|
|
33
|
+
else field += ch;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (ch === '"') { inQuotes = true; continue; }
|
|
37
|
+
if (ch === ',') { pushField(); continue; }
|
|
38
|
+
if (ch === '\n') { pushField(); pushRow(); continue; }
|
|
39
|
+
if (ch === '\r') { if (csvText[i + 1] === '\n') i++; pushField(); pushRow(); continue; }
|
|
40
|
+
field += ch;
|
|
41
|
+
}
|
|
42
|
+
pushField(); pushRow();
|
|
43
|
+
if (!rows.length) return [];
|
|
44
|
+
const headers = rows[0];
|
|
45
|
+
return rows.slice(1).map((r) => Object.fromEntries(headers.map((h, idx) => [h, r[idx] ?? ''])));
|
|
46
|
+
}
|
|
47
|
+
function readCsvIfExists(filePath) {
|
|
48
|
+
try { return parseCsv(fs.readFileSync(filePath, 'utf8')); } catch { return []; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const args = parseArgs(process.argv);
|
|
52
|
+
if (!args['run-dir']) { console.error('Missing --run-dir'); process.exit(1); }
|
|
53
|
+
const runDir = path.resolve(process.cwd(), args['run-dir']);
|
|
54
|
+
|
|
55
|
+
const summary = JSON.parse(fs.readFileSync(path.join(runDir, 'summary.json'), 'utf8'));
|
|
56
|
+
const findingsRows = readCsvIfExists(path.join(runDir, 'findings-summary.csv'));
|
|
57
|
+
const libraries = readCsvIfExists(path.join(runDir, 'script-inventory.csv'));
|
|
58
|
+
const vulnRows = readCsvIfExists(path.join(runDir, 'vulnerabilities.csv'));
|
|
59
|
+
|
|
60
|
+
const vulnByComponent = new Map();
|
|
61
|
+
for (const v of vulnRows) {
|
|
62
|
+
const key = `${v.component}__${v.source}`;
|
|
63
|
+
if (!vulnByComponent.has(key)) vulnByComponent.set(key, { component: v.component, source: v.source, vulns: [] });
|
|
64
|
+
vulnByComponent.get(key).vulns.push({ id: v.id, title: v.title });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sorted = sortFindingsBySeverity(findingsRows.map((f) => ({ ...f, suppressed: f.suppressed === 'true' })));
|
|
68
|
+
const risk = summary.risk || computeRiskGrade(findingsRows.filter((f) => f.suppressed !== 'true'));
|
|
69
|
+
|
|
70
|
+
const branding = loadBranding(fs, path, args['brand-config']);
|
|
71
|
+
const site = args.site || summary.site;
|
|
72
|
+
|
|
73
|
+
const html = buildSecurityDashboardHtml({
|
|
74
|
+
site,
|
|
75
|
+
risk,
|
|
76
|
+
sorted,
|
|
77
|
+
primaryPlatform: summary.primaryPlatform,
|
|
78
|
+
platformSignals: summary.platformSignals || [{ platform: summary.primaryPlatform, confidence: 'n/a' }],
|
|
79
|
+
hostingSignals: summary.hostingSignals || [],
|
|
80
|
+
tlsInfo: summary.tls || { ok: false },
|
|
81
|
+
dnsRecords: summary.dnsRecords || {},
|
|
82
|
+
libraries,
|
|
83
|
+
vulnResults: [...vulnByComponent.values()],
|
|
84
|
+
crawlerExposure: summary.crawlerExposure || {},
|
|
85
|
+
paymentProcessors: summary.paymentProcessors || [],
|
|
86
|
+
generatedAt: summary.generatedAt,
|
|
87
|
+
}, branding);
|
|
88
|
+
|
|
89
|
+
const htmlPath = path.join(runDir, 'security-dashboard.html');
|
|
90
|
+
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
91
|
+
console.log(`Wrote: ${htmlPath}`);
|
|
92
|
+
|
|
93
|
+
const browser = await chromium.launch({ headless: true });
|
|
94
|
+
const page = await browser.newPage();
|
|
95
|
+
await page.goto('file://' + htmlPath);
|
|
96
|
+
const pdfPath = path.join(runDir, 'security-dashboard.pdf');
|
|
97
|
+
await page.pdf({ path: pdfPath, format: 'A4', printBackground: true, margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' } });
|
|
98
|
+
await browser.close();
|
|
99
|
+
console.log(`Wrote: ${pdfPath}`);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Converts a run's findings-summary.csv into SARIF 2.1.0 for GitHub code scanning (Security tab).
|
|
3
|
+
// Locations use the finding's URL as the artifact URI — the same convention tools like OWASP ZAP's
|
|
4
|
+
// SARIF exporter use for web-security findings that aren't tied to a file in the repo.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const args = {};
|
|
10
|
+
for (let i = 2; i < argv.length; i++) {
|
|
11
|
+
const a = argv[i];
|
|
12
|
+
if (!a.startsWith('--')) continue;
|
|
13
|
+
const key = a.replace(/^--/, '');
|
|
14
|
+
const next = argv[i + 1];
|
|
15
|
+
if (!next || next.startsWith('--')) args[key] = true;
|
|
16
|
+
else { args[key] = next; i++; }
|
|
17
|
+
}
|
|
18
|
+
return args;
|
|
19
|
+
}
|
|
20
|
+
function parseCsv(csvText) {
|
|
21
|
+
const rows = [];
|
|
22
|
+
let row = [], field = '', inQuotes = false;
|
|
23
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
24
|
+
const pushRow = () => { if (!(row.length === 1 && row[0] === '')) rows.push(row); row = []; };
|
|
25
|
+
for (let i = 0; i < csvText.length; i++) {
|
|
26
|
+
const ch = csvText[i];
|
|
27
|
+
if (inQuotes) {
|
|
28
|
+
if (ch === '"') { if (csvText[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; }
|
|
29
|
+
else field += ch;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '"') { inQuotes = true; continue; }
|
|
33
|
+
if (ch === ',') { pushField(); continue; }
|
|
34
|
+
if (ch === '\n') { pushField(); pushRow(); continue; }
|
|
35
|
+
if (ch === '\r') { if (csvText[i + 1] === '\n') i++; pushField(); pushRow(); continue; }
|
|
36
|
+
field += ch;
|
|
37
|
+
}
|
|
38
|
+
pushField(); pushRow();
|
|
39
|
+
if (!rows.length) return [];
|
|
40
|
+
const headers = rows[0];
|
|
41
|
+
return rows.slice(1).map((r) => Object.fromEntries(headers.map((h, idx) => [h, r[idx] ?? ''])));
|
|
42
|
+
}
|
|
43
|
+
function slugify(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80); }
|
|
44
|
+
|
|
45
|
+
const SARIF_LEVEL = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'note' };
|
|
46
|
+
const SECURITY_SEVERITY = { critical: '9.0', high: '7.0', medium: '5.0', low: '3.0', info: '1.0' };
|
|
47
|
+
|
|
48
|
+
const args = parseArgs(process.argv);
|
|
49
|
+
if (!args['run-dir']) { console.error('Missing --run-dir'); process.exit(1); }
|
|
50
|
+
const runDir = path.resolve(process.cwd(), args['run-dir']);
|
|
51
|
+
const findings = parseCsv(fs.readFileSync(path.join(runDir, 'findings-summary.csv'), 'utf8')).filter((f) => f.suppressed !== 'true');
|
|
52
|
+
|
|
53
|
+
const rulesById = new Map();
|
|
54
|
+
const results = [];
|
|
55
|
+
for (const f of findings) {
|
|
56
|
+
const ruleId = `${slugify(f.category)}--${slugify(f.title)}`;
|
|
57
|
+
if (!rulesById.has(ruleId)) {
|
|
58
|
+
rulesById.set(ruleId, {
|
|
59
|
+
id: ruleId,
|
|
60
|
+
name: f.title,
|
|
61
|
+
shortDescription: { text: f.title },
|
|
62
|
+
fullDescription: { text: f.detail || f.title },
|
|
63
|
+
properties: { 'security-severity': SECURITY_SEVERITY[f.severity] || '1.0', tags: ['security', f.category] },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const uri = f.url || 'about:blank';
|
|
67
|
+
results.push({
|
|
68
|
+
ruleId,
|
|
69
|
+
level: SARIF_LEVEL[f.severity] || 'note',
|
|
70
|
+
message: { text: f.detail || f.title },
|
|
71
|
+
locations: [{ physicalLocation: { artifactLocation: { uri } } }],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sarif = {
|
|
76
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
77
|
+
version: '2.1.0',
|
|
78
|
+
runs: [{
|
|
79
|
+
tool: {
|
|
80
|
+
driver: {
|
|
81
|
+
name: 'universal-security-audit',
|
|
82
|
+
informationUri: 'https://github.com/pbpyrojust/universal-security-audit',
|
|
83
|
+
version: '0.1.0',
|
|
84
|
+
rules: [...rulesById.values()],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
results,
|
|
88
|
+
}],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const outPath = args.out ? path.resolve(process.cwd(), args.out) : path.join(runDir, 'security-findings.sarif');
|
|
92
|
+
fs.writeFileSync(outPath, JSON.stringify(sarif, null, 2), 'utf8');
|
|
93
|
+
console.log(`Wrote: ${outPath} (${results.length} result(s), ${rulesById.size} rule(s))`);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Turns findings-summary.csv into a ticket-ready backlog CSV, mirroring universal-seo-audit's
|
|
3
|
+
// generate-seo-tickets.mjs conventions (global vs. per-URL grouping, ticket_title/description/labels).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
function parseArgs(argv) {
|
|
8
|
+
const args = {};
|
|
9
|
+
for (let i = 2; i < argv.length; i++) {
|
|
10
|
+
const a = argv[i];
|
|
11
|
+
if (!a.startsWith('--')) continue;
|
|
12
|
+
const key = a.replace(/^--/, '');
|
|
13
|
+
const next = argv[i + 1];
|
|
14
|
+
if (!next || next.startsWith('--')) args[key] = true;
|
|
15
|
+
else { args[key] = next; i++; }
|
|
16
|
+
}
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
function parseCsv(csvText) {
|
|
20
|
+
const rows = [];
|
|
21
|
+
let row = [], field = '', inQuotes = false;
|
|
22
|
+
const pushField = () => { row.push(field); field = ''; };
|
|
23
|
+
const pushRow = () => { if (!(row.length === 1 && row[0] === '')) rows.push(row); row = []; };
|
|
24
|
+
for (let i = 0; i < csvText.length; i++) {
|
|
25
|
+
const ch = csvText[i];
|
|
26
|
+
if (inQuotes) {
|
|
27
|
+
if (ch === '"') { if (csvText[i + 1] === '"') { field += '"'; i++; } else inQuotes = false; }
|
|
28
|
+
else field += ch;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (ch === '"') { inQuotes = true; continue; }
|
|
32
|
+
if (ch === ',') { pushField(); continue; }
|
|
33
|
+
if (ch === '\n') { pushField(); pushRow(); continue; }
|
|
34
|
+
if (ch === '\r') { if (csvText[i + 1] === '\n') i++; pushField(); pushRow(); continue; }
|
|
35
|
+
field += ch;
|
|
36
|
+
}
|
|
37
|
+
pushField(); pushRow();
|
|
38
|
+
if (!rows.length) return [];
|
|
39
|
+
const headers = rows[0];
|
|
40
|
+
return rows.slice(1).map((r) => Object.fromEntries(headers.map((h, idx) => [h, r[idx] ?? ''])));
|
|
41
|
+
}
|
|
42
|
+
function esc(s) { s = String(s ?? ''); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
|
|
43
|
+
function writeCsv(rows, columns, outPath) {
|
|
44
|
+
const body = [columns.join(','), ...rows.map((r) => columns.map((c) => esc(r[c])).join(','))].join('\n') + '\n';
|
|
45
|
+
fs.writeFileSync(outPath, body, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Categories whose findings apply to the whole site (one root cause, dedupe across pages) vs.
|
|
49
|
+
// categories that are inherently per-page/per-URL and should stay as separate tickets.
|
|
50
|
+
const GLOBAL_CATEGORIES = new Set([
|
|
51
|
+
'headers', 'cookies', 'tls', 'cors', 'crawler-exposure', 'subdomain-takeover',
|
|
52
|
+
'http-methods', 'open-redirect', 'source-map', 'email-auth', 'user-enum',
|
|
53
|
+
'verbose-errors', 'cve', 'admin-login', 'exposed-path', 'exposed-api', 'payment',
|
|
54
|
+
]);
|
|
55
|
+
const SEVERITY_PRIORITY = { critical: 'P0', high: 'P1', medium: 'P2', low: 'P3', info: 'P4' };
|
|
56
|
+
|
|
57
|
+
const args = parseArgs(process.argv);
|
|
58
|
+
const runDir = args['run-dir'];
|
|
59
|
+
if (!runDir) { console.error('ERROR: Missing --run-dir'); process.exit(1); }
|
|
60
|
+
const findings = parseCsv(fs.readFileSync(path.join(runDir, 'findings-summary.csv'), 'utf8')).filter((f) => f.suppressed !== 'true');
|
|
61
|
+
|
|
62
|
+
const globals = new Map();
|
|
63
|
+
const perUrl = [];
|
|
64
|
+
for (const f of findings) {
|
|
65
|
+
if (GLOBAL_CATEGORIES.has(f.category)) {
|
|
66
|
+
const key = `${f.category}__${f.title}__${f.severity}`;
|
|
67
|
+
const item = globals.get(key) || { ...f, urls: new Set(), occurrences: 0 };
|
|
68
|
+
item.urls.add(f.url);
|
|
69
|
+
item.occurrences += 1;
|
|
70
|
+
globals.set(key, item);
|
|
71
|
+
} else {
|
|
72
|
+
perUrl.push(f);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rows = [];
|
|
77
|
+
for (const item of globals.values()) {
|
|
78
|
+
const exampleUrls = [...item.urls].slice(0, 5).join(' | ');
|
|
79
|
+
const priority = SEVERITY_PRIORITY[item.severity] || 'P4';
|
|
80
|
+
rows.push({
|
|
81
|
+
ticket_type: 'Global',
|
|
82
|
+
category: item.category,
|
|
83
|
+
severity: item.severity,
|
|
84
|
+
priority,
|
|
85
|
+
pages_affected: item.urls.size,
|
|
86
|
+
occurrences: item.occurrences,
|
|
87
|
+
example_urls: exampleUrls,
|
|
88
|
+
ticket_title: `[SEC][${priority}] ${item.title}`,
|
|
89
|
+
ticket_description: [
|
|
90
|
+
`Category: ${item.category}`,
|
|
91
|
+
`Severity: ${item.severity}`,
|
|
92
|
+
`Detail: ${item.detail}`,
|
|
93
|
+
`URLs affected: ${item.urls.size}`,
|
|
94
|
+
`Occurrences: ${item.occurrences}`,
|
|
95
|
+
`Example URL(s): ${exampleUrls}`,
|
|
96
|
+
'',
|
|
97
|
+
'Recommended action: fix at the shared/infra level (server config, template, or plugin) rather than per-page, then re-run the audit to confirm resolution.',
|
|
98
|
+
].join('\n'),
|
|
99
|
+
ticket_labels: `security, ${item.category}, priority:${priority.toLowerCase()}, global`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const f of perUrl) {
|
|
103
|
+
const priority = SEVERITY_PRIORITY[f.severity] || 'P4';
|
|
104
|
+
rows.push({
|
|
105
|
+
ticket_type: 'Page',
|
|
106
|
+
category: f.category,
|
|
107
|
+
severity: f.severity,
|
|
108
|
+
priority,
|
|
109
|
+
pages_affected: 1,
|
|
110
|
+
occurrences: 1,
|
|
111
|
+
example_urls: f.url,
|
|
112
|
+
ticket_title: `[SEC][${priority}] ${f.title} — ${f.url}`,
|
|
113
|
+
ticket_description: [
|
|
114
|
+
`Category: ${f.category}`,
|
|
115
|
+
`Severity: ${f.severity}`,
|
|
116
|
+
`Detail: ${f.detail}`,
|
|
117
|
+
`URL: ${f.url}`,
|
|
118
|
+
].join('\n'),
|
|
119
|
+
ticket_labels: `security, ${f.category}, priority:${priority.toLowerCase()}, page`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
rows.sort((a, b) => a.priority.localeCompare(b.priority));
|
|
124
|
+
const outPath = path.join(runDir, 'security-ticket-backlog.csv');
|
|
125
|
+
writeCsv(rows, ['ticket_type', 'category', 'severity', 'priority', 'pages_affected', 'occurrences', 'example_urls', 'ticket_title', 'ticket_description', 'ticket_labels'], outPath);
|
|
126
|
+
console.log(`Wrote: ${outPath} (${rows.length} ticket${rows.length === 1 ? '' : 's'})`);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// ── Baseline/exceptions: known-accepted findings (by a stable fingerprint of category+title+url)
|
|
2
|
+
// are suppressed from risk scoring and CI gating on future runs, but stay visible in the report
|
|
3
|
+
// marked as "suppressed" rather than silently vanishing. Same idea as .trivyignore / gitleaks
|
|
4
|
+
// baselines — accept a risk once with a reason, stop it from re-failing every run. ──
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export function fingerprintFinding(f) {
|
|
8
|
+
const key = `${f.category}|${f.title}|${f.url}`;
|
|
9
|
+
return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadBaseline(fs, path, baselinePath) {
|
|
13
|
+
if (!baselinePath) return { entries: [], accepted: new Map() };
|
|
14
|
+
let data;
|
|
15
|
+
try {
|
|
16
|
+
data = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), baselinePath), 'utf8'));
|
|
17
|
+
} catch (e) {
|
|
18
|
+
throw new Error(`Could not read --baseline file: ${String(e?.message || e)}`);
|
|
19
|
+
}
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
const accepted = new Map();
|
|
22
|
+
for (const entry of data.accepted || []) {
|
|
23
|
+
if (entry.expires && new Date(entry.expires).getTime() < now) continue; // expired exceptions stop applying
|
|
24
|
+
accepted.set(entry.fingerprint, entry);
|
|
25
|
+
}
|
|
26
|
+
return { entries: data.accepted || [], accepted };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Returns every finding with `suppressed` + `suppressedReason` set, so callers can filter for
|
|
30
|
+
// scoring/CI purposes while still writing the full (marked) set to CSV/JSON/dashboard.
|
|
31
|
+
export function applyBaseline(findings, baseline) {
|
|
32
|
+
return findings.map((f) => {
|
|
33
|
+
const fp = fingerprintFinding(f);
|
|
34
|
+
const entry = baseline.accepted.get(fp);
|
|
35
|
+
return { ...f, fingerprint: fp, suppressed: !!entry, suppressedReason: entry?.reason || null };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// ── Authenticated Drupal admin audit. Requires the caller to already be logged in (via
|
|
2
|
+
// maybePerformFormLogin against /user/login). Drupal's admin theme/markup varies more across
|
|
3
|
+
// versions/installs than WordPress's, so parsing here leans on visible text (innerText) and Drupal
|
|
4
|
+
// core's default Views field classes rather than brittle theme-specific selectors — best-effort,
|
|
5
|
+
// same "just read what a logged-in admin can already see" philosophy as the WP audit. ──
|
|
6
|
+
|
|
7
|
+
export async function isLoggedIntoDrupalAdmin(page, origin) {
|
|
8
|
+
try {
|
|
9
|
+
const res = await page.goto(new URL('/user', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
10
|
+
const url = page.url();
|
|
11
|
+
return !/\/user\/login/i.test(url) && (res?.status?.() ?? 200) < 400;
|
|
12
|
+
} catch { return false; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function scanDrupalStatusReport(page, origin) {
|
|
16
|
+
const result = { drupalVersion: null, phpVersion: null, dbVersion: null, errorCount: 0, warningCount: 0, errorItems: [] };
|
|
17
|
+
try {
|
|
18
|
+
await page.goto(new URL('/admin/reports/status', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
19
|
+
const text = await page.evaluate(() => document.body.innerText);
|
|
20
|
+
const drupalM = /Drupal (?:Version|core version)\s*\n?\s*([\d.]+)/i.exec(text);
|
|
21
|
+
if (drupalM) result.drupalVersion = drupalM[1];
|
|
22
|
+
const phpM = /\bPHP\b\s*\n?\s*([\d.]+)/i.exec(text);
|
|
23
|
+
if (phpM) result.phpVersion = phpM[1];
|
|
24
|
+
const dbM = /Database\s*\n?\s*(MySQL|MariaDB|PostgreSQL|SQLite)?\s*([\d.]+)/i.exec(text);
|
|
25
|
+
if (dbM) result.dbVersion = [dbM[1], dbM[2]].filter(Boolean).join(' ');
|
|
26
|
+
result.errorCount = (text.match(/\bError\b/g) || []).length;
|
|
27
|
+
result.warningCount = (text.match(/\bWarning\b/g) || []).length;
|
|
28
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
29
|
+
result.errorItems = lines.filter((l) => /^Error\b/.test(l)).slice(0, 20);
|
|
30
|
+
} catch {}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function scanDrupalModules(page, origin) {
|
|
35
|
+
try {
|
|
36
|
+
await page.goto(new URL('/admin/modules', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
37
|
+
return await page.$$eval('input[type=checkbox][name^="modules["]', (checkboxes) => checkboxes.map((cb) => {
|
|
38
|
+
const nameMatch = /^modules\[([^\]]+)\]/.exec(cb.getAttribute('name') || '');
|
|
39
|
+
const machineName = nameMatch ? nameMatch[1] : '';
|
|
40
|
+
const row = cb.closest('tr') || cb.closest('.form-item')?.parentElement || cb.parentElement;
|
|
41
|
+
const rowText = row ? row.innerText : '';
|
|
42
|
+
const versionMatch = /(\d+\.x-\d+\.[\d.]+|\d+\.\d+\.\d+)/.exec(rowText);
|
|
43
|
+
const nameEl = row?.querySelector('label, .module-name, td strong, strong');
|
|
44
|
+
return {
|
|
45
|
+
slug: machineName,
|
|
46
|
+
name: nameEl ? nameEl.textContent.trim().split('\n')[0] : machineName,
|
|
47
|
+
enabled: cb.checked,
|
|
48
|
+
version: versionMatch ? versionMatch[1] : null,
|
|
49
|
+
};
|
|
50
|
+
}).filter((m) => m.slug));
|
|
51
|
+
} catch { return []; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function scanDrupalUsers(page, origin) {
|
|
55
|
+
try {
|
|
56
|
+
await page.goto(new URL('/admin/people', origin).toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
57
|
+
return await page.$$eval('tr', (rows) => rows.map((row) => {
|
|
58
|
+
const nameEl = row.querySelector('.views-field-name a, td.views-field-name');
|
|
59
|
+
const rolesEl = row.querySelector('.views-field-roles');
|
|
60
|
+
const statusEl = row.querySelector('.views-field-status');
|
|
61
|
+
if (!nameEl) return null;
|
|
62
|
+
return {
|
|
63
|
+
username: nameEl.textContent.trim(),
|
|
64
|
+
roles: rolesEl ? rolesEl.textContent.trim().replace(/\s+/g, ' ') : '',
|
|
65
|
+
status: statusEl ? statusEl.textContent.trim() : '',
|
|
66
|
+
};
|
|
67
|
+
}).filter(Boolean));
|
|
68
|
+
} catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EOL_PHP_MINORS = new Set(['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1']);
|
|
72
|
+
export function checkPhpEol(phpVersion) {
|
|
73
|
+
if (!phpVersion) return null;
|
|
74
|
+
const minor = (phpVersion.match(/^(\d+\.\d+)/) || [])[1];
|
|
75
|
+
return minor ? EOL_PHP_MINORS.has(minor) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Drupal core major versions no longer receiving security coverage (checked against the major.minor).
|
|
79
|
+
const EOL_DRUPAL_MAJORS = new Set(['6', '7', '8', '9']);
|
|
80
|
+
export function checkDrupalCoreEol(drupalVersion) {
|
|
81
|
+
if (!drupalVersion) return null;
|
|
82
|
+
const major = (drupalVersion.match(/^(\d+)/) || [])[1];
|
|
83
|
+
return major ? EOL_DRUPAL_MAJORS.has(major) : null;
|
|
84
|
+
}
|