nsauditor-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +24 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/nsauditor-ai-mcp.mjs +2 -0
- package/bin/nsauditor-ai.mjs +2 -0
- package/cli.mjs +939 -0
- package/config/services.json +304 -0
- package/docs/EULA-nsauditor-ai.md +324 -0
- package/index.mjs +15 -0
- package/mcp_server.mjs +382 -0
- package/package.json +44 -0
- package/plugin_manager.mjs +829 -0
- package/plugins/arp_scanner.mjs +162 -0
- package/plugins/db_scanner.mjs +248 -0
- package/plugins/dns_scanner.mjs +369 -0
- package/plugins/dnssd-scanner.mjs +245 -0
- package/plugins/ftp_banner_check.mjs +247 -0
- package/plugins/host_up_check.mjs +337 -0
- package/plugins/http_probe.mjs +290 -0
- package/plugins/llmnr_scanner.mjs +130 -0
- package/plugins/mdns_scanner.mjs +522 -0
- package/plugins/netbios_scanner.mjs +737 -0
- package/plugins/opensearch_scanner.mjs +276 -0
- package/plugins/os_detector.mjs +436 -0
- package/plugins/ping_checker.mjs +271 -0
- package/plugins/port_scanner.mjs +250 -0
- package/plugins/result_concluder.mjs +274 -0
- package/plugins/snmp_scanner.mjs +278 -0
- package/plugins/ssh_scanner.mjs +421 -0
- package/plugins/sunrpc_scanner.mjs +339 -0
- package/plugins/syn_scanner.mjs +314 -0
- package/plugins/tls_scanner.mjs +225 -0
- package/plugins/upnp_scanner.mjs +441 -0
- package/plugins/webapp_detector.mjs +246 -0
- package/plugins/wsd_scanner.mjs +290 -0
- package/utils/attack_map.mjs +180 -0
- package/utils/capabilities.mjs +53 -0
- package/utils/conclusion_utils.mjs +70 -0
- package/utils/cpe.mjs +74 -0
- package/utils/cve_validator.mjs +64 -0
- package/utils/cvss.mjs +129 -0
- package/utils/delta_reporter.mjs +110 -0
- package/utils/export_csv.mjs +82 -0
- package/utils/finding_queue.mjs +64 -0
- package/utils/finding_schema.mjs +36 -0
- package/utils/host_iterator.mjs +166 -0
- package/utils/license.mjs +29 -0
- package/utils/net_validation.mjs +66 -0
- package/utils/nvd_cache.mjs +77 -0
- package/utils/nvd_client.mjs +130 -0
- package/utils/oui.mjs +107 -0
- package/utils/plugin_discovery.mjs +89 -0
- package/utils/prompts.mjs +143 -0
- package/utils/raw_report_html.mjs +170 -0
- package/utils/redact.mjs +79 -0
- package/utils/report_html.mjs +236 -0
- package/utils/sarif.mjs +225 -0
- package/utils/scan_history.mjs +248 -0
- package/utils/scheduler.mjs +157 -0
- package/utils/webhook.mjs +177 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// utils/report_html.mjs
|
|
2
|
+
// HTML report builder for AI conclusion text (markdown -> styled HTML with badges & safe CVE/URL links)
|
|
3
|
+
|
|
4
|
+
import { attackUrl } from './attack_map.mjs';
|
|
5
|
+
|
|
6
|
+
/** Escape characters that can break HTML attribute values. */
|
|
7
|
+
function escAttr(s) {
|
|
8
|
+
return String(s)
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/"/g, '"')
|
|
11
|
+
.replace(/'/g, ''')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function buildHtmlReport({ host, whenIso, model, md }) {
|
|
17
|
+
// --- 0) Pre-sanitize: remove any literal or *escaped* anchors from the model's markdown ----
|
|
18
|
+
// We keep only the visible text (URL/CVE token), then we'll linkify cleanly later.
|
|
19
|
+
|
|
20
|
+
const stripEscapedAnchors = (s) =>
|
|
21
|
+
String(s || '').replace(/<a\b[\s\S]*?>([\s\S]*?)<\/a>/gi, '$1');
|
|
22
|
+
|
|
23
|
+
const stripLiteralAnchors = (s) =>
|
|
24
|
+
String(s || '').replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1');
|
|
25
|
+
|
|
26
|
+
// Collapse odd NVD fragments the model sometimes emits inside table cells, e.g.:
|
|
27
|
+
// https://nvd.nist.gov/vuln/detail/CVE-2023-2450" target="_blank" … >CVE-2023-2450
|
|
28
|
+
// Reduce to just "CVE-2023-2450" so we can linkify later.
|
|
29
|
+
const collapseWeirdNvd = (s) =>
|
|
30
|
+
String(s || '').replace(
|
|
31
|
+
/https?:\/\/nvd\.nist\.gov\/vuln\/detail\/(CVE-\d{4}-\d{4,7})["'][^>\n]*?>\s*\1/gi,
|
|
32
|
+
'$1'
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const mdSanitized =
|
|
36
|
+
stripLiteralAnchors(
|
|
37
|
+
stripEscapedAnchors(
|
|
38
|
+
collapseWeirdNvd(md)
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// --- 0b) CVE validation markers → display tokens ---------------------------
|
|
43
|
+
// {{CVE_VERIFIED:CVE-XXXX-XXXXX}} → CVE-XXXX-XXXXX (linkifier handles normally)
|
|
44
|
+
// {{CVE_UNVERIFIED:CVE-XXXX-XXXXX}} → ⚠CVE-XXXX-XXXXX (linkifier renders as warning)
|
|
45
|
+
const expandCveMarkers = (s) =>
|
|
46
|
+
String(s || '')
|
|
47
|
+
.replace(/\{\{CVE_VERIFIED:(CVE-\d{4}-\d{4,7})\}\}/g, '$1')
|
|
48
|
+
.replace(/\{\{CVE_UNVERIFIED:(CVE-\d{4}-\d{4,7})\}\}/g, '\u26A0$1');
|
|
49
|
+
|
|
50
|
+
const mdClean = expandCveMarkers(mdSanitized);
|
|
51
|
+
|
|
52
|
+
// --- 1) Markdown -> HTML ----------------------------------------------------
|
|
53
|
+
let render;
|
|
54
|
+
try {
|
|
55
|
+
const { default: MarkdownIt } = await import('markdown-it');
|
|
56
|
+
const mdIt = new MarkdownIt({ html: false, linkify: true, breaks: false, typographer: true });
|
|
57
|
+
render = (s) => mdIt.render(String(s || ''));
|
|
58
|
+
} catch {
|
|
59
|
+
const escHtml = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
60
|
+
render = (s) =>
|
|
61
|
+
escHtml(String(s || ''))
|
|
62
|
+
.replace(/^###\s+(.*)$/gim, '<h3>$1</h3>')
|
|
63
|
+
.replace(/^##\s+(.*)$/gim, '<h2>$1</h2>')
|
|
64
|
+
.replace(/^#\s+(.*)$/gim, '<h1>$1</h1>')
|
|
65
|
+
.replace(/^[-*]\s+(.*)$/gim, '<ul><li>$1</li></ul>')
|
|
66
|
+
.replace(/\n{2,}/g, '</p><p>')
|
|
67
|
+
.replace(/^/, '<p>') + '</p>';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let body = render(mdClean);
|
|
71
|
+
|
|
72
|
+
// --- 2) Defensive cleanup in the produced HTML -----------------------------
|
|
73
|
+
// If any *broken* anchors survived (e.g., href contains an encoded or literal <a …>):
|
|
74
|
+
// strip such <a>…</a> tags entirely, keeping only their visible inner text.
|
|
75
|
+
const stripBrokenAnchorsInHtml = (html) =>
|
|
76
|
+
html.replace(/<a\b[^>]*href="[^"]*(?:<|<)a[^"]*"[^>]*>([\s\S]*?)<\/a>/gi, '$1');
|
|
77
|
+
|
|
78
|
+
// Also trim a dangling quote that sometimes ends up after </a>
|
|
79
|
+
const stripDanglingQuoteAfterAnchor = (html) =>
|
|
80
|
+
html.replace(/<\/a>"/g, '</a>');
|
|
81
|
+
|
|
82
|
+
body = stripBrokenAnchorsInHtml(body);
|
|
83
|
+
body = stripDanglingQuoteAfterAnchor(body);
|
|
84
|
+
|
|
85
|
+
// --- 3) CVE/URL linkification (text nodes only) -----------------------------
|
|
86
|
+
// A) Normalize already-linked CVEs that (correctly) point to NVD (strip junk attributes):
|
|
87
|
+
const normalizeExistingNvdAnchors = (html) =>
|
|
88
|
+
html.replace(
|
|
89
|
+
/<a[^>]*href=["']https?:\/\/nvd\.nist\.gov\/vuln\/detail\/(CVE-\d{4}-\d{4,7})["'][^>]*>(?:\1|CVE-\d{4}-\d{4,7})<\/a>/gi,
|
|
90
|
+
(_m, id) => `<a href="https://nvd.nist.gov/vuln/detail/${id}" target="_blank" rel="noopener noreferrer">${id}</a>`
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// B1) Linkify unverified CVEs (⚠CVE-XXXX-XXXXX) — must run BEFORE bare CVE linkification:
|
|
94
|
+
const linkifyUnverifiedCves = (html) =>
|
|
95
|
+
html.replace(/(>[^<]*?)\u26A0(CVE-\d{4}-\d{4,7})/g, (_m, pre, id) =>
|
|
96
|
+
`${pre}<span class="cve-unverified" title="This CVE could not be verified in NVD">${id} <sup>unverified</sup></span>`
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// B2) Linkify CVE tokens *only inside text nodes* (avoid attributes and already-handled unverified CVEs):
|
|
100
|
+
const linkifyBareCveTokens = (html) =>
|
|
101
|
+
html.replace(/(>[^<]*?)\b(CVE-\d{4}-\d{4,7})\b/g, (m, pre, id, offset) => {
|
|
102
|
+
// Skip if this CVE is already inside a cve-unverified span.
|
|
103
|
+
// offset points to the '>' char that starts the match, so include it in the lookbehind.
|
|
104
|
+
const context = html.slice(Math.max(0, offset - 200), offset + 1);
|
|
105
|
+
if (/<span[^>]*class="cve-unverified"[^>]*>[^<]*$/.test(context)) return m;
|
|
106
|
+
return `${pre}<a class="cve-verified" href="https://nvd.nist.gov/vuln/detail/${id}" target="_blank" rel="noopener noreferrer">${id}</a>`;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// C) Linkify bare URLs *only inside text nodes* (avoid attributes):
|
|
110
|
+
const linkifyBareUrls = (html) =>
|
|
111
|
+
html.replace(/(>[^<]*?)\b(https?:\/\/[^\s<>"')]+)\b/g, (_m, pre, u) => {
|
|
112
|
+
if (!/^https?:\/\//i.test(u)) return `${pre}${u}`;
|
|
113
|
+
const safeU = escAttr(u);
|
|
114
|
+
return `${pre}<a href="${safeU}" target="_blank" rel="noopener noreferrer">${safeU}</a>`;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// D) As a final safety net, if there are any plaintext escaped-anchors in HTML (shouldn’t be),
|
|
118
|
+
// remove them to their inner text again, then re-linkify.
|
|
119
|
+
const stripEscapedAnchorsInHtml = (html) =>
|
|
120
|
+
html.replace(/<a\b[\s\S]*?>([\s\S]*?)<\/a>/gi, '$1');
|
|
121
|
+
|
|
122
|
+
// E) Linkify ATT&CK technique IDs (T1234 or T1234.001) in text nodes:
|
|
123
|
+
const linkifyAttackTechniques = (html) =>
|
|
124
|
+
html.replace(/(>[^<]*?)\b(T\d{4}(?:\.\d{3})?)\b/g, (_m, pre, tid) => {
|
|
125
|
+
const url = attackUrl(tid);
|
|
126
|
+
return `${pre}<a class="attack-badge" href="${url}" target="_blank" rel="noopener">${tid}</a>`;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
body = normalizeExistingNvdAnchors(body);
|
|
130
|
+
body = stripEscapedAnchorsInHtml(body);
|
|
131
|
+
body = linkifyUnverifiedCves(body);
|
|
132
|
+
body = linkifyBareCveTokens(body);
|
|
133
|
+
body = linkifyBareUrls(body);
|
|
134
|
+
body = linkifyAttackTechniques(body);
|
|
135
|
+
|
|
136
|
+
// --- 4) SERVER-SIDE Priority badge injection -------------------------------
|
|
137
|
+
// Your tests look for the badge span in the final HTML. We decorate here too (in addition to client-side).
|
|
138
|
+
const decoratePriorityServer = (html) => {
|
|
139
|
+
const rx = /(\b(?:<strong>|<b>)?\s*Priority\s*(?:<\/strong>|<\/b>)?\s*:\s*)(Critical|High|Medium|Low)\b/gi;
|
|
140
|
+
return html.replace(rx, (_, pre, lvl) => {
|
|
141
|
+
const key = String(lvl || '').toLowerCase();
|
|
142
|
+
const cap = key.charAt(0).toUpperCase() + key.slice(1);
|
|
143
|
+
return `${pre}<span class="badge badge-${key}">${cap}</span>`;
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
body = decoratePriorityServer(body);
|
|
147
|
+
|
|
148
|
+
// Simple esc for header fields
|
|
149
|
+
const esc = (s) =>
|
|
150
|
+
String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
151
|
+
|
|
152
|
+
// --- 5) Final HTML (client-side: Severity/Priority badges) ------------------
|
|
153
|
+
return `<!doctype html>
|
|
154
|
+
<html lang="en">
|
|
155
|
+
<meta charset="utf-8">
|
|
156
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
157
|
+
<title>Network Audit – ${esc(host)}</title>
|
|
158
|
+
<style>
|
|
159
|
+
:root{
|
|
160
|
+
--bg:#0b0f14; --card:#121823; --text:#e7eef7; --muted:#a9b6c6;
|
|
161
|
+
--border:#1f2937; --link:#60a5fa;
|
|
162
|
+
--crit:#b71c1c; --high:#d32f2f; --med:#f57c00; --low:#388e3c;
|
|
163
|
+
--crit-bg:#2b0f12; --high-bg:#311014; --med-bg:#2a1b0a; --low-bg:#0f2014;
|
|
164
|
+
}
|
|
165
|
+
*{box-sizing:border-box}
|
|
166
|
+
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 system-ui,Segoe UI,Roboto,Ubuntu,sans-serif}
|
|
167
|
+
.main{max-width:980px;margin:24px auto;padding:24px;background:var(--card);border:1px solid var(--border);border-radius:14px}
|
|
168
|
+
h1,h2,h3{margin:1.2em 0 .6em}
|
|
169
|
+
h1{font-size:26px} h2{font-size:20px} h3{font-size:16px}
|
|
170
|
+
p,li{color:var(--text)}
|
|
171
|
+
a{color:var(--link);text-decoration:none} a:hover{text-decoration:underline}
|
|
172
|
+
code,kbd,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
173
|
+
table{width:100%;border-collapse:collapse;margin:14px 0;border:1px solid var(--border)}
|
|
174
|
+
th,td{border:1px solid var(--border);padding:10px 12px;vertical-align:top}
|
|
175
|
+
th{background:#0e1420;color:#cfe0ff;text-align:left}
|
|
176
|
+
tbody tr:nth-child(odd){background:#0f1522}
|
|
177
|
+
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-weight:600;border:1px solid transparent;white-space:nowrap}
|
|
178
|
+
.badge-critical{background:var(--crit-bg);border-color:var(--crit);color:#ffb4b4}
|
|
179
|
+
.badge-high{background:var(--high-bg);border-color:var(--high);color:#ffc3c3}
|
|
180
|
+
.badge-medium{background:var(--med-bg);border-color:var(--med);color:#ffd8a6}
|
|
181
|
+
.badge-low{background:var(--low-bg);border-color:var(--low);color:#b9f6ca}
|
|
182
|
+
.cve-verified{color:#4caf50;font-weight:600}
|
|
183
|
+
.cve-unverified{color:#f44336;font-weight:600;text-decoration:line-through;text-decoration-style:wavy}
|
|
184
|
+
.cve-unverified sup{font-size:10px;color:#f44336;margin-left:2px}
|
|
185
|
+
.attack-badge{display:inline-block;padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#1a1a2e;border:1px solid #4a4a8a;color:#a0a0ff;white-space:nowrap;text-decoration:none}
|
|
186
|
+
.attack-badge:hover{background:#2a2a4e;border-color:#6a6aaa;color:#c0c0ff;text-decoration:none}
|
|
187
|
+
.meta{color:var(--muted);font-size:12px;margin-top:2px}
|
|
188
|
+
.header{display:flex;justify-content:space-between;align-items:center;border-bottom:1px dashed var(--border);padding-bottom:12px;margin-bottom:18px}
|
|
189
|
+
</style>
|
|
190
|
+
<body>
|
|
191
|
+
<div class="main">
|
|
192
|
+
<div class="header">
|
|
193
|
+
<div>
|
|
194
|
+
<h1>Network Audit – ${esc(host)}</h1>
|
|
195
|
+
<div class="meta">Generated: ${esc(whenIso)}</div>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="meta">Model: ${esc(model)}</div>
|
|
198
|
+
</div>
|
|
199
|
+
${body}
|
|
200
|
+
</div>
|
|
201
|
+
<script>
|
|
202
|
+
(function(){
|
|
203
|
+
const sevMap = { 'critical':'critical','high':'high','medium':'medium','med':'medium','low':'low' };
|
|
204
|
+
function titleCase(s){ return s.charAt(0).toUpperCase() + s.slice(1); }
|
|
205
|
+
|
|
206
|
+
// Severity badges in any table with a "Severity" header
|
|
207
|
+
document.querySelectorAll('table').forEach(tbl => {
|
|
208
|
+
const ths = Array.from(tbl.querySelectorAll('thead th'));
|
|
209
|
+
const idx = ths.findIndex(th => /\\bSeverity\\b/i.test(th.textContent || ''));
|
|
210
|
+
if (idx >= 0) {
|
|
211
|
+
tbl.querySelectorAll('tbody tr').forEach(tr => {
|
|
212
|
+
const td = tr.children[idx];
|
|
213
|
+
if (!td) return;
|
|
214
|
+
const raw = (td.textContent || '').trim();
|
|
215
|
+
const key = sevMap[raw.toLowerCase()];
|
|
216
|
+
if (key) td.innerHTML = '<span class="badge badge-' + key + '">' + titleCase(key) + '</span>';
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Priority badges anywhere ("Priority: X") — robust across p, li, td, th (handles bold labels)
|
|
222
|
+
const re = /(\\b(?:<strong>|<b>)?\\s*Priority\\s*(?:<\\/strong>|<\\/b>)?\\s*:\\s*)(Critical|High|Medium|Low)\\b/i;
|
|
223
|
+
document.querySelectorAll('p,li,td,th').forEach(node => {
|
|
224
|
+
const txt = (node.textContent || '').trim();
|
|
225
|
+
if (!/Priority\\s*:/i.test(txt)) return;
|
|
226
|
+
if (node.querySelector('.badge')) return; // already server-side injected
|
|
227
|
+
node.innerHTML = node.innerHTML.replace(re, function(_, pre, lvl){
|
|
228
|
+
const key = (lvl||'').toLowerCase();
|
|
229
|
+
return pre + '<span class="badge badge-' + key + '">' + (lvl.charAt(0).toUpperCase() + lvl.slice(1).toLowerCase()) + '</span>';
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
})();
|
|
233
|
+
</script>
|
|
234
|
+
</body>
|
|
235
|
+
</html>`;
|
|
236
|
+
}
|
package/utils/sarif.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// utils/sarif.mjs
|
|
2
|
+
// Generate SARIF 2.1.0 output from nsauditor scan results.
|
|
3
|
+
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { version: TOOL_VERSION } = require('../package.json');
|
|
7
|
+
|
|
8
|
+
const SARIF_VERSION = '2.1.0';
|
|
9
|
+
const SARIF_SCHEMA = 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json';
|
|
10
|
+
const TOOL_NAME = 'nsauditor';
|
|
11
|
+
const TOOL_URI = 'https://github.com/nsasoft/nsauditor-plugin-manager';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Map nsauditor severity to SARIF level.
|
|
15
|
+
* @param {string} severity - 'Critical', 'High', 'Medium', 'Low', 'Info'
|
|
16
|
+
* @returns {'error'|'warning'|'note'}
|
|
17
|
+
*/
|
|
18
|
+
export function severityToLevel(severity) {
|
|
19
|
+
const s = String(severity || '').toLowerCase();
|
|
20
|
+
if (s === 'critical' || s === 'high') return 'error';
|
|
21
|
+
if (s === 'medium') return 'warning';
|
|
22
|
+
return 'note';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build a rule ID from a service record.
|
|
27
|
+
* @param {object} svc
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function ruleIdFromService(svc) {
|
|
31
|
+
const program = svc.program && svc.program !== 'Unknown' ? svc.program : svc.service;
|
|
32
|
+
const version = svc.version && svc.version !== 'Unknown' ? svc.version : null;
|
|
33
|
+
const base = String(program || 'unknown').toLowerCase().replace(/\s+/g, '-');
|
|
34
|
+
return version ? `${base}:${version}` : base;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Infer severity from a service's status and other indicators.
|
|
39
|
+
* @param {object} svc
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function inferServiceSeverity(svc) {
|
|
43
|
+
if (svc.status === 'open') return 'Medium';
|
|
44
|
+
return 'Info';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build message text from a service record.
|
|
49
|
+
* @param {object} svc
|
|
50
|
+
* @param {string} host
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function buildServiceMessage(svc, host) {
|
|
54
|
+
const parts = [];
|
|
55
|
+
parts.push(`Service ${svc.service || 'unknown'} detected on ${host}:${svc.port}/${svc.protocol || 'tcp'}`);
|
|
56
|
+
if (svc.program && svc.program !== 'Unknown') parts.push(`Program: ${svc.program}`);
|
|
57
|
+
if (svc.version && svc.version !== 'Unknown') parts.push(`Version: ${svc.version}`);
|
|
58
|
+
if (svc.status) parts.push(`Status: ${svc.status}`);
|
|
59
|
+
if (svc.info) parts.push(`Info: ${svc.info}`);
|
|
60
|
+
if (svc.banner) parts.push(`Banner: ${svc.banner}`);
|
|
61
|
+
return parts.join('. ');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build SARIF result entries for security findings on a service.
|
|
66
|
+
* @param {object} svc
|
|
67
|
+
* @param {string} host
|
|
68
|
+
* @returns {{ results: object[], rules: object[] }}
|
|
69
|
+
*/
|
|
70
|
+
function securityFindingsFromService(svc, host) {
|
|
71
|
+
const results = [];
|
|
72
|
+
const rules = [];
|
|
73
|
+
|
|
74
|
+
const makeResult = (ruleId, level, message) => ({
|
|
75
|
+
ruleId,
|
|
76
|
+
level,
|
|
77
|
+
message: { text: message },
|
|
78
|
+
locations: [{
|
|
79
|
+
physicalLocation: {
|
|
80
|
+
artifactLocation: { uri: host }
|
|
81
|
+
}
|
|
82
|
+
}]
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// anonymousLogin: true
|
|
86
|
+
if (svc.anonymousLogin === true) {
|
|
87
|
+
const ruleId = 'ftp-anonymous-login';
|
|
88
|
+
rules.push({
|
|
89
|
+
id: ruleId,
|
|
90
|
+
shortDescription: { text: 'FTP anonymous login enabled' },
|
|
91
|
+
helpUri: `${TOOL_URI}`,
|
|
92
|
+
properties: { severity: 'Critical' }
|
|
93
|
+
});
|
|
94
|
+
results.push(makeResult(ruleId, 'error',
|
|
95
|
+
`FTP anonymous login is enabled on ${host}:${svc.port}. This allows unauthenticated access to the FTP server.`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// axfrAllowed: true
|
|
99
|
+
if (svc.axfrAllowed === true) {
|
|
100
|
+
const ruleId = 'dns-zone-transfer';
|
|
101
|
+
rules.push({
|
|
102
|
+
id: ruleId,
|
|
103
|
+
shortDescription: { text: 'DNS zone transfer (AXFR) allowed' },
|
|
104
|
+
helpUri: `${TOOL_URI}`,
|
|
105
|
+
properties: { severity: 'Critical' }
|
|
106
|
+
});
|
|
107
|
+
results.push(makeResult(ruleId, 'error',
|
|
108
|
+
`DNS zone transfer (AXFR) is allowed on ${host}:${svc.port}. This can expose the entire DNS zone to attackers.`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// weakAlgorithms: [...]
|
|
112
|
+
if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) {
|
|
113
|
+
for (const algo of svc.weakAlgorithms) {
|
|
114
|
+
const algoName = typeof algo === 'string' ? algo : (algo?.algorithm || algo?.name || String(algo));
|
|
115
|
+
const ruleId = `weak-algorithm-${algoName.replace(/[^a-zA-Z0-9._-]/g, '-')}`;
|
|
116
|
+
rules.push({
|
|
117
|
+
id: ruleId,
|
|
118
|
+
shortDescription: { text: `Weak algorithm: ${algoName}` },
|
|
119
|
+
helpUri: `${TOOL_URI}`,
|
|
120
|
+
properties: { severity: 'Medium' }
|
|
121
|
+
});
|
|
122
|
+
results.push(makeResult(ruleId, 'warning',
|
|
123
|
+
`Weak algorithm "${algoName}" is supported on ${host}:${svc.port}/${svc.protocol || 'tcp'}.`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// dangerousMethods: [...]
|
|
128
|
+
if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) {
|
|
129
|
+
for (const method of svc.dangerousMethods) {
|
|
130
|
+
const ruleId = `http-dangerous-method-${String(method).toLowerCase()}`;
|
|
131
|
+
rules.push({
|
|
132
|
+
id: ruleId,
|
|
133
|
+
shortDescription: { text: `Dangerous HTTP method: ${method}` },
|
|
134
|
+
helpUri: `${TOOL_URI}`,
|
|
135
|
+
properties: { severity: 'Medium' }
|
|
136
|
+
});
|
|
137
|
+
results.push(makeResult(ruleId, 'warning',
|
|
138
|
+
`Dangerous HTTP method "${method}" is allowed on ${host}:${svc.port}.`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// CVE data (if service has cves or cve array)
|
|
143
|
+
const cves = svc.cves || svc.cve || [];
|
|
144
|
+
if (Array.isArray(cves)) {
|
|
145
|
+
for (const cve of cves) {
|
|
146
|
+
const cveId = typeof cve === 'string' ? cve : (cve?.id || cve?.cveId || String(cve));
|
|
147
|
+
const cveSeverity = cve?.severity || 'High';
|
|
148
|
+
const ruleId = cveId;
|
|
149
|
+
rules.push({
|
|
150
|
+
id: ruleId,
|
|
151
|
+
shortDescription: { text: `Known vulnerability: ${cveId}` },
|
|
152
|
+
helpUri: `https://nvd.nist.gov/vuln/detail/${cveId}`,
|
|
153
|
+
properties: { severity: cveSeverity }
|
|
154
|
+
});
|
|
155
|
+
results.push(makeResult(ruleId, severityToLevel(cveSeverity),
|
|
156
|
+
`${cveId} affects ${svc.program || svc.service}${svc.version && svc.version !== 'Unknown' ? ' ' + svc.version : ''} on ${host}:${svc.port}.`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { results, rules };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build a SARIF 2.1.0 log from scan conclusion.
|
|
165
|
+
* @param {{ host: string, conclusion: object, results?: object[] }} scanData
|
|
166
|
+
* @returns {object} SARIF log object
|
|
167
|
+
*/
|
|
168
|
+
export function buildSarifLog(scanData) {
|
|
169
|
+
const { host, conclusion } = scanData;
|
|
170
|
+
const sarifResults = [];
|
|
171
|
+
const rulesMap = new Map();
|
|
172
|
+
|
|
173
|
+
const services = conclusion?.result?.services || [];
|
|
174
|
+
|
|
175
|
+
for (const svc of services) {
|
|
176
|
+
// Base service result
|
|
177
|
+
const ruleId = ruleIdFromService(svc);
|
|
178
|
+
const severity = inferServiceSeverity(svc);
|
|
179
|
+
const level = severityToLevel(severity);
|
|
180
|
+
const message = buildServiceMessage(svc, host);
|
|
181
|
+
|
|
182
|
+
if (!rulesMap.has(ruleId)) {
|
|
183
|
+
rulesMap.set(ruleId, {
|
|
184
|
+
id: ruleId,
|
|
185
|
+
shortDescription: { text: `${svc.service || 'unknown'} service detected` },
|
|
186
|
+
helpUri: TOOL_URI,
|
|
187
|
+
properties: { severity }
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
sarifResults.push({
|
|
192
|
+
ruleId,
|
|
193
|
+
level,
|
|
194
|
+
message: { text: message },
|
|
195
|
+
locations: [{
|
|
196
|
+
physicalLocation: {
|
|
197
|
+
artifactLocation: { uri: host }
|
|
198
|
+
}
|
|
199
|
+
}]
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Security findings
|
|
203
|
+
const { results: secResults, rules: secRules } = securityFindingsFromService(svc, host);
|
|
204
|
+
for (const sr of secResults) sarifResults.push(sr);
|
|
205
|
+
for (const rule of secRules) {
|
|
206
|
+
if (!rulesMap.has(rule.id)) rulesMap.set(rule.id, rule);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
$schema: SARIF_SCHEMA,
|
|
212
|
+
version: SARIF_VERSION,
|
|
213
|
+
runs: [{
|
|
214
|
+
tool: {
|
|
215
|
+
driver: {
|
|
216
|
+
name: TOOL_NAME,
|
|
217
|
+
version: TOOL_VERSION,
|
|
218
|
+
informationUri: TOOL_URI,
|
|
219
|
+
rules: [...rulesMap.values()]
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
results: sarifResults
|
|
223
|
+
}]
|
|
224
|
+
};
|
|
225
|
+
}
|