@pbpyrojust/universal-security-audit 0.0.0-stage → 0.3.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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +435 -2
  3. package/auth-config.example.json +12 -0
  4. package/bin/universal-security-audit.mjs +134 -0
  5. package/branding.example.json +10 -0
  6. package/package.json +60 -4
  7. package/scripts/compare-security-runs.mjs +94 -0
  8. package/scripts/generate-baseline.mjs +66 -0
  9. package/scripts/generate-security-report.mjs +99 -0
  10. package/scripts/generate-security-sarif.mjs +93 -0
  11. package/scripts/generate-security-tickets.mjs +126 -0
  12. package/scripts/lib/authorization.mjs +54 -0
  13. package/scripts/lib/baseline.mjs +37 -0
  14. package/scripts/lib/drupal-admin-audit.mjs +84 -0
  15. package/scripts/lib/exposed-paths.mjs +111 -0
  16. package/scripts/lib/fingerprint.mjs +154 -0
  17. package/scripts/lib/graphql-introspect.mjs +55 -0
  18. package/scripts/lib/infra-topology.mjs +71 -0
  19. package/scripts/lib/intensity.mjs +30 -0
  20. package/scripts/lib/misc-checks.mjs +78 -0
  21. package/scripts/lib/pentest-recon.mjs +220 -0
  22. package/scripts/lib/pii-payment.mjs +79 -0
  23. package/scripts/lib/port-scan.mjs +72 -0
  24. package/scripts/lib/proxy-support.mjs +14 -0
  25. package/scripts/lib/report-builder.mjs +113 -0
  26. package/scripts/lib/scoring.mjs +44 -0
  27. package/scripts/lib/script-inventory.mjs +131 -0
  28. package/scripts/lib/security-headers.mjs +115 -0
  29. package/scripts/lib/security-txt.mjs +69 -0
  30. package/scripts/lib/subdomain-enum.mjs +64 -0
  31. package/scripts/lib/tls-enum.mjs +49 -0
  32. package/scripts/lib/vuln-lookup.mjs +118 -0
  33. package/scripts/lib/whois-lookup.mjs +44 -0
  34. package/scripts/lib/wordfence.mjs +112 -0
  35. package/scripts/lib/wp-admin-audit.mjs +102 -0
  36. package/scripts/lib/wp-plugin-directory.mjs +58 -0
  37. package/scripts/lib/wp-rest-audit.mjs +59 -0
  38. package/scripts/run-batch-audit.mjs +111 -0
  39. package/scripts/run-security-audit.mjs +1192 -0
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@pbpyrojust/universal-security-audit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.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,54 @@
1
+ // ── Permission for the active (pentest-style) checks ──
2
+ // Passive checks only read what any visitor gets: the pages, response headers, TLS, DNS/WHOIS,
3
+ // the files and libraries the pages load, and public vulnerability databases. The active checks
4
+ // request what a visitor wouldn't (exposed config/backup paths, admin and login pages, GraphQL
5
+ // introspection, open-redirect and HTTP-method probes, WordPress user enumeration, error
6
+ // disclosure, subdomain liveness, a TCP port scan) or log in with credentials you supply. Those
7
+ // need the site owner's written permission, so they only run once it's confirmed, and the
8
+ // confirmation is written into the run's report.
9
+ import os from 'node:os';
10
+ import readline from 'node:readline/promises';
11
+
12
+ /** Bump the version whenever the wording changes; each report keeps the exact text confirmed. */
13
+ export const AUTHORIZATION_STATEMENT_VERSION = '2026-10-31';
14
+ export const AUTHORIZATION_STATEMENT =
15
+ 'I own this site, or I have the site owner\'s written permission to security-test it, including requests a normal visitor wouldn\'t make (admin, login and configuration paths, port and subdomain checks, redirect and method probes). I am responsible for having that permission. The tool and its authors accept no liability for how it is used.';
16
+
17
+ export const ACTIVE_CHECKS = [
18
+ 'exposed paths, admin/login pages and GraphQL introspection',
19
+ 'subdomain liveness',
20
+ 'TCP port scan',
21
+ 'recon probes (open redirect, HTTP methods, WordPress user enumeration, error disclosure)',
22
+ 'authenticated WordPress/Drupal admin audit',
23
+ ];
24
+
25
+ /**
26
+ * Resolves whether the active checks may run. `--authorized` (or `--i-have-permission`) confirms
27
+ * up front, for scripts and CI. Otherwise an interactive terminal is asked to type "yes"; anything
28
+ * else, or no terminal (CI, --json, batch-audit children), means passive checks only.
29
+ */
30
+ export async function resolveAuthorization(args, site, { interactive = process.stdin.isTTY && process.stdout.isTTY } = {}) {
31
+ const record = (how) => ({
32
+ confirmed: true,
33
+ how,
34
+ site,
35
+ statement: AUTHORIZATION_STATEMENT,
36
+ statementVersion: AUTHORIZATION_STATEMENT_VERSION,
37
+ at: new Date().toISOString(),
38
+ user: safeUser(),
39
+ });
40
+ if (args['authorized'] || args['i-have-permission']) return record('flag');
41
+ if (!interactive || args['json']) return null;
42
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
43
+ try {
44
+ process.stderr.write(`\n Active checks on ${site} need the site owner's written permission:\n "${AUTHORIZATION_STATEMENT}"\n`);
45
+ const answer = (await rl.question(' Type "yes" to confirm and run them, or press Enter for passive checks only: ')).trim().toLowerCase();
46
+ return answer === 'yes' ? record('prompt') : null;
47
+ } finally {
48
+ rl.close();
49
+ }
50
+ }
51
+
52
+ function safeUser() {
53
+ try { return os.userInfo().username; } catch { return null; }
54
+ }
@@ -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
+ }